mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-24 11:36:39 +00:00
Compare commits
253 Commits
1.16.2-s.1
...
logging-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b78b91449 | ||
|
|
f9bff5954f | ||
|
|
2c6e9507b5 | ||
|
|
6471571bc6 | ||
|
|
fe40ea58c1 | ||
|
|
0d4edcd1c7 | ||
|
|
7d8797840a | ||
|
|
19f8c1772f | ||
|
|
37d331e813 | ||
|
|
c660df55cd | ||
|
|
7c8b865379 | ||
|
|
3cca0c09c0 | ||
|
|
7c2b4f422a | ||
|
|
ad2a0ae127 | ||
|
|
6c2c620c99 | ||
|
|
f643abf19a | ||
|
|
a1729033cf | ||
|
|
7311766512 | ||
|
|
17105f3a51 | ||
|
|
edcfbd26e4 | ||
|
|
0c4d9ea164 | ||
|
|
a5a5224f5c | ||
|
|
8773f7c0a7 | ||
|
|
f385bc2d22 | ||
|
|
a8c9d2e7e6 | ||
|
|
db3f90318b | ||
|
|
2d4d0df5ca | ||
|
|
569ebc671d | ||
|
|
8c8e4e6233 | ||
|
|
c7901ef74b | ||
|
|
be3bd72c1b | ||
|
|
73d1f9288d | ||
|
|
fb7e9f6898 | ||
|
|
38e4b3077f | ||
|
|
312cdc563b | ||
|
|
48ff6dd705 | ||
|
|
695e831090 | ||
|
|
046b431bb8 | ||
|
|
ce2704fc1a | ||
|
|
7e89b36188 | ||
|
|
222dd6bba3 | ||
|
|
ca9ab65228 | ||
|
|
ee4e8f7029 | ||
|
|
f86a1eb32b | ||
|
|
ffd648ed74 | ||
|
|
b2b72169fd | ||
|
|
76746fb6e1 | ||
|
|
6258787c73 | ||
|
|
720080e487 | ||
|
|
46ad1317e4 | ||
|
|
cd28720e46 | ||
|
|
38af02ad3c | ||
|
|
5eed547f91 | ||
|
|
d363ee02ed | ||
|
|
594ee31f43 | ||
|
|
56e25d01ae | ||
|
|
d9766b0f99 | ||
|
|
eeaa1d56ad | ||
|
|
e7f5bc585c | ||
|
|
4f26fb7750 | ||
|
|
cdbc190bfc | ||
|
|
1b1f9ab4cf | ||
|
|
2efe6cfdb3 | ||
|
|
517c607ecf | ||
|
|
802e8f7a22 | ||
|
|
c7cfe2efcb | ||
|
|
ae1f36f39a | ||
|
|
a479ef28ac | ||
|
|
ce2cf50b5a | ||
|
|
f48d01acde | ||
|
|
991fed93ee | ||
|
|
26ab63d0e4 | ||
|
|
4843268537 | ||
|
|
03288d2a60 | ||
|
|
f60ae13e4e | ||
|
|
e72697f8b8 | ||
|
|
0c3dc1ad14 | ||
|
|
840fe86f78 | ||
|
|
e079927a5b | ||
|
|
63379964fa | ||
|
|
0cfaf6ed7f | ||
|
|
043ee9e9d2 | ||
|
|
b63e3e5888 | ||
|
|
4f82470506 | ||
|
|
40e21b6f28 | ||
|
|
67fab1928d | ||
|
|
eb98374566 | ||
|
|
1169b68619 | ||
|
|
6c83e78256 | ||
|
|
d3bfd67738 | ||
|
|
0908f0f057 | ||
|
|
2785449c7a | ||
|
|
d2419ba572 | ||
|
|
d44292cf33 | ||
|
|
aed86ce4ba | ||
|
|
2c2be50b19 | ||
|
|
e2db4c6246 | ||
|
|
c4839fee08 | ||
|
|
965b7026f0 | ||
|
|
e14e15fcbb | ||
|
|
4ca5acf158 | ||
|
|
ea41fcc566 | ||
|
|
5736c1d8ce | ||
|
|
d142366dd9 | ||
|
|
bab09dff95 | ||
|
|
23d3345ab9 | ||
|
|
09a64815d4 | ||
|
|
6d5f969798 | ||
|
|
10349932f4 | ||
|
|
9c430b37aa | ||
|
|
86bba494fe | ||
|
|
1a43f1ef4b | ||
|
|
75ab074805 | ||
|
|
dc4e0253de | ||
|
|
cccf236042 | ||
|
|
63fd63c65c | ||
|
|
beee1d692d | ||
|
|
fde786ca84 | ||
|
|
3086fdd064 | ||
|
|
6c30f6db31 | ||
|
|
f021b73458 | ||
|
|
74f4751bcc | ||
|
|
e5bce4e180 | ||
|
|
9b0e7b381c | ||
|
|
90afe5a7ac | ||
|
|
b24de85157 | ||
|
|
eda43dffe1 | ||
|
|
82c9a1eb70 | ||
|
|
a3d4553d14 | ||
|
|
1cc5f59f66 | ||
|
|
4e2d88efdd | ||
|
|
4975cabb2c | ||
|
|
225591094f | ||
|
|
82f88f2cd3 | ||
|
|
99e6bd31b6 | ||
|
|
5c50590d7b | ||
|
|
072c89e704 | ||
|
|
dbdff6812d | ||
|
|
42b9d5158d | ||
|
|
2ba225299e | ||
|
|
cc841d5640 | ||
|
|
fa0818d3fa | ||
|
|
dec358c4cd | ||
|
|
e98f873f81 | ||
|
|
e9a2a7e752 | ||
|
|
06015d5191 | ||
|
|
af688d2a23 | ||
|
|
7d0b3ec6b5 | ||
|
|
cf5fb8dc33 | ||
|
|
2e2684c695 | ||
|
|
7e2fd8f49d | ||
|
|
9a0a255445 | ||
|
|
91b7ceb2cf | ||
|
|
d5a37436c0 | ||
|
|
be609b5000 | ||
|
|
0503c6e66e | ||
|
|
d4b830b9bb | ||
|
|
14d6ff25a7 | ||
|
|
1f62f305ce | ||
|
|
9405b0b70a | ||
|
|
a26ee4ac1a | ||
|
|
cebcf3e337 | ||
|
|
4cfcc64481 | ||
|
|
1a2069a6d9 | ||
|
|
2a5c9465e9 | ||
|
|
f36b66e397 | ||
|
|
8c6d44677d | ||
|
|
1bfff630bf | ||
|
|
ebcef28b05 | ||
|
|
e87e12898c | ||
|
|
d60ab281cf | ||
|
|
483d54a9f0 | ||
|
|
0ab6ff9148 | ||
|
|
c73a39f797 | ||
|
|
c87b6872e5 | ||
|
|
f315c8bc43 | ||
|
|
20fa1519fd | ||
|
|
54430afc40 | ||
|
|
7990d08fee | ||
|
|
e9042d9e2e | ||
|
|
24a15841e4 | ||
|
|
bb8f6e09fd | ||
|
|
04bc8ab694 | ||
|
|
6ac8335cf2 | ||
|
|
4c6144f8fb | ||
|
|
255003794e | ||
|
|
119d5c79a0 | ||
|
|
8e2d7c25df | ||
|
|
753dee3023 | ||
|
|
cac0272952 | ||
|
|
ee5b74f9fc | ||
|
|
1362b72cd3 | ||
|
|
35b1566962 | ||
|
|
a4bcce5a0c | ||
|
|
c03f1946e8 | ||
|
|
c11e107758 | ||
|
|
3b4e49f63a | ||
|
|
ea7253f7e8 | ||
|
|
8a529f7946 | ||
|
|
e76612e018 | ||
|
|
e1f99985d8 | ||
|
|
e0c2735635 | ||
|
|
8e6b4e243d | ||
|
|
2623fa8f02 | ||
|
|
7ff92d32cd | ||
|
|
c7f691b20a | ||
|
|
db042e520e | ||
|
|
b01fcc70fe | ||
|
|
ed95f10fcc | ||
|
|
35fed74e49 | ||
|
|
64bae5b142 | ||
|
|
6cf1b9b010 | ||
|
|
dae169540b | ||
|
|
19f9dda490 | ||
|
|
a060c8029f | ||
|
|
aca9d1e070 | ||
|
|
df53dfc936 | ||
|
|
8e2e09ab81 | ||
|
|
1eac7cbccd | ||
|
|
ddaaed65e4 | ||
|
|
8e633c21c7 | ||
|
|
e7c4ef44d8 | ||
|
|
3d71470bd2 | ||
|
|
dd627a222e | ||
|
|
62cc20fa1c | ||
|
|
0450fc9f57 | ||
|
|
c58aaf5ba6 | ||
|
|
655522d4e2 | ||
|
|
225475dcae | ||
|
|
ccb977fdfb | ||
|
|
280cbb6e22 | ||
|
|
c20babcb53 | ||
|
|
768eebe2cd | ||
|
|
75a909784a | ||
|
|
244f497a9c | ||
|
|
e58f0c9f07 | ||
|
|
5f18c06e03 | ||
|
|
27d52646a0 | ||
|
|
4dd8080c55 | ||
|
|
0b35d4f2e3 | ||
|
|
54a9fb9e54 | ||
|
|
60a9e68f02 | ||
|
|
ad374298e3 | ||
|
|
c5dc4e6127 | ||
|
|
291ad831c5 | ||
|
|
0a018f0ca8 | ||
|
|
6673eeb1bb | ||
|
|
4641f0b9ef | ||
|
|
a4487964e5 | ||
|
|
fe42fdd1ec | ||
|
|
5c4de03588 | ||
|
|
81c1a1da9c | ||
|
|
52f26396ac |
14
.github/workflows/cicd.yml
vendored
14
.github/workflows/cicd.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -264,7 +264,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: 1.24
|
go-version: 1.24
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
- name: Upload artifacts from /install/bin
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: install-bin
|
name: install-bin
|
||||||
path: install/bin/
|
path: install/bin/
|
||||||
@@ -407,7 +407,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -415,7 +415,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
# cosign is used to sign and verify container images (key and keyless)
|
# cosign is used to sign and verify container images (key and keyless)
|
||||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||||
|
|
||||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>
|
<strong>
|
||||||
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -60,9 +60,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
| <img width=500 /> | Description |
|
| <img width=500 /> | Description |
|
||||||
|-----------------|--------------|
|
|-----------------|--------------|
|
||||||
|
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
|
||||||
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
||||||
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
||||||
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/nodes) and connect to our control plane. |
|
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
@@ -85,17 +85,16 @@ Download the Pangolin client for your platform:
|
|||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
|
### Sign up now
|
||||||
|
|
||||||
|
Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available.
|
||||||
|
|
||||||
### Check out the docs
|
### Check out the docs
|
||||||
|
|
||||||
We encourage everyone to read the full documentation first, which is
|
We encourage everyone to read the full documentation first, which is
|
||||||
available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of
|
available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of
|
||||||
the docs to illustrate some basic ideas.
|
the docs to illustrate some basic ideas.
|
||||||
|
|
||||||
### Sign up and try now
|
|
||||||
|
|
||||||
For Pangolin's managed service, you will first need to create an account at
|
|
||||||
[app.pangolin.net](https://app.pangolin.net). We have a generous free tier to get started.
|
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
module installer
|
module installer
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v0.8.0
|
github.com/charmbracelet/huh v0.8.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.40.0
|
golang.org/x/term v0.41.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Прокси заявки чрез HTTPS, използвайки напълно квалифицирано име на домейн.",
|
"resourceHTTPDescription": "Прокси заявки чрез HTTPS, използвайки напълно квалифицирано име на домейн.",
|
||||||
"resourceRaw": "Суров TCP/UDP ресурс",
|
"resourceRaw": "Суров TCP/UDP ресурс",
|
||||||
"resourceRawDescription": "Прокси заявки чрез сурови TCP/UDP, използвайки порт номер.",
|
"resourceRawDescription": "Прокси заявки чрез сурови TCP/UDP, използвайки порт номер.",
|
||||||
|
"resourceRawDescriptionCloud": "Получавайте заявки чрез суров TCP/UDP с използване на портен номер. Изисква се сайтовете да се свързват към отдалечен възел.",
|
||||||
"resourceCreate": "Създайте ресурс",
|
"resourceCreate": "Създайте ресурс",
|
||||||
"resourceCreateDescription": "Следвайте стъпките по-долу, за да създадете нов ресурс",
|
"resourceCreateDescription": "Следвайте стъпките по-долу, за да създадете нов ресурс",
|
||||||
"resourceSeeAll": "Вижте всички ресурси",
|
"resourceSeeAll": "Вижте всички ресурси",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Получаване на потребител",
|
"actionGetUser": "Получаване на потребител",
|
||||||
"actionGetOrgUser": "Вземете потребител на организация",
|
"actionGetOrgUser": "Вземете потребител на организация",
|
||||||
"actionListOrgDomains": "Изброяване на домейни на организация",
|
"actionListOrgDomains": "Изброяване на домейни на организация",
|
||||||
|
"actionGetDomain": "Вземи домейн",
|
||||||
|
"actionCreateOrgDomain": "Създай домейн",
|
||||||
|
"actionUpdateOrgDomain": "Актуализирай домейн",
|
||||||
|
"actionDeleteOrgDomain": "Изтрий домейн",
|
||||||
|
"actionGetDNSRecords": "Вземи DNS записи",
|
||||||
|
"actionRestartOrgDomain": "Рестартирай домейн",
|
||||||
"actionCreateSite": "Създаване на сайт",
|
"actionCreateSite": "Създаване на сайт",
|
||||||
"actionDeleteSite": "Изтриване на сайта",
|
"actionDeleteSite": "Изтриване на сайта",
|
||||||
"actionGetSite": "Вземете сайт",
|
"actionGetSite": "Вземете сайт",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Име на пространство: {namespace}",
|
"domainPickerNamespace": "Име на пространство: {namespace}",
|
||||||
"domainPickerShowMore": "Покажи повече",
|
"domainPickerShowMore": "Покажи повече",
|
||||||
"regionSelectorTitle": "Избор на регион",
|
"regionSelectorTitle": "Избор на регион",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Предоставените домейни не се поддържат, когато сайтовете се свързват към отдалечени крайни възли. За да бъдат ресурсите налични на отдалечени възли, използвайте персонализиран домейн вместо това.",
|
||||||
"regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.",
|
"regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.",
|
||||||
"regionSelectorPlaceholder": "Изберете регион",
|
"regionSelectorPlaceholder": "Изберете регион",
|
||||||
"regionSelectorComingSoon": "Очаква се скоро",
|
"regionSelectorComingSoon": "Очаква се скоро",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.",
|
"sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.",
|
||||||
"sshSudo": "Разреши sudo",
|
"sshSudo": "Разреши sudo",
|
||||||
"sshSudoCommands": "Sudo команди",
|
"sshSudoCommands": "Sudo команди",
|
||||||
"sshSudoCommandsDescription": "Списък с команди, които потребителят е разрешено да изпълнява с sudo.",
|
"sshSudoCommandsDescription": "Списък, разделен със запетаи, с команди, които потребителят е позволено да изпълнява с sudo.",
|
||||||
"sshCreateHomeDir": "Създай начална директория",
|
"sshCreateHomeDir": "Създай начална директория",
|
||||||
"sshUnixGroups": "Unix групи",
|
"sshUnixGroups": "Unix групи",
|
||||||
"sshUnixGroupsDescription": "Unix групи, в които да добавите потребителя на целевия хост.",
|
"sshUnixGroupsDescription": "Списък, разделен със запетаи, с Unix групи, към които да се добави потребителят на целевия хост.",
|
||||||
"retryAttempts": "Опити за повторно",
|
"retryAttempts": "Опити за повторно",
|
||||||
"expectedResponseCodes": "Очаквани кодове за отговор",
|
"expectedResponseCodes": "Очаквани кодове за отговор",
|
||||||
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
|
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Край на следващата година",
|
"logRetentionEndOfFollowingYear": "Край на следващата година",
|
||||||
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
|
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
|
||||||
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
|
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
|
||||||
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> или <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> за използване на тази функция. <bookADemoLink>Резервирайте демонстрация или пробен POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Необходимо е <enterpriseEditionLink>изданието Enterprise</enterpriseEditionLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> е необходим за използване на тази функция. Тази функция също е налична в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Резервирайте демонстрация или пробен POC</bookADemoLink>.",
|
||||||
"certResolver": "Решавач на сертификати",
|
"certResolver": "Решавач на сертификати",
|
||||||
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
|
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
|
||||||
"selectCertResolver": "Изберете решавач на сертификати",
|
"selectCertResolver": "Изберете решавач на сертификати",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства",
|
"approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства",
|
||||||
"approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.",
|
"approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед",
|
"approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед",
|
||||||
"approvalsEmptyStateButtonText": "Управлявайте роли"
|
"approvalsEmptyStateButtonText": "Управлявайте роли",
|
||||||
|
"domainErrorTitle": "Имаме проблем с проверката на вашия домейн"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy požadavky přes HTTPS pomocí plně kvalifikovaného názvu domény.",
|
"resourceHTTPDescription": "Proxy požadavky přes HTTPS pomocí plně kvalifikovaného názvu domény.",
|
||||||
"resourceRaw": "Surový TCP/UDP zdroj",
|
"resourceRaw": "Surový TCP/UDP zdroj",
|
||||||
"resourceRawDescription": "Proxy požadavky přes nezpracovaný TCP/UDP pomocí čísla portu.",
|
"resourceRawDescription": "Proxy požadavky přes nezpracovaný TCP/UDP pomocí čísla portu.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy požadavky na syrové TCP/UDP pomocí čísla portu. Vyžaduje připojení stránek ke vzdálenému uzlu.",
|
||||||
"resourceCreate": "Vytvořit zdroj",
|
"resourceCreate": "Vytvořit zdroj",
|
||||||
"resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj",
|
"resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj",
|
||||||
"resourceSeeAll": "Zobrazit všechny zdroje",
|
"resourceSeeAll": "Zobrazit všechny zdroje",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Získat uživatele",
|
"actionGetUser": "Získat uživatele",
|
||||||
"actionGetOrgUser": "Získat uživatele organizace",
|
"actionGetOrgUser": "Získat uživatele organizace",
|
||||||
"actionListOrgDomains": "Seznam domén organizace",
|
"actionListOrgDomains": "Seznam domén organizace",
|
||||||
|
"actionGetDomain": "Získat doménu",
|
||||||
|
"actionCreateOrgDomain": "Vytvořit doménu",
|
||||||
|
"actionUpdateOrgDomain": "Aktualizovat doménu",
|
||||||
|
"actionDeleteOrgDomain": "Odstranit doménu",
|
||||||
|
"actionGetDNSRecords": "Získat záznamy DNS",
|
||||||
|
"actionRestartOrgDomain": "Restartovat doménu",
|
||||||
"actionCreateSite": "Vytvořit lokalitu",
|
"actionCreateSite": "Vytvořit lokalitu",
|
||||||
"actionDeleteSite": "Odstranění lokality",
|
"actionDeleteSite": "Odstranění lokality",
|
||||||
"actionGetSite": "Získat web",
|
"actionGetSite": "Získat web",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Jmenný prostor: {namespace}",
|
"domainPickerNamespace": "Jmenný prostor: {namespace}",
|
||||||
"domainPickerShowMore": "Zobrazit více",
|
"domainPickerShowMore": "Zobrazit více",
|
||||||
"regionSelectorTitle": "Vybrat region",
|
"regionSelectorTitle": "Vybrat region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Poskytnuté domény nejsou podporovány, když se stránky připojují k vzdáleným výstupním uzlům. Pro dostupné zdroje na vzdálených uzlech použijte vlastní doménu.",
|
||||||
"regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.",
|
"regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.",
|
||||||
"regionSelectorPlaceholder": "Vyberte region",
|
"regionSelectorPlaceholder": "Vyberte region",
|
||||||
"regionSelectorComingSoon": "Již brzy",
|
"regionSelectorComingSoon": "Již brzy",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.",
|
"sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.",
|
||||||
"sshSudo": "Povolit sudo",
|
"sshSudo": "Povolit sudo",
|
||||||
"sshSudoCommands": "Sudo příkazy",
|
"sshSudoCommands": "Sudo příkazy",
|
||||||
"sshSudoCommandsDescription": "Seznam příkazů, které může uživatel spouštět s sudo.",
|
"sshSudoCommandsDescription": "Čárkami oddělený seznam příkazů, které může uživatel spouštět s sudo.",
|
||||||
"sshCreateHomeDir": "Vytvořit domovský adresář",
|
"sshCreateHomeDir": "Vytvořit domovský adresář",
|
||||||
"sshUnixGroups": "Unixové skupiny",
|
"sshUnixGroups": "Unixové skupiny",
|
||||||
"sshUnixGroupsDescription": "Unix skupiny přidají uživatele do cílového hostitele.",
|
"sshUnixGroupsDescription": "Čárkou oddělené skupiny Unix přidají uživatele do cílového hostitele.",
|
||||||
"retryAttempts": "Opakovat pokusy",
|
"retryAttempts": "Opakovat pokusy",
|
||||||
"expectedResponseCodes": "Očekávané kódy odezvy",
|
"expectedResponseCodes": "Očekávané kódy odezvy",
|
||||||
"expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.",
|
"expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
|
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
|
||||||
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
|
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
|
||||||
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
|
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
|
||||||
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Tato funkce je také dostupná v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> nebo <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> . <bookADemoLink>Zarezervujte si demo nebo POC zkušební verzi</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Rezervujte si demo nebo POC zkušební verzi</bookADemoLink>.",
|
||||||
"certResolver": "Oddělovač certifikátů",
|
"certResolver": "Oddělovač certifikátů",
|
||||||
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
|
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
|
||||||
"selectCertResolver": "Vyberte řešič certifikátů",
|
"selectCertResolver": "Vyberte řešič certifikátů",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Povolit schválení zařízení",
|
"approvalsEmptyStateStep2Title": "Povolit schválení zařízení",
|
||||||
"approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.",
|
"approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi",
|
"approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi",
|
||||||
"approvalsEmptyStateButtonText": "Spravovat role"
|
"approvalsEmptyStateButtonText": "Spravovat role",
|
||||||
|
"domainErrorTitle": "Máme problém s ověřením tvé domény"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy-Anfragen über HTTPS mit einem voll qualifizierten Domain-Namen.",
|
"resourceHTTPDescription": "Proxy-Anfragen über HTTPS mit einem voll qualifizierten Domain-Namen.",
|
||||||
"resourceRaw": "Direkte TCP/UDP Ressource (raw)",
|
"resourceRaw": "Direkte TCP/UDP Ressource (raw)",
|
||||||
"resourceRawDescription": "Proxy-Anfragen über rohes TCP/UDP mit einer Portnummer.",
|
"resourceRawDescription": "Proxy-Anfragen über rohes TCP/UDP mit einer Portnummer.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy-Anfragen über rohe TCP/UDP mit Portnummer. Benötigt Sites, um sich mit einem entfernten Knoten zu verbinden.",
|
||||||
"resourceCreate": "Ressource erstellen",
|
"resourceCreate": "Ressource erstellen",
|
||||||
"resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen",
|
"resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen",
|
||||||
"resourceSeeAll": "Alle Ressourcen anzeigen",
|
"resourceSeeAll": "Alle Ressourcen anzeigen",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Benutzer abrufen",
|
"actionGetUser": "Benutzer abrufen",
|
||||||
"actionGetOrgUser": "Organisationsbenutzer abrufen",
|
"actionGetOrgUser": "Organisationsbenutzer abrufen",
|
||||||
"actionListOrgDomains": "Organisationsdomains auflisten",
|
"actionListOrgDomains": "Organisationsdomains auflisten",
|
||||||
|
"actionGetDomain": "Domain abrufen",
|
||||||
|
"actionCreateOrgDomain": "Domain erstellen",
|
||||||
|
"actionUpdateOrgDomain": "Domain aktualisieren",
|
||||||
|
"actionDeleteOrgDomain": "Domain löschen",
|
||||||
|
"actionGetDNSRecords": "DNS-Einträge abrufen",
|
||||||
|
"actionRestartOrgDomain": "Domain neu starten",
|
||||||
"actionCreateSite": "Standort erstellen",
|
"actionCreateSite": "Standort erstellen",
|
||||||
"actionDeleteSite": "Standort löschen",
|
"actionDeleteSite": "Standort löschen",
|
||||||
"actionGetSite": "Standort abrufen",
|
"actionGetSite": "Standort abrufen",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Mehr anzeigen",
|
"domainPickerShowMore": "Mehr anzeigen",
|
||||||
"regionSelectorTitle": "Region auswählen",
|
"regionSelectorTitle": "Region auswählen",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Angegebene Domains werden nicht unterstützt, wenn sich Websites mit externen Exit-Knoten verbinden. Damit Ressourcen auf entfernten Knoten verfügbar sind, verwenden Sie stattdessen eine eigene Domain.",
|
||||||
"regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.",
|
"regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.",
|
||||||
"regionSelectorPlaceholder": "Wähle eine Region",
|
"regionSelectorPlaceholder": "Wähle eine Region",
|
||||||
"regionSelectorComingSoon": "Kommt bald",
|
"regionSelectorComingSoon": "Kommt bald",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.",
|
"sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.",
|
||||||
"sshSudo": "sudo erlauben",
|
"sshSudo": "sudo erlauben",
|
||||||
"sshSudoCommands": "Sudo-Befehle",
|
"sshSudoCommands": "Sudo-Befehle",
|
||||||
"sshSudoCommandsDescription": "Liste der Befehle, die der Benutzer mit sudo ausführen darf.",
|
"sshSudoCommandsDescription": "Kommagetrennte Liste von Befehlen, die der Benutzer mit sudo ausführen darf.",
|
||||||
"sshCreateHomeDir": "Home-Verzeichnis erstellen",
|
"sshCreateHomeDir": "Home-Verzeichnis erstellen",
|
||||||
"sshUnixGroups": "Unix-Gruppen",
|
"sshUnixGroups": "Unix-Gruppen",
|
||||||
"sshUnixGroupsDescription": "Unix-Gruppen, zu denen der Benutzer auf dem Ziel-Host hinzugefügt wird.",
|
"sshUnixGroupsDescription": "Durch Komma getrennte Unix-Gruppen, um den Benutzer auf dem Zielhost hinzuzufügen.",
|
||||||
"retryAttempts": "Wiederholungsversuche",
|
"retryAttempts": "Wiederholungsversuche",
|
||||||
"expectedResponseCodes": "Erwartete Antwortcodes",
|
"expectedResponseCodes": "Erwartete Antwortcodes",
|
||||||
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
|
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
|
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
|
||||||
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
|
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
|
||||||
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
|
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
|
||||||
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
|
"licenseRequiredToUse": "Eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz oder <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> wird benötigt, um diese Funktion nutzen zu können. <bookADemoLink>Buchen Sie eine Demo oder POC Testversion</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Um diese Funktion nutzen zu können, ist die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
|
"ossEnterpriseEditionRequired": "Die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> wird benötigt, um diese Funktion nutzen zu können. Diese Funktion ist auch in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>verfügbar. <bookADemoLink>Buchen Sie eine Demo oder POC Testversion</bookADemoLink>.",
|
||||||
"certResolver": "Zertifikatsauflöser",
|
"certResolver": "Zertifikatsauflöser",
|
||||||
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
|
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
|
||||||
"selectCertResolver": "Zertifikatsauflöser auswählen",
|
"selectCertResolver": "Zertifikatsauflöser auswählen",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren",
|
"approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren",
|
||||||
"approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.",
|
"approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt",
|
"approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt",
|
||||||
"approvalsEmptyStateButtonText": "Rollen verwalten"
|
"approvalsEmptyStateButtonText": "Rollen verwalten",
|
||||||
|
"domainErrorTitle": "Wir haben Probleme mit der Überprüfung deiner Domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||||
"resourceRaw": "Raw TCP/UDP Resource",
|
"resourceRaw": "Raw TCP/UDP Resource",
|
||||||
"resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
|
"resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
@@ -1119,6 +1120,7 @@
|
|||||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||||
"setupTokenRequired": "Setup token is required",
|
"setupTokenRequired": "Setup token is required",
|
||||||
"actionUpdateSite": "Update Site",
|
"actionUpdateSite": "Update Site",
|
||||||
|
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
||||||
"actionListSiteRoles": "List Allowed Site Roles",
|
"actionListSiteRoles": "List Allowed Site Roles",
|
||||||
"actionCreateResource": "Create Resource",
|
"actionCreateResource": "Create Resource",
|
||||||
"actionDeleteResource": "Delete Resource",
|
"actionDeleteResource": "Delete Resource",
|
||||||
@@ -1426,6 +1428,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Show More",
|
"domainPickerShowMore": "Show More",
|
||||||
"regionSelectorTitle": "Select Region",
|
"regionSelectorTitle": "Select Region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.",
|
"regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.",
|
||||||
"regionSelectorPlaceholder": "Choose a region",
|
"regionSelectorPlaceholder": "Choose a region",
|
||||||
"regionSelectorComingSoon": "Coming Soon",
|
"regionSelectorComingSoon": "Coming Soon",
|
||||||
@@ -2342,8 +2345,14 @@
|
|||||||
"logRetentionEndOfFollowingYear": "End of following year",
|
"logRetentionEndOfFollowingYear": "End of following year",
|
||||||
"actionLogsDescription": "View a history of actions performed in this organization",
|
"actionLogsDescription": "View a history of actions performed in this organization",
|
||||||
"accessLogsDescription": "View access auth requests for resources in this organization",
|
"accessLogsDescription": "View access auth requests for resources in this organization",
|
||||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.",
|
"connectionLogs": "Connection Logs",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"connectionLogsDescription": "View connection logs for tunnels in this organization",
|
||||||
|
"sidebarLogsConnection": "Connection Logs",
|
||||||
|
"sourceAddress": "Source Address",
|
||||||
|
"destinationAddress": "Destination Address",
|
||||||
|
"duration": "Duration",
|
||||||
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
@@ -2680,5 +2689,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
||||||
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
||||||
"approvalsEmptyStateButtonText": "Manage Roles"
|
"approvalsEmptyStateButtonText": "Manage Roles",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.",
|
"resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.",
|
||||||
"resourceRaw": "Recurso TCP/UDP sin procesar",
|
"resourceRaw": "Recurso TCP/UDP sin procesar",
|
||||||
"resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.",
|
"resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.",
|
||||||
|
"resourceRawDescriptionCloud": "Las peticiones de proxy sobre TCP/UDP crudas usando un número de puerto. Requiere que los sitios se conecten a un nodo remoto.",
|
||||||
"resourceCreate": "Crear Recurso",
|
"resourceCreate": "Crear Recurso",
|
||||||
"resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso",
|
"resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso",
|
||||||
"resourceSeeAll": "Ver todos los recursos",
|
"resourceSeeAll": "Ver todos los recursos",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Obtener usuario",
|
"actionGetUser": "Obtener usuario",
|
||||||
"actionGetOrgUser": "Obtener usuario de la organización",
|
"actionGetOrgUser": "Obtener usuario de la organización",
|
||||||
"actionListOrgDomains": "Listar dominios de la organización",
|
"actionListOrgDomains": "Listar dominios de la organización",
|
||||||
|
"actionGetDomain": "Obtener dominio",
|
||||||
|
"actionCreateOrgDomain": "Crear dominio",
|
||||||
|
"actionUpdateOrgDomain": "Actualizar dominio",
|
||||||
|
"actionDeleteOrgDomain": "Eliminar dominio",
|
||||||
|
"actionGetDNSRecords": "Obtener registros DNS",
|
||||||
|
"actionRestartOrgDomain": "Reiniciar dominio",
|
||||||
"actionCreateSite": "Crear sitio",
|
"actionCreateSite": "Crear sitio",
|
||||||
"actionDeleteSite": "Eliminar sitio",
|
"actionDeleteSite": "Eliminar sitio",
|
||||||
"actionGetSite": "Obtener sitio",
|
"actionGetSite": "Obtener sitio",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Espacio de nombres: {namespace}",
|
"domainPickerNamespace": "Espacio de nombres: {namespace}",
|
||||||
"domainPickerShowMore": "Mostrar más",
|
"domainPickerShowMore": "Mostrar más",
|
||||||
"regionSelectorTitle": "Seleccionar Región",
|
"regionSelectorTitle": "Seleccionar Región",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Los dominios suministrados no son compatibles cuando los sitios se conectan a nodos de salida remotos. Para que los recursos estén disponibles en nodos remotos, utilice un dominio personalizado en su lugar.",
|
||||||
"regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.",
|
"regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.",
|
||||||
"regionSelectorPlaceholder": "Elige una región",
|
"regionSelectorPlaceholder": "Elige una región",
|
||||||
"regionSelectorComingSoon": "Próximamente",
|
"regionSelectorComingSoon": "Próximamente",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.",
|
"sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.",
|
||||||
"sshSudo": "Permitir sudo",
|
"sshSudo": "Permitir sudo",
|
||||||
"sshSudoCommands": "Comandos Sudo",
|
"sshSudoCommands": "Comandos Sudo",
|
||||||
"sshSudoCommandsDescription": "Lista de comandos que el usuario puede ejecutar con sudo.",
|
"sshSudoCommandsDescription": "Lista separada por comas de comandos que el usuario puede ejecutar con sudo.",
|
||||||
"sshCreateHomeDir": "Crear directorio principal",
|
"sshCreateHomeDir": "Crear directorio principal",
|
||||||
"sshUnixGroups": "Grupos Unix",
|
"sshUnixGroups": "Grupos Unix",
|
||||||
"sshUnixGroupsDescription": "Grupos Unix para agregar el usuario en el host de destino.",
|
"sshUnixGroupsDescription": "Grupos Unix separados por comas para agregar el usuario en el host de destino.",
|
||||||
"retryAttempts": "Intentos de Reintento",
|
"retryAttempts": "Intentos de Reintento",
|
||||||
"expectedResponseCodes": "Códigos de respuesta esperados",
|
"expectedResponseCodes": "Códigos de respuesta esperados",
|
||||||
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
|
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
||||||
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
||||||
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
||||||
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> para utilizar esta función. Esta característica también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> para usar esta función. <bookADemoLink>Reserve una demostración o prueba POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>versión Enterprise</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserva una demostración o prueba POC</bookADemoLink>.",
|
||||||
"certResolver": "Resolver certificado",
|
"certResolver": "Resolver certificado",
|
||||||
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
||||||
"selectCertResolver": "Seleccionar Resolver Certificado",
|
"selectCertResolver": "Seleccionar Resolver Certificado",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo",
|
"approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo",
|
||||||
"approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.",
|
"approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión",
|
"approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión",
|
||||||
"approvalsEmptyStateButtonText": "Administrar roles"
|
"approvalsEmptyStateButtonText": "Administrar roles",
|
||||||
|
"domainErrorTitle": "Estamos teniendo problemas para verificar su dominio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy les demandes sur HTTPS en utilisant un nom de domaine entièrement qualifié.",
|
"resourceHTTPDescription": "Proxy les demandes sur HTTPS en utilisant un nom de domaine entièrement qualifié.",
|
||||||
"resourceRaw": "Ressource TCP/UDP brute",
|
"resourceRaw": "Ressource TCP/UDP brute",
|
||||||
"resourceRawDescription": "Proxy les demandes sur TCP/UDP brut en utilisant un numéro de port.",
|
"resourceRawDescription": "Proxy les demandes sur TCP/UDP brut en utilisant un numéro de port.",
|
||||||
|
"resourceRawDescriptionCloud": "Requêtes de proxy sur TCP/UDP brute en utilisant un numéro de port. Nécessite des sites pour se connecter à un noeud distant.",
|
||||||
"resourceCreate": "Créer une ressource",
|
"resourceCreate": "Créer une ressource",
|
||||||
"resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource",
|
"resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource",
|
||||||
"resourceSeeAll": "Voir toutes les ressources",
|
"resourceSeeAll": "Voir toutes les ressources",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Obtenir l'utilisateur",
|
"actionGetUser": "Obtenir l'utilisateur",
|
||||||
"actionGetOrgUser": "Obtenir l'utilisateur de l'organisation",
|
"actionGetOrgUser": "Obtenir l'utilisateur de l'organisation",
|
||||||
"actionListOrgDomains": "Lister les domaines de l'organisation",
|
"actionListOrgDomains": "Lister les domaines de l'organisation",
|
||||||
|
"actionGetDomain": "Obtenir un domaine",
|
||||||
|
"actionCreateOrgDomain": "Créer un domaine",
|
||||||
|
"actionUpdateOrgDomain": "Mettre à jour le domaine",
|
||||||
|
"actionDeleteOrgDomain": "Supprimer le domaine",
|
||||||
|
"actionGetDNSRecords": "Récupérer les enregistrements DNS",
|
||||||
|
"actionRestartOrgDomain": "Redémarrer le domaine",
|
||||||
"actionCreateSite": "Créer un site",
|
"actionCreateSite": "Créer un site",
|
||||||
"actionDeleteSite": "Supprimer un site",
|
"actionDeleteSite": "Supprimer un site",
|
||||||
"actionGetSite": "Obtenir un site",
|
"actionGetSite": "Obtenir un site",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Espace de noms : {namespace}",
|
"domainPickerNamespace": "Espace de noms : {namespace}",
|
||||||
"domainPickerShowMore": "Afficher plus",
|
"domainPickerShowMore": "Afficher plus",
|
||||||
"regionSelectorTitle": "Sélectionner Région",
|
"regionSelectorTitle": "Sélectionner Région",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Les domaines fournis ne sont pas pris en charge lorsque les sites se connectent à des nœuds de sortie distants. Pour que les ressources soient disponibles sur des nœuds distants, utilisez un domaine personnalisé à la place.",
|
||||||
"regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.",
|
"regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.",
|
||||||
"regionSelectorPlaceholder": "Choisissez une région",
|
"regionSelectorPlaceholder": "Choisissez une région",
|
||||||
"regionSelectorComingSoon": "Bientôt disponible",
|
"regionSelectorComingSoon": "Bientôt disponible",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.",
|
"sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.",
|
||||||
"sshSudo": "Autoriser sudo",
|
"sshSudo": "Autoriser sudo",
|
||||||
"sshSudoCommands": "Commandes Sudo",
|
"sshSudoCommands": "Commandes Sudo",
|
||||||
"sshSudoCommandsDescription": "Liste des commandes que l'utilisateur est autorisé à exécuter avec sudo.",
|
"sshSudoCommandsDescription": "Liste des commandes séparées par des virgules que l'utilisateur est autorisé à exécuter avec sudo.",
|
||||||
"sshCreateHomeDir": "Créer un répertoire personnel",
|
"sshCreateHomeDir": "Créer un répertoire personnel",
|
||||||
"sshUnixGroups": "Groupes Unix",
|
"sshUnixGroups": "Groupes Unix",
|
||||||
"sshUnixGroupsDescription": "Groupes Unix à ajouter à l'utilisateur sur l'hôte cible.",
|
"sshUnixGroupsDescription": "Groupes Unix séparés par des virgules pour ajouter l'utilisateur sur l'hôte cible.",
|
||||||
"retryAttempts": "Tentatives de réessai",
|
"retryAttempts": "Tentatives de réessai",
|
||||||
"expectedResponseCodes": "Codes de réponse attendus",
|
"expectedResponseCodes": "Codes de réponse attendus",
|
||||||
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
|
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
|
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
|
||||||
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
|
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
|
||||||
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
|
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
|
||||||
"licenseRequiredToUse": "Une licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> est nécessaire pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Une <enterpriseLicenseLink>licence Enterprise Edition</enterpriseLicenseLink> ou <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> est requise pour utiliser cette fonctionnalité. <bookADemoLink>Réservez une démonstration ou une évaluation de POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Réservez une démo ou un essai POC</bookADemoLink>.",
|
||||||
"certResolver": "Résolveur de certificat",
|
"certResolver": "Résolveur de certificat",
|
||||||
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
|
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
|
||||||
"selectCertResolver": "Sélectionnez le résolveur de certificat",
|
"selectCertResolver": "Sélectionnez le résolveur de certificat",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil",
|
"approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil",
|
||||||
"approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.",
|
"approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification",
|
"approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification",
|
||||||
"approvalsEmptyStateButtonText": "Gérer les rôles"
|
"approvalsEmptyStateButtonText": "Gérer les rôles",
|
||||||
|
"domainErrorTitle": "Nous avons des difficultés à vérifier votre domaine"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
|
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
|
||||||
"resourceRaw": "Risorsa Raw TCP/UDP",
|
"resourceRaw": "Risorsa Raw TCP/UDP",
|
||||||
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.",
|
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.",
|
||||||
|
"resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.",
|
||||||
"resourceCreate": "Crea Risorsa",
|
"resourceCreate": "Crea Risorsa",
|
||||||
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
|
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
|
||||||
"resourceSeeAll": "Vedi Tutte Le Risorse",
|
"resourceSeeAll": "Vedi Tutte Le Risorse",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Ottieni Utente",
|
"actionGetUser": "Ottieni Utente",
|
||||||
"actionGetOrgUser": "Ottieni Utente Organizzazione",
|
"actionGetOrgUser": "Ottieni Utente Organizzazione",
|
||||||
"actionListOrgDomains": "Elenca Domini Organizzazione",
|
"actionListOrgDomains": "Elenca Domini Organizzazione",
|
||||||
|
"actionGetDomain": "Ottieni Dominio",
|
||||||
|
"actionCreateOrgDomain": "Crea Dominio",
|
||||||
|
"actionUpdateOrgDomain": "Aggiorna Dominio",
|
||||||
|
"actionDeleteOrgDomain": "Elimina Dominio",
|
||||||
|
"actionGetDNSRecords": "Ottieni Record DNS",
|
||||||
|
"actionRestartOrgDomain": "Riavvia Dominio",
|
||||||
"actionCreateSite": "Crea Sito",
|
"actionCreateSite": "Crea Sito",
|
||||||
"actionDeleteSite": "Elimina Sito",
|
"actionDeleteSite": "Elimina Sito",
|
||||||
"actionGetSite": "Ottieni Sito",
|
"actionGetSite": "Ottieni Sito",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Mostra Altro",
|
"domainPickerShowMore": "Mostra Altro",
|
||||||
"regionSelectorTitle": "Seleziona regione",
|
"regionSelectorTitle": "Seleziona regione",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "I domini forniti non sono supportati quando i siti si connettono a nodi di uscita remoti. Affinché le risorse siano disponibili su nodi remoti, utilizza invece un dominio personalizzato.",
|
||||||
"regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.",
|
"regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.",
|
||||||
"regionSelectorPlaceholder": "Scegli una regione",
|
"regionSelectorPlaceholder": "Scegli una regione",
|
||||||
"regionSelectorComingSoon": "Prossimamente",
|
"regionSelectorComingSoon": "Prossimamente",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.",
|
"sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.",
|
||||||
"sshSudo": "Consenti sudo",
|
"sshSudo": "Consenti sudo",
|
||||||
"sshSudoCommands": "Comandi Sudo",
|
"sshSudoCommands": "Comandi Sudo",
|
||||||
"sshSudoCommandsDescription": "Elenco di comandi che l'utente può eseguire con sudo.",
|
"sshSudoCommandsDescription": "Elenco di comandi separati da virgole che l'utente può eseguire con sudo.",
|
||||||
"sshCreateHomeDir": "Crea Cartella Home",
|
"sshCreateHomeDir": "Crea Cartella Home",
|
||||||
"sshUnixGroups": "Gruppi Unix",
|
"sshUnixGroups": "Gruppi Unix",
|
||||||
"sshUnixGroupsDescription": "Gruppi Unix su cui aggiungere l'utente sull'host di destinazione.",
|
"sshUnixGroupsDescription": "Gruppi Unix separati da virgole per aggiungere l'utente sull'host di destinazione.",
|
||||||
"retryAttempts": "Tentativi di Riprova",
|
"retryAttempts": "Tentativi di Riprova",
|
||||||
"expectedResponseCodes": "Codici di Risposta Attesi",
|
"expectedResponseCodes": "Codici di Risposta Attesi",
|
||||||
"expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.",
|
"expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
|
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
|
||||||
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
|
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
|
||||||
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
|
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
|
||||||
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> . <bookADemoLink>Prenota una demo o una prova POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Prenota una demo o una prova POC</bookADemoLink>.",
|
||||||
"certResolver": "Risolutore Di Certificato",
|
"certResolver": "Risolutore Di Certificato",
|
||||||
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
|
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
|
||||||
"selectCertResolver": "Seleziona Risolutore Di Certificato",
|
"selectCertResolver": "Seleziona Risolutore Di Certificato",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo",
|
"approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo",
|
||||||
"approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.",
|
"approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione",
|
"approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione",
|
||||||
"approvalsEmptyStateButtonText": "Gestisci Ruoli"
|
"approvalsEmptyStateButtonText": "Gestisci Ruoli",
|
||||||
|
"domainErrorTitle": "Stiamo avendo problemi a verificare il tuo dominio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "완전한 도메인 이름을 사용해 RAW 또는 HTTPS로 프록시 요청을 수행합니다.",
|
"resourceHTTPDescription": "완전한 도메인 이름을 사용해 RAW 또는 HTTPS로 프록시 요청을 수행합니다.",
|
||||||
"resourceRaw": "원시 TCP/UDP 리소스",
|
"resourceRaw": "원시 TCP/UDP 리소스",
|
||||||
"resourceRawDescription": "포트 번호를 사용하여 RAW TCP/UDP로 요청을 프록시합니다.",
|
"resourceRawDescription": "포트 번호를 사용하여 RAW TCP/UDP로 요청을 프록시합니다.",
|
||||||
|
"resourceRawDescriptionCloud": "포트 번호를 사용하여 원격 노드에 연결해야 합니다. 원격 노드에서 리소스를 사용하려면 사용자 지정 도메인을 사용하십시오.",
|
||||||
"resourceCreate": "리소스 생성",
|
"resourceCreate": "리소스 생성",
|
||||||
"resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.",
|
"resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.",
|
||||||
"resourceSeeAll": "모든 리소스 보기",
|
"resourceSeeAll": "모든 리소스 보기",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "사용자 조회",
|
"actionGetUser": "사용자 조회",
|
||||||
"actionGetOrgUser": "조직 사용자 가져오기",
|
"actionGetOrgUser": "조직 사용자 가져오기",
|
||||||
"actionListOrgDomains": "조직 도메인 목록",
|
"actionListOrgDomains": "조직 도메인 목록",
|
||||||
|
"actionGetDomain": "도메인 가져오기",
|
||||||
|
"actionCreateOrgDomain": "도메인 생성",
|
||||||
|
"actionUpdateOrgDomain": "도메인 업데이트",
|
||||||
|
"actionDeleteOrgDomain": "도메인 삭제",
|
||||||
|
"actionGetDNSRecords": "DNS 레코드 가져오기",
|
||||||
|
"actionRestartOrgDomain": "도메인 재시작",
|
||||||
"actionCreateSite": "사이트 생성",
|
"actionCreateSite": "사이트 생성",
|
||||||
"actionDeleteSite": "사이트 삭제",
|
"actionDeleteSite": "사이트 삭제",
|
||||||
"actionGetSite": "사이트 가져오기",
|
"actionGetSite": "사이트 가져오기",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "이름 공간: {namespace}",
|
"domainPickerNamespace": "이름 공간: {namespace}",
|
||||||
"domainPickerShowMore": "더보기",
|
"domainPickerShowMore": "더보기",
|
||||||
"regionSelectorTitle": "지역 선택",
|
"regionSelectorTitle": "지역 선택",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "제공된 도메인은 원격 종료 노드에 연결된 사이트에서 지원되지 않습니다. 원격 노드에서 리소스를 사용하려면 사용자 지정 도메인을 사용하십시오.",
|
||||||
"regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.",
|
"regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.",
|
||||||
"regionSelectorPlaceholder": "지역 선택",
|
"regionSelectorPlaceholder": "지역 선택",
|
||||||
"regionSelectorComingSoon": "곧 출시 예정",
|
"regionSelectorComingSoon": "곧 출시 예정",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.",
|
"sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.",
|
||||||
"sshSudo": "Sudo 허용",
|
"sshSudo": "Sudo 허용",
|
||||||
"sshSudoCommands": "Sudo 명령",
|
"sshSudoCommands": "Sudo 명령",
|
||||||
"sshSudoCommandsDescription": "사용자가 sudo로 실행할 수 있도록 허용된 명령 목록입니다.",
|
"sshSudoCommandsDescription": "사용자가 sudo로 실행할 수 있는 명령어의 쉼표로 구분된 목록입니다.",
|
||||||
"sshCreateHomeDir": "홈 디렉터리 생성",
|
"sshCreateHomeDir": "홈 디렉터리 생성",
|
||||||
"sshUnixGroups": "유닉스 그룹",
|
"sshUnixGroups": "유닉스 그룹",
|
||||||
"sshUnixGroupsDescription": "대상 호스트에서 사용자를 추가할 유닉스 그룹입니다.",
|
"sshUnixGroupsDescription": "대상 호스트에서 사용자에게 추가할 유닉스 그룹의 쉼표로 구분된 목록입니다.",
|
||||||
"retryAttempts": "재시도 횟수",
|
"retryAttempts": "재시도 횟수",
|
||||||
"expectedResponseCodes": "예상 응답 코드",
|
"expectedResponseCodes": "예상 응답 코드",
|
||||||
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
|
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "다음 연도 말",
|
"logRetentionEndOfFollowingYear": "다음 연도 말",
|
||||||
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
|
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
|
||||||
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
|
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
|
||||||
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
|
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다. <bookADemoLink>데모 또는 POC 체험을 예약하세요</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
|
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이(가) 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다. <bookADemoLink>데모 또는 POC 체험을 예약하세요</bookADemoLink>.",
|
||||||
"certResolver": "인증서 해결사",
|
"certResolver": "인증서 해결사",
|
||||||
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
|
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
|
||||||
"selectCertResolver": "인증서 해결사 선택",
|
"selectCertResolver": "인증서 해결사 선택",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "장치 승인 활성화",
|
"approvalsEmptyStateStep2Title": "장치 승인 활성화",
|
||||||
"approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.",
|
"approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.",
|
||||||
"approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.",
|
"approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.",
|
||||||
"approvalsEmptyStateButtonText": "역할 관리"
|
"approvalsEmptyStateButtonText": "역할 관리",
|
||||||
|
"domainErrorTitle": "도메인 확인에 문제가 발생했습니다."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy forespørsler over HTTPS ved å bruke et fullstendig kvalifisert domenenavn.",
|
"resourceHTTPDescription": "Proxy forespørsler over HTTPS ved å bruke et fullstendig kvalifisert domenenavn.",
|
||||||
"resourceRaw": "Rå TCP/UDP-ressurs",
|
"resourceRaw": "Rå TCP/UDP-ressurs",
|
||||||
"resourceRawDescription": "Proxy forespørsler over rå TCP/UDP ved å bruke et portnummer.",
|
"resourceRawDescription": "Proxy forespørsler over rå TCP/UDP ved å bruke et portnummer.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy forespørsler om rå TCP/UDP ved hjelp av et portnummer. Krever sider for å koble til en ekstern node.",
|
||||||
"resourceCreate": "Opprett ressurs",
|
"resourceCreate": "Opprett ressurs",
|
||||||
"resourceCreateDescription": "Følg trinnene nedenfor for å opprette en ny ressurs",
|
"resourceCreateDescription": "Følg trinnene nedenfor for å opprette en ny ressurs",
|
||||||
"resourceSeeAll": "Se alle ressurser",
|
"resourceSeeAll": "Se alle ressurser",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Hent bruker",
|
"actionGetUser": "Hent bruker",
|
||||||
"actionGetOrgUser": "Hent organisasjonsbruker",
|
"actionGetOrgUser": "Hent organisasjonsbruker",
|
||||||
"actionListOrgDomains": "List opp organisasjonsdomener",
|
"actionListOrgDomains": "List opp organisasjonsdomener",
|
||||||
|
"actionGetDomain": "Få Domene",
|
||||||
|
"actionCreateOrgDomain": "Opprett domene",
|
||||||
|
"actionUpdateOrgDomain": "Oppdater domene",
|
||||||
|
"actionDeleteOrgDomain": "Slett domene",
|
||||||
|
"actionGetDNSRecords": "Hent DNS-oppføringer",
|
||||||
|
"actionRestartOrgDomain": "Omstart Domene",
|
||||||
"actionCreateSite": "Opprett område",
|
"actionCreateSite": "Opprett område",
|
||||||
"actionDeleteSite": "Slett område",
|
"actionDeleteSite": "Slett område",
|
||||||
"actionGetSite": "Hent område",
|
"actionGetSite": "Hent område",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Navnerom: {namespace}",
|
"domainPickerNamespace": "Navnerom: {namespace}",
|
||||||
"domainPickerShowMore": "Vis mer",
|
"domainPickerShowMore": "Vis mer",
|
||||||
"regionSelectorTitle": "Velg Region",
|
"regionSelectorTitle": "Velg Region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Tilbudte domener støttes ikke når sider kobles til eksterne avkjøringsnoder. For ressurser som skal være tilgjengelige på eksterne noder, brukes et egendefinert domene i stedet.",
|
||||||
"regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.",
|
"regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.",
|
||||||
"regionSelectorPlaceholder": "Velg en region",
|
"regionSelectorPlaceholder": "Velg en region",
|
||||||
"regionSelectorComingSoon": "Kommer snart",
|
"regionSelectorComingSoon": "Kommer snart",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.",
|
"sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.",
|
||||||
"sshSudo": "Tillat sudo",
|
"sshSudo": "Tillat sudo",
|
||||||
"sshSudoCommands": "Sudo kommandoer",
|
"sshSudoCommands": "Sudo kommandoer",
|
||||||
"sshSudoCommandsDescription": "Liste av kommandoer brukeren har lov til å kjøre med sudo.",
|
"sshSudoCommandsDescription": "Kommaseparert liste med kommandoer brukeren kan kjøre med sudo.",
|
||||||
"sshCreateHomeDir": "Opprett hjemmappe",
|
"sshCreateHomeDir": "Opprett hjemmappe",
|
||||||
"sshUnixGroups": "Unix grupper",
|
"sshUnixGroups": "Unix grupper",
|
||||||
"sshUnixGroupsDescription": "Unix grupper for å legge til brukeren til målverten.",
|
"sshUnixGroupsDescription": "Kommaseparerte Unix grupper for å legge brukeren til på mål-verten.",
|
||||||
"retryAttempts": "Forsøk på nytt",
|
"retryAttempts": "Forsøk på nytt",
|
||||||
"expectedResponseCodes": "Forventede svarkoder",
|
"expectedResponseCodes": "Forventede svarkoder",
|
||||||
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
|
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Slutt på neste år",
|
"logRetentionEndOfFollowingYear": "Slutt på neste år",
|
||||||
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
|
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
|
||||||
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
|
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
|
||||||
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens er påkrevd for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens eller <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> er påkrevd for å bruke denne funksjonen. <bookADemoLink>Bestill en demo eller POC prøveversjon</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Bestill en demo eller POC studie</bookADemoLink>.",
|
||||||
"certResolver": "Sertifikat løser",
|
"certResolver": "Sertifikat løser",
|
||||||
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
|
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
|
||||||
"selectCertResolver": "Velg sertifikatløser",
|
"selectCertResolver": "Velg sertifikatløser",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
|
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
|
||||||
"approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.",
|
"approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering",
|
"approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering",
|
||||||
"approvalsEmptyStateButtonText": "Administrer Roller"
|
"approvalsEmptyStateButtonText": "Administrer Roller",
|
||||||
|
"domainErrorTitle": "Vi har problemer med å verifisere domenet ditt"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxyverzoeken via HTTPS met een volledig gekwalificeerde domeinnaam.",
|
"resourceHTTPDescription": "Proxyverzoeken via HTTPS met een volledig gekwalificeerde domeinnaam.",
|
||||||
"resourceRaw": "TCP/UDP bron",
|
"resourceRaw": "TCP/UDP bron",
|
||||||
"resourceRawDescription": "Proxyverzoeken via ruwe TCP/UDP met een poortnummer.",
|
"resourceRawDescription": "Proxyverzoeken via ruwe TCP/UDP met een poortnummer.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy verzoeken over rauwe TCP/UDP met behulp van een poortnummer. Vereist sites om verbinding te maken met een remote node.",
|
||||||
"resourceCreate": "Bron maken",
|
"resourceCreate": "Bron maken",
|
||||||
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
|
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
|
||||||
"resourceSeeAll": "Alle bronnen bekijken",
|
"resourceSeeAll": "Alle bronnen bekijken",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Gebruiker ophalen",
|
"actionGetUser": "Gebruiker ophalen",
|
||||||
"actionGetOrgUser": "Krijg organisatie-gebruiker",
|
"actionGetOrgUser": "Krijg organisatie-gebruiker",
|
||||||
"actionListOrgDomains": "Lijst organisatie domeinen",
|
"actionListOrgDomains": "Lijst organisatie domeinen",
|
||||||
|
"actionGetDomain": "Domein verkrijgen",
|
||||||
|
"actionCreateOrgDomain": "Domein aanmaken",
|
||||||
|
"actionUpdateOrgDomain": "Domein bijwerken",
|
||||||
|
"actionDeleteOrgDomain": "Domein verwijderen",
|
||||||
|
"actionGetDNSRecords": "Krijg DNS Records",
|
||||||
|
"actionRestartOrgDomain": "Domein opnieuw starten",
|
||||||
"actionCreateSite": "Site aanmaken",
|
"actionCreateSite": "Site aanmaken",
|
||||||
"actionDeleteSite": "Site verwijderen",
|
"actionDeleteSite": "Site verwijderen",
|
||||||
"actionGetSite": "Site ophalen",
|
"actionGetSite": "Site ophalen",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Naamruimte: {namespace}",
|
"domainPickerNamespace": "Naamruimte: {namespace}",
|
||||||
"domainPickerShowMore": "Meer weergeven",
|
"domainPickerShowMore": "Meer weergeven",
|
||||||
"regionSelectorTitle": "Selecteer Regio",
|
"regionSelectorTitle": "Selecteer Regio",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Opgegeven domeinen worden niet ondersteund wanneer websites verbinding maken met externe sluitnodes. Gebruik in plaats daarvan een aangepast domein. Om bronnen beschikbaar te maken op externe nodes.",
|
||||||
"regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.",
|
"regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.",
|
||||||
"regionSelectorPlaceholder": "Kies een regio",
|
"regionSelectorPlaceholder": "Kies een regio",
|
||||||
"regionSelectorComingSoon": "Komt binnenkort",
|
"regionSelectorComingSoon": "Komt binnenkort",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.",
|
"sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.",
|
||||||
"sshSudo": "sudo toestaan",
|
"sshSudo": "sudo toestaan",
|
||||||
"sshSudoCommands": "Sudo Commando's",
|
"sshSudoCommands": "Sudo Commando's",
|
||||||
"sshSudoCommandsDescription": "Lijst van commando's die de gebruiker mag uitvoeren met een sudo.",
|
"sshSudoCommandsDescription": "Komma's gescheiden lijst van commando's waar de gebruiker een sudo mee mag uitvoeren.",
|
||||||
"sshCreateHomeDir": "Maak Home Directory",
|
"sshCreateHomeDir": "Maak Home Directory",
|
||||||
"sshUnixGroups": "Unix groepen",
|
"sshUnixGroups": "Unix groepen",
|
||||||
"sshUnixGroupsDescription": "Unix groepen om de gebruiker toe te voegen aan de doel host.",
|
"sshUnixGroupsDescription": "Door komma's gescheiden Unix-groepen om de gebruiker toe te voegen aan de doelhost.",
|
||||||
"retryAttempts": "Herhaal Pogingen",
|
"retryAttempts": "Herhaal Pogingen",
|
||||||
"expectedResponseCodes": "Verwachte Reactiecodes",
|
"expectedResponseCodes": "Verwachte Reactiecodes",
|
||||||
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
|
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
|
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
|
||||||
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
|
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
|
||||||
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
|
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
|
||||||
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie of <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is vereist om deze functie te gebruiken. <bookADemoLink>Boek een demo of POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Boek een demo of POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Certificaat Resolver",
|
"certResolver": "Certificaat Resolver",
|
||||||
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
|
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
|
||||||
"selectCertResolver": "Certificaat Resolver selecteren",
|
"selectCertResolver": "Certificaat Resolver selecteren",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen",
|
"approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen",
|
||||||
"approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.",
|
"approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen",
|
"approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen",
|
||||||
"approvalsEmptyStateButtonText": "Rollen beheren"
|
"approvalsEmptyStateButtonText": "Rollen beheren",
|
||||||
|
"domainErrorTitle": "We ondervinden problemen bij het controleren van uw domein"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy zapytań przez HTTPS przy użyciu w pełni kwalifikowanej nazwy domeny.",
|
"resourceHTTPDescription": "Proxy zapytań przez HTTPS przy użyciu w pełni kwalifikowanej nazwy domeny.",
|
||||||
"resourceRaw": "Surowy zasób TCP/UDP",
|
"resourceRaw": "Surowy zasób TCP/UDP",
|
||||||
"resourceRawDescription": "Proxy zapytań przez surowe TCP/UDP przy użyciu numeru portu.",
|
"resourceRawDescription": "Proxy zapytań przez surowe TCP/UDP przy użyciu numeru portu.",
|
||||||
|
"resourceRawDescriptionCloud": "Żądania proxy nad surowym TCP/UDP przy użyciu numeru portu. Wymaga stron aby połączyć się ze zdalnym węzłem.",
|
||||||
"resourceCreate": "Utwórz zasób",
|
"resourceCreate": "Utwórz zasób",
|
||||||
"resourceCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowy zasób",
|
"resourceCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowy zasób",
|
||||||
"resourceSeeAll": "Zobacz wszystkie zasoby",
|
"resourceSeeAll": "Zobacz wszystkie zasoby",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Pobierz użytkownika",
|
"actionGetUser": "Pobierz użytkownika",
|
||||||
"actionGetOrgUser": "Pobierz użytkownika organizacji",
|
"actionGetOrgUser": "Pobierz użytkownika organizacji",
|
||||||
"actionListOrgDomains": "Lista domen organizacji",
|
"actionListOrgDomains": "Lista domen organizacji",
|
||||||
|
"actionGetDomain": "Pobierz domenę",
|
||||||
|
"actionCreateOrgDomain": "Utwórz domenę",
|
||||||
|
"actionUpdateOrgDomain": "Aktualizuj domenę",
|
||||||
|
"actionDeleteOrgDomain": "Usuń domenę",
|
||||||
|
"actionGetDNSRecords": "Pobierz rekordy DNS",
|
||||||
|
"actionRestartOrgDomain": "Zrestartuj domenę",
|
||||||
"actionCreateSite": "Utwórz witrynę",
|
"actionCreateSite": "Utwórz witrynę",
|
||||||
"actionDeleteSite": "Usuń witrynę",
|
"actionDeleteSite": "Usuń witrynę",
|
||||||
"actionGetSite": "Pobierz witrynę",
|
"actionGetSite": "Pobierz witrynę",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Przestrzeń nazw: {namespace}",
|
"domainPickerNamespace": "Przestrzeń nazw: {namespace}",
|
||||||
"domainPickerShowMore": "Pokaż więcej",
|
"domainPickerShowMore": "Pokaż więcej",
|
||||||
"regionSelectorTitle": "Wybierz region",
|
"regionSelectorTitle": "Wybierz region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Podane domeny nie są obsługiwane, gdy witryny łączą się ze zdalnymi węzłami wyjścia. Aby zasoby były dostępne w węzłach zdalnych, użyj domeny niestandardowej.",
|
||||||
"regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.",
|
"regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.",
|
||||||
"regionSelectorPlaceholder": "Wybierz region",
|
"regionSelectorPlaceholder": "Wybierz region",
|
||||||
"regionSelectorComingSoon": "Wkrótce dostępne",
|
"regionSelectorComingSoon": "Wkrótce dostępne",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.",
|
"sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.",
|
||||||
"sshSudo": "Zezwól na sudo",
|
"sshSudo": "Zezwól na sudo",
|
||||||
"sshSudoCommands": "Komendy Sudo",
|
"sshSudoCommands": "Komendy Sudo",
|
||||||
"sshSudoCommandsDescription": "Lista poleceń, które użytkownik może uruchamiać z sudo.",
|
"sshSudoCommandsDescription": "Lista poleceń oddzielonych przecinkami, które użytkownik może uruchamiać z sudo.",
|
||||||
"sshCreateHomeDir": "Utwórz katalog domowy",
|
"sshCreateHomeDir": "Utwórz katalog domowy",
|
||||||
"sshUnixGroups": "Grupy Unix",
|
"sshUnixGroups": "Grupy Unix",
|
||||||
"sshUnixGroupsDescription": "Grupy Unix do dodania użytkownika do docelowego hosta.",
|
"sshUnixGroupsDescription": "Oddzielone przecinkami grupy Unix, aby dodać użytkownika do docelowego hosta.",
|
||||||
"retryAttempts": "Próby Ponowienia",
|
"retryAttempts": "Próby Ponowienia",
|
||||||
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
|
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
|
||||||
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
|
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
|
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
|
||||||
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
|
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
|
||||||
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
|
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
|
||||||
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lub <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> . <bookADemoLink>Zarezerwuj wersję demonstracyjną lub wersję próbną POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Zarezerwuj demo lub okres próbny POC</bookADemoLink>.",
|
||||||
"certResolver": "Rozwiązywanie certyfikatów",
|
"certResolver": "Rozwiązywanie certyfikatów",
|
||||||
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
|
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
|
||||||
"selectCertResolver": "Wybierz Resolver certyfikatów",
|
"selectCertResolver": "Wybierz Resolver certyfikatów",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
|
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
|
||||||
"approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.",
|
"approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj",
|
"approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj",
|
||||||
"approvalsEmptyStateButtonText": "Zarządzaj rolami"
|
"approvalsEmptyStateButtonText": "Zarządzaj rolami",
|
||||||
|
"domainErrorTitle": "Mamy problem z weryfikacją Twojej domeny"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxies requests sobre HTTPS usando um nome de domínio totalmente qualificado.",
|
"resourceHTTPDescription": "Proxies requests sobre HTTPS usando um nome de domínio totalmente qualificado.",
|
||||||
"resourceRaw": "Recurso TCP/UDP bruto",
|
"resourceRaw": "Recurso TCP/UDP bruto",
|
||||||
"resourceRawDescription": "Proxies solicitações sobre TCP/UDP bruto usando um número de porta.",
|
"resourceRawDescription": "Proxies solicitações sobre TCP/UDP bruto usando um número de porta.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy solicita por TCP/UDP bruto usando um número de porta. Requer que sites se conectem a um nó remoto.",
|
||||||
"resourceCreate": "Criar Recurso",
|
"resourceCreate": "Criar Recurso",
|
||||||
"resourceCreateDescription": "Siga os passos abaixo para criar um novo recurso",
|
"resourceCreateDescription": "Siga os passos abaixo para criar um novo recurso",
|
||||||
"resourceSeeAll": "Ver todos os recursos",
|
"resourceSeeAll": "Ver todos os recursos",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Obter Usuário",
|
"actionGetUser": "Obter Usuário",
|
||||||
"actionGetOrgUser": "Obter Utilizador da Organização",
|
"actionGetOrgUser": "Obter Utilizador da Organização",
|
||||||
"actionListOrgDomains": "Listar Domínios da Organização",
|
"actionListOrgDomains": "Listar Domínios da Organização",
|
||||||
|
"actionGetDomain": "Obter domínio",
|
||||||
|
"actionCreateOrgDomain": "Criar domínio",
|
||||||
|
"actionUpdateOrgDomain": "Atualizar domínio",
|
||||||
|
"actionDeleteOrgDomain": "Excluir domínio",
|
||||||
|
"actionGetDNSRecords": "Obter registros de DNS",
|
||||||
|
"actionRestartOrgDomain": "Reiniciar domínio",
|
||||||
"actionCreateSite": "Criar Site",
|
"actionCreateSite": "Criar Site",
|
||||||
"actionDeleteSite": "Eliminar Site",
|
"actionDeleteSite": "Eliminar Site",
|
||||||
"actionGetSite": "Obter Site",
|
"actionGetSite": "Obter Site",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Mostrar Mais",
|
"domainPickerShowMore": "Mostrar Mais",
|
||||||
"regionSelectorTitle": "Selecionar Região",
|
"regionSelectorTitle": "Selecionar Região",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Domínios fornecidos não são suportados quando os sites se conectam a nós de saída remota. Para recursos disponíveis em nós remotos, use um domínio personalizado.",
|
||||||
"regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.",
|
"regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.",
|
||||||
"regionSelectorPlaceholder": "Escolher uma região",
|
"regionSelectorPlaceholder": "Escolher uma região",
|
||||||
"regionSelectorComingSoon": "Em breve",
|
"regionSelectorComingSoon": "Em breve",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.",
|
"sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.",
|
||||||
"sshSudo": "Permitir sudo",
|
"sshSudo": "Permitir sudo",
|
||||||
"sshSudoCommands": "Comandos Sudo",
|
"sshSudoCommands": "Comandos Sudo",
|
||||||
"sshSudoCommandsDescription": "Lista de comandos com permissão de executar com o sudo.",
|
"sshSudoCommandsDescription": "Lista separada por vírgulas de comandos que o usuário pode executar com sudo.",
|
||||||
"sshCreateHomeDir": "Criar Diretório Inicial",
|
"sshCreateHomeDir": "Criar Diretório Inicial",
|
||||||
"sshUnixGroups": "Grupos Unix",
|
"sshUnixGroups": "Grupos Unix",
|
||||||
"sshUnixGroupsDescription": "Grupos Unix para adicionar o usuário no host de destino.",
|
"sshUnixGroupsDescription": "Grupos Unix separados por vírgulas para adicionar o usuário no host alvo.",
|
||||||
"retryAttempts": "Tentativas de Repetição",
|
"retryAttempts": "Tentativas de Repetição",
|
||||||
"expectedResponseCodes": "Códigos de Resposta Esperados",
|
"expectedResponseCodes": "Códigos de Resposta Esperados",
|
||||||
"expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.",
|
"expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
|
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
|
||||||
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
|
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
|
||||||
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
|
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
|
||||||
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> é necessária para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> ou <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> é necessária para usar este recurso. <bookADemoLink>Reserve um teste de demonstração ou POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserve uma demonstração ou avaliação POC</bookADemoLink>.",
|
||||||
"certResolver": "Resolvedor de Certificado",
|
"certResolver": "Resolvedor de Certificado",
|
||||||
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
|
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
|
||||||
"selectCertResolver": "Selecionar solucionador de certificado",
|
"selectCertResolver": "Selecionar solucionador de certificado",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo",
|
"approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo",
|
||||||
"approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.",
|
"approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão",
|
"approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão",
|
||||||
"approvalsEmptyStateButtonText": "Gerir Funções"
|
"approvalsEmptyStateButtonText": "Gerir Funções",
|
||||||
|
"domainErrorTitle": "Estamos tendo problemas ao verificar seu domínio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
|
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
|
||||||
"resourceRaw": "Сырой TCP/UDP-ресурс",
|
"resourceRaw": "Сырой TCP/UDP-ресурс",
|
||||||
"resourceRawDescription": "Проксировать запросы по сырому TCP/UDP с использованием номера порта.",
|
"resourceRawDescription": "Проксировать запросы по сырому TCP/UDP с использованием номера порта.",
|
||||||
|
"resourceRawDescriptionCloud": "Прокси запросы через необработанный TCP/UDP с использованием номера порта. Требуется подключение сайтов к удаленному узлу.",
|
||||||
"resourceCreate": "Создание ресурса",
|
"resourceCreate": "Создание ресурса",
|
||||||
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
|
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
|
||||||
"resourceSeeAll": "Посмотреть все ресурсы",
|
"resourceSeeAll": "Посмотреть все ресурсы",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Получить пользователя",
|
"actionGetUser": "Получить пользователя",
|
||||||
"actionGetOrgUser": "Получить пользователя организации",
|
"actionGetOrgUser": "Получить пользователя организации",
|
||||||
"actionListOrgDomains": "Список доменов организации",
|
"actionListOrgDomains": "Список доменов организации",
|
||||||
|
"actionGetDomain": "Получить домен",
|
||||||
|
"actionCreateOrgDomain": "Создать домен",
|
||||||
|
"actionUpdateOrgDomain": "Обновить домен",
|
||||||
|
"actionDeleteOrgDomain": "Удалить домен",
|
||||||
|
"actionGetDNSRecords": "Получить записи DNS",
|
||||||
|
"actionRestartOrgDomain": "Перезапустить домен",
|
||||||
"actionCreateSite": "Создать сайт",
|
"actionCreateSite": "Создать сайт",
|
||||||
"actionDeleteSite": "Удалить сайт",
|
"actionDeleteSite": "Удалить сайт",
|
||||||
"actionGetSite": "Получить сайт",
|
"actionGetSite": "Получить сайт",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Пространство имен: {namespace}",
|
"domainPickerNamespace": "Пространство имен: {namespace}",
|
||||||
"domainPickerShowMore": "Показать еще",
|
"domainPickerShowMore": "Показать еще",
|
||||||
"regionSelectorTitle": "Выберите регион",
|
"regionSelectorTitle": "Выберите регион",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Предоставленные домены не поддерживаются при подключении сайтов к удаленным узлам. Для доступа к ресурсам на удаленных узлах используйте пользовательский домен.",
|
||||||
"regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.",
|
"regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.",
|
||||||
"regionSelectorPlaceholder": "Выбор региона",
|
"regionSelectorPlaceholder": "Выбор региона",
|
||||||
"regionSelectorComingSoon": "Скоро будет",
|
"regionSelectorComingSoon": "Скоро будет",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
|
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
|
||||||
"sshSudo": "Разрешить sudo",
|
"sshSudo": "Разрешить sudo",
|
||||||
"sshSudoCommands": "Sudo Команды",
|
"sshSudoCommands": "Sudo Команды",
|
||||||
"sshSudoCommandsDescription": "Список команд, которые пользователю разрешено запускать с помощью sudo.",
|
"sshSudoCommandsDescription": "Список команд, разделенных запятыми, которые пользователю разрешено запускать с помощью sudo.",
|
||||||
"sshCreateHomeDir": "Создать домашний каталог",
|
"sshCreateHomeDir": "Создать домашний каталог",
|
||||||
"sshUnixGroups": "Unix группы",
|
"sshUnixGroups": "Unix группы",
|
||||||
"sshUnixGroupsDescription": "Unix группы для добавления пользователя на целевой хост.",
|
"sshUnixGroupsDescription": "Группы Unix через запятую, чтобы добавить пользователя на целевой хост.",
|
||||||
"retryAttempts": "Количество попыток повторного запроса",
|
"retryAttempts": "Количество попыток повторного запроса",
|
||||||
"expectedResponseCodes": "Ожидаемые коды ответов",
|
"expectedResponseCodes": "Ожидаемые коды ответов",
|
||||||
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
|
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Конец следующего года",
|
"logRetentionEndOfFollowingYear": "Конец следующего года",
|
||||||
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
|
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
|
||||||
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
|
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
|
||||||
"licenseRequiredToUse": "Лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "Требуется лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> или <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> для использования этой функции. <bookADemoLink>Забронируйте демонстрацию или пробный POC</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Для использования этой функции требуется <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Забронируйте демонстрацию или пробный POC</bookADemoLink>.",
|
||||||
"certResolver": "Резольвер сертификата",
|
"certResolver": "Резольвер сертификата",
|
||||||
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
|
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
|
||||||
"selectCertResolver": "Выберите резолвер сертификата",
|
"selectCertResolver": "Выберите резолвер сертификата",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Включить утверждения устройства",
|
"approvalsEmptyStateStep2Title": "Включить утверждения устройства",
|
||||||
"approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.",
|
"approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки",
|
"approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки",
|
||||||
"approvalsEmptyStateButtonText": "Управление ролями"
|
"approvalsEmptyStateButtonText": "Управление ролями",
|
||||||
|
"domainErrorTitle": "У нас возникли проблемы с проверкой вашего домена"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Tam nitelikli bir etki alanı adı kullanarak HTTPS üzerinden proxy isteklerini yönlendirin.",
|
"resourceHTTPDescription": "Tam nitelikli bir etki alanı adı kullanarak HTTPS üzerinden proxy isteklerini yönlendirin.",
|
||||||
"resourceRaw": "Ham TCP/UDP Kaynağı",
|
"resourceRaw": "Ham TCP/UDP Kaynağı",
|
||||||
"resourceRawDescription": "Port numarası kullanarak ham TCP/UDP üzerinden proxy isteklerini yönlendirin.",
|
"resourceRawDescription": "Port numarası kullanarak ham TCP/UDP üzerinden proxy isteklerini yönlendirin.",
|
||||||
|
"resourceRawDescriptionCloud": "Proxy isteklerini bir port numarası kullanarak ham TCP/UDP üzerinden yapın. Sitelerin uzak bir düğüme bağlanması gereklidir.",
|
||||||
"resourceCreate": "Kaynak Oluştur",
|
"resourceCreate": "Kaynak Oluştur",
|
||||||
"resourceCreateDescription": "Yeni bir kaynak oluşturmak için aşağıdaki adımları izleyin",
|
"resourceCreateDescription": "Yeni bir kaynak oluşturmak için aşağıdaki adımları izleyin",
|
||||||
"resourceSeeAll": "Tüm Kaynakları Gör",
|
"resourceSeeAll": "Tüm Kaynakları Gör",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "Kullanıcıyı Getir",
|
"actionGetUser": "Kullanıcıyı Getir",
|
||||||
"actionGetOrgUser": "Kuruluş Kullanıcısını Al",
|
"actionGetOrgUser": "Kuruluş Kullanıcısını Al",
|
||||||
"actionListOrgDomains": "Kuruluş Alan Adlarını Listele",
|
"actionListOrgDomains": "Kuruluş Alan Adlarını Listele",
|
||||||
|
"actionGetDomain": "Alan Adını Al",
|
||||||
|
"actionCreateOrgDomain": "Alan Adı Oluştur",
|
||||||
|
"actionUpdateOrgDomain": "Alan Adını Güncelle",
|
||||||
|
"actionDeleteOrgDomain": "Alan Adını Sil",
|
||||||
|
"actionGetDNSRecords": "DNS Kayıtlarını Al",
|
||||||
|
"actionRestartOrgDomain": "Alanı Yeniden Başlat",
|
||||||
"actionCreateSite": "Site Oluştur",
|
"actionCreateSite": "Site Oluştur",
|
||||||
"actionDeleteSite": "Siteyi Sil",
|
"actionDeleteSite": "Siteyi Sil",
|
||||||
"actionGetSite": "Siteyi Al",
|
"actionGetSite": "Siteyi Al",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Ad Alanı: {namespace}",
|
"domainPickerNamespace": "Ad Alanı: {namespace}",
|
||||||
"domainPickerShowMore": "Daha Fazla Göster",
|
"domainPickerShowMore": "Daha Fazla Göster",
|
||||||
"regionSelectorTitle": "Bölge Seç",
|
"regionSelectorTitle": "Bölge Seç",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Belirtilen alan adları, siteler uzak çıkış düğümlerine bağlandığında desteklenmez. Kaynakların uzak düğümlerde kullanılabilir olması için özel bir alan adı kullanın.",
|
||||||
"regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.",
|
"regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.",
|
||||||
"regionSelectorPlaceholder": "Bölge Seçin",
|
"regionSelectorPlaceholder": "Bölge Seçin",
|
||||||
"regionSelectorComingSoon": "Yakında Geliyor",
|
"regionSelectorComingSoon": "Yakında Geliyor",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.",
|
"sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.",
|
||||||
"sshSudo": "Sudo'ya izin ver",
|
"sshSudo": "Sudo'ya izin ver",
|
||||||
"sshSudoCommands": "Sudo Komutları",
|
"sshSudoCommands": "Sudo Komutları",
|
||||||
"sshSudoCommandsDescription": "Kullanıcının sudo ile çalıştırmasına izin verilen komutların listesi.",
|
"sshSudoCommandsDescription": "Kullanıcının sudo ile çalıştırmasına izin verilen komutların virgülle ayrılmış listesi.",
|
||||||
"sshCreateHomeDir": "Ev Dizini Oluştur",
|
"sshCreateHomeDir": "Ev Dizini Oluştur",
|
||||||
"sshUnixGroups": "Unix Grupları",
|
"sshUnixGroups": "Unix Grupları",
|
||||||
"sshUnixGroupsDescription": "Hedef ana bilgisayarda kullanıcıya eklemek için Unix grupları.",
|
"sshUnixGroupsDescription": "Hedef konakta kullanıcıya eklenecek Unix gruplarının virgülle ayrılmış listesi.",
|
||||||
"retryAttempts": "Tekrar Deneme Girişimleri",
|
"retryAttempts": "Tekrar Deneme Girişimleri",
|
||||||
"expectedResponseCodes": "Beklenen Yanıt Kodları",
|
"expectedResponseCodes": "Beklenen Yanıt Kodları",
|
||||||
"expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.",
|
"expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
|
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
|
||||||
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
|
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
|
||||||
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
|
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
|
||||||
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
|
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı veya <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> gereklidir. <bookADemoLink>Tanıtım veya POC denemesi ayarlayın</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
|
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>’da da mevcuttur. <bookADemoLink>Tanıtım veya POC denemesi ayarlayın</bookADemoLink>.",
|
||||||
"certResolver": "Sertifika Çözücü",
|
"certResolver": "Sertifika Çözücü",
|
||||||
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
|
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
|
||||||
"selectCertResolver": "Sertifika Çözücü Seçin",
|
"selectCertResolver": "Sertifika Çözücü Seçin",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir",
|
"approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir",
|
||||||
"approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.",
|
"approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.",
|
"approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.",
|
||||||
"approvalsEmptyStateButtonText": "Rolleri Yönet"
|
"approvalsEmptyStateButtonText": "Rolleri Yönet",
|
||||||
|
"domainErrorTitle": "Alan adınızı doğrulamada sorun yaşıyoruz"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
|
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
|
||||||
"resourceRaw": "TCP/UDP 资源",
|
"resourceRaw": "TCP/UDP 资源",
|
||||||
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
|
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
|
||||||
|
"resourceRawDescriptionCloud": "正在使用端口号使用 TCP/UDP 代理请求。需要站点连接到远程节点。",
|
||||||
"resourceCreate": "创建资源",
|
"resourceCreate": "创建资源",
|
||||||
"resourceCreateDescription": "按照下面的步骤创建新资源",
|
"resourceCreateDescription": "按照下面的步骤创建新资源",
|
||||||
"resourceSeeAll": "查看所有资源",
|
"resourceSeeAll": "查看所有资源",
|
||||||
@@ -1101,6 +1102,12 @@
|
|||||||
"actionGetUser": "获取用户",
|
"actionGetUser": "获取用户",
|
||||||
"actionGetOrgUser": "获取组织用户",
|
"actionGetOrgUser": "获取组织用户",
|
||||||
"actionListOrgDomains": "列出组织域",
|
"actionListOrgDomains": "列出组织域",
|
||||||
|
"actionGetDomain": "获取域",
|
||||||
|
"actionCreateOrgDomain": "创建域",
|
||||||
|
"actionUpdateOrgDomain": "更新域",
|
||||||
|
"actionDeleteOrgDomain": "删除域",
|
||||||
|
"actionGetDNSRecords": "获取 DNS 记录",
|
||||||
|
"actionRestartOrgDomain": "重新启动域",
|
||||||
"actionCreateSite": "创建站点",
|
"actionCreateSite": "创建站点",
|
||||||
"actionDeleteSite": "删除站点",
|
"actionDeleteSite": "删除站点",
|
||||||
"actionGetSite": "获取站点",
|
"actionGetSite": "获取站点",
|
||||||
@@ -1419,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "命名空间:{namespace}",
|
"domainPickerNamespace": "命名空间:{namespace}",
|
||||||
"domainPickerShowMore": "显示更多",
|
"domainPickerShowMore": "显示更多",
|
||||||
"regionSelectorTitle": "选择区域",
|
"regionSelectorTitle": "选择区域",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "当站点连接到远程退出节点时不支持所提供的域。为了资源可在远程节点上使用,请使用自定义域名。",
|
||||||
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
|
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
|
||||||
"regionSelectorPlaceholder": "选择一个区域",
|
"regionSelectorPlaceholder": "选择一个区域",
|
||||||
"regionSelectorComingSoon": "即将推出",
|
"regionSelectorComingSoon": "即将推出",
|
||||||
@@ -1669,10 +1677,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
|
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
|
||||||
"sshSudo": "允许Sudo",
|
"sshSudo": "允许Sudo",
|
||||||
"sshSudoCommands": "Sudo 命令",
|
"sshSudoCommands": "Sudo 命令",
|
||||||
"sshSudoCommandsDescription": "允许用户使用 sudo 运行的命令列表。",
|
"sshSudoCommandsDescription": "逗号分隔的用户允许使用 sudo 运行的命令列表。",
|
||||||
"sshCreateHomeDir": "创建主目录",
|
"sshCreateHomeDir": "创建主目录",
|
||||||
"sshUnixGroups": "Unix 组",
|
"sshUnixGroups": "Unix 组",
|
||||||
"sshUnixGroupsDescription": "将用户添加到目标主机的Unix组。",
|
"sshUnixGroupsDescription": "用逗号分隔了Unix组,将用户添加到目标主机上。",
|
||||||
"retryAttempts": "重试次数",
|
"retryAttempts": "重试次数",
|
||||||
"expectedResponseCodes": "期望响应代码",
|
"expectedResponseCodes": "期望响应代码",
|
||||||
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
|
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
|
||||||
@@ -2335,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "下一年结束",
|
"logRetentionEndOfFollowingYear": "下一年结束",
|
||||||
"actionLogsDescription": "查看此机构执行的操作历史",
|
"actionLogsDescription": "查看此机构执行的操作历史",
|
||||||
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
||||||
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
"licenseRequiredToUse": "使用此功能需要<enterpriseLicenseLink>企业版</enterpriseLicenseLink>许可证或<pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>。<bookADemoLink>预约演示或POC试用</bookADemoLink>。",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
"ossEnterpriseEditionRequired": "需要 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 才能使用此功能。 此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>上获取。 <bookADemoLink>预订演示或POC 试用</bookADemoLink>。",
|
||||||
"certResolver": "证书解决器",
|
"certResolver": "证书解决器",
|
||||||
"certResolverDescription": "选择用于此资源的证书解析器。",
|
"certResolverDescription": "选择用于此资源的证书解析器。",
|
||||||
"selectCertResolver": "选择证书解析",
|
"selectCertResolver": "选择证书解析",
|
||||||
@@ -2673,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "启用设备批准",
|
"approvalsEmptyStateStep2Title": "启用设备批准",
|
||||||
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
|
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
|
||||||
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
|
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
|
||||||
"approvalsEmptyStateButtonText": "管理角色"
|
"approvalsEmptyStateButtonText": "管理角色",
|
||||||
|
"domainErrorTitle": "我们在验证您的域名时遇到了问题"
|
||||||
}
|
}
|
||||||
|
|||||||
5134
package-lock.json
generated
5134
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.989.0",
|
"@aws-sdk/client-s3": "3.1011.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
@@ -62,8 +62,8 @@
|
|||||||
"@react-email/components": "1.0.8",
|
"@react-email/components": "1.0.8",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/render": "2.0.4",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/tailwind": "2.0.5",
|
||||||
"@simplewebauthn/browser": "13.2.2",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.2.3",
|
"@simplewebauthn/server": "13.3.0",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
@@ -80,16 +80,16 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.2.1",
|
"express-rate-limit": "8.3.0",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.9.3",
|
"ioredis": "5.10.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.563.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.5",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.12",
|
"next": "15.5.12",
|
||||||
@@ -99,21 +99,21 @@
|
|||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.19.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.26.0",
|
"posthog-node": "5.28.0",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "9.13.2",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.71.2",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"resend": "6.9.2",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"sshpk": "^1.18.0",
|
"sshpk": "^1.18.0",
|
||||||
"stripe": "20.3.1",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
@@ -131,10 +131,10 @@
|
|||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.52.0",
|
"@dotenvx/dotenvx": "1.54.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.8",
|
"@react-email/preview-server": "5.2.10",
|
||||||
"@tailwindcss/postcss": "4.1.18",
|
"@tailwindcss/postcss": "4.2.1",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
@@ -146,10 +146,10 @@
|
|||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.2.3",
|
"@types/node": "25.3.5",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "7.0.11",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.16.0",
|
"@types/pg": "8.18.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -159,18 +159,22 @@
|
|||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.9",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.27.3",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.20.1",
|
||||||
"eslint": "9.39.2",
|
"eslint": "10.0.3",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.7",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.8",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"react-email": "5.2.8",
|
"react-email": "5.2.10",
|
||||||
"tailwindcss": "4.1.18",
|
"tailwindcss": "4.2.1",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.55.0"
|
"typescript-eslint": "8.56.1"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": "0.27.3",
|
||||||
|
"dompurify": "3.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
createResource = "createResource",
|
createResource = "createResource",
|
||||||
deleteResource = "deleteResource",
|
deleteResource = "deleteResource",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||||
|
import { flushConnectionLogToDb } from "#dynamic/routers/newt";
|
||||||
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
|
await flushBandwidthToDb();
|
||||||
|
await flushConnectionLogToDb();
|
||||||
|
await flushSiteBandwidthToDb();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
sessions,
|
sessions,
|
||||||
clients
|
clients,
|
||||||
|
siteResources,
|
||||||
|
sites
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -302,6 +304,45 @@ export const accessAuditLog = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const connectionAuditLog = pgTable(
|
||||||
|
"connectionAuditLog",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
sessionId: text("sessionId").notNull(),
|
||||||
|
siteResourceId: integer("siteResourceId").references(
|
||||||
|
() => siteResources.siteResourceId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
orgId: text("orgId").references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
clientId: integer("clientId").references(() => clients.clientId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
userId: text("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
sourceAddr: text("sourceAddr").notNull(),
|
||||||
|
destAddr: text("destAddr").notNull(),
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
startedAt: integer("startedAt").notNull(),
|
||||||
|
endedAt: integer("endedAt"),
|
||||||
|
bytesTx: integer("bytesTx"),
|
||||||
|
bytesRx: integer("bytesRx")
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_accessAuditLog_startedAt").on(table.startedAt),
|
||||||
|
index("idx_accessAuditLog_org_startedAt").on(
|
||||||
|
table.orgId,
|
||||||
|
table.startedAt
|
||||||
|
),
|
||||||
|
index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const approvals = pgTable("approvals", {
|
export const approvals = pgTable("approvals", {
|
||||||
approvalId: serial("approvalId").primaryKey(),
|
approvalId: serial("approvalId").primaryKey(),
|
||||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||||
@@ -328,6 +369,14 @@ export const approvals = pgTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const bannedEmails = pgTable("bannedEmails", {
|
||||||
|
email: varchar("email", { length: 255 }).primaryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bannedIps = pgTable("bannedIps", {
|
||||||
|
ip: varchar("ip", { length: 255 }).primaryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -349,3 +398,4 @@ export type LoginPage = InferSelectModel<typeof loginPage>;
|
|||||||
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
export type ConnectionAuditLog = InferSelectModel<typeof connectionAuditLog>;
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export const domains = pgTable("domains", {
|
|||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: varchar("certResolver"),
|
certResolver: varchar("certResolver"),
|
||||||
customCertResolver: varchar("customCertResolver"),
|
customCertResolver: varchar("customCertResolver"),
|
||||||
preferWildcardCert: boolean("preferWildcardCert")
|
preferWildcardCert: boolean("preferWildcardCert"),
|
||||||
|
errorMessage: text("errorMessage")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = pgTable("dnsRecords", {
|
export const dnsRecords = pgTable("dnsRecords", {
|
||||||
@@ -54,6 +55,9 @@ export const orgs = pgTable("orgs", {
|
|||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
|
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
|
.notNull()
|
||||||
|
.default(0),
|
||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: boolean("isBillingOrg"),
|
isBillingOrg: boolean("isBillingOrg"),
|
||||||
@@ -88,6 +92,7 @@ export const sites = pgTable("sites", {
|
|||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||||
online: boolean("online").notNull().default(false),
|
online: boolean("online").notNull().default(false),
|
||||||
|
lastPing: integer("lastPing"),
|
||||||
address: varchar("address"),
|
address: varchar("address"),
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
publicKey: varchar("publicKey"),
|
publicKey: varchar("publicKey"),
|
||||||
@@ -283,6 +288,7 @@ export const users = pgTable("user", {
|
|||||||
dateCreated: varchar("dateCreated").notNull(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
||||||
termsVersion: varchar("termsVersion"),
|
termsVersion: varchar("termsVersion"),
|
||||||
|
marketingEmailConsent: boolean("marketingEmailConsent").default(false),
|
||||||
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
||||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
||||||
});
|
});
|
||||||
@@ -719,6 +725,7 @@ export const clientSitesAssociationsCache = pgTable(
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").notNull(),
|
siteId: integer("siteId").notNull(),
|
||||||
isRelayed: boolean("isRelayed").notNull().default(false),
|
isRelayed: boolean("isRelayed").notNull().default(false),
|
||||||
|
isJitMode: boolean("isJitMode").notNull().default(false),
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
sqliteTable,
|
sqliteTable,
|
||||||
text
|
text
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { clients, domains, exitNodes, orgs, sessions, users } from "./schema";
|
import { clients, domains, exitNodes, orgs, sessions, siteResources, sites, users } from "./schema";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -294,6 +294,45 @@ export const accessAuditLog = sqliteTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const connectionAuditLog = sqliteTable(
|
||||||
|
"connectionAuditLog",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
sessionId: text("sessionId").notNull(),
|
||||||
|
siteResourceId: integer("siteResourceId").references(
|
||||||
|
() => siteResources.siteResourceId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
orgId: text("orgId").references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
clientId: integer("clientId").references(() => clients.clientId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
userId: text("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
sourceAddr: text("sourceAddr").notNull(),
|
||||||
|
destAddr: text("destAddr").notNull(),
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
startedAt: integer("startedAt").notNull(),
|
||||||
|
endedAt: integer("endedAt"),
|
||||||
|
bytesTx: integer("bytesTx"),
|
||||||
|
bytesRx: integer("bytesRx")
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_accessAuditLog_startedAt").on(table.startedAt),
|
||||||
|
index("idx_accessAuditLog_org_startedAt").on(
|
||||||
|
table.orgId,
|
||||||
|
table.startedAt
|
||||||
|
),
|
||||||
|
index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const approvals = sqliteTable("approvals", {
|
export const approvals = sqliteTable("approvals", {
|
||||||
approvalId: integer("approvalId").primaryKey({ autoIncrement: true }),
|
approvalId: integer("approvalId").primaryKey({ autoIncrement: true }),
|
||||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||||
@@ -318,6 +357,15 @@ export const approvals = sqliteTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const bannedEmails = sqliteTable("bannedEmails", {
|
||||||
|
email: text("email").primaryKey()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bannedIps = sqliteTable("bannedIps", {
|
||||||
|
ip: text("ip").primaryKey()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -339,3 +387,4 @@ export type LoginPage = InferSelectModel<typeof loginPage>;
|
|||||||
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
export type ConnectionAuditLog = InferSelectModel<typeof connectionAuditLog>;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const domains = sqliteTable("domains", {
|
|||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
|
errorMessage: text("errorMessage")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -46,6 +47,9 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
|
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
|
.notNull()
|
||||||
|
.default(0),
|
||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||||
@@ -89,6 +93,7 @@ export const sites = sqliteTable("sites", {
|
|||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
type: text("type").notNull(), // "newt" or "wireguard"
|
type: text("type").notNull(), // "newt" or "wireguard"
|
||||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||||
|
lastPing: integer("lastPing"),
|
||||||
|
|
||||||
// exit node stuff that is how to connect to the site when it has a wg server
|
// exit node stuff that is how to connect to the site when it has a wg server
|
||||||
address: text("address"), // this is the address of the wireguard interface in newt
|
address: text("address"), // this is the address of the wireguard interface in newt
|
||||||
@@ -314,6 +319,9 @@ export const users = sqliteTable("user", {
|
|||||||
dateCreated: text("dateCreated").notNull(),
|
dateCreated: text("dateCreated").notNull(),
|
||||||
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
||||||
termsVersion: text("termsVersion"),
|
termsVersion: text("termsVersion"),
|
||||||
|
marketingEmailConsent: integer("marketingEmailConsent", {
|
||||||
|
mode: "boolean"
|
||||||
|
}).default(false),
|
||||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
@@ -406,6 +414,9 @@ export const clientSitesAssociationsCache = sqliteTable(
|
|||||||
isRelayed: integer("isRelayed", { mode: "boolean" })
|
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
isJitMode: integer("isJitMode", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
endpoint: text("endpoint"),
|
endpoint: text("endpoint"),
|
||||||
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export enum TierFeature {
|
|||||||
LogExport = "logExport",
|
LogExport = "logExport",
|
||||||
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
||||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||||
|
ConnectionLogs = "connectionLogs",
|
||||||
RotateCredentials = "rotateCredentials",
|
RotateCredentials = "rotateCredentials",
|
||||||
MaintencePage = "maintencePage", // handle downgrade
|
MaintencePage = "maintencePage", // handle downgrade
|
||||||
DevicePosture = "devicePosture",
|
DevicePosture = "devicePosture",
|
||||||
@@ -26,6 +27,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.LogExport]: ["tier3", "enterprise"],
|
[TierFeature.LogExport]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export async function applyBlueprint({
|
|||||||
[target],
|
[target],
|
||||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||||
result.proxyResource.protocol,
|
result.proxyResource.protocol,
|
||||||
result.proxyResource.proxyPort
|
site.newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { db, orgs } from "@server/db";
|
|||||||
import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAudit";
|
import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAudit";
|
||||||
import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit";
|
import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit";
|
||||||
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
|
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
|
||||||
|
import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt";
|
||||||
import { gt, or } from "drizzle-orm";
|
import { gt, or } from "drizzle-orm";
|
||||||
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
|
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export function initLogCleanupInterval() {
|
export function initLogCleanupInterval() {
|
||||||
|
if (build == "saas") { // skip log cleanup for saas builds
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return setInterval(
|
return setInterval(
|
||||||
async () => {
|
async () => {
|
||||||
const orgsToClean = await db
|
const orgsToClean = await db
|
||||||
@@ -16,14 +21,17 @@ export function initLogCleanupInterval() {
|
|||||||
settingsLogRetentionDaysAccess:
|
settingsLogRetentionDaysAccess:
|
||||||
orgs.settingsLogRetentionDaysAccess,
|
orgs.settingsLogRetentionDaysAccess,
|
||||||
settingsLogRetentionDaysRequest:
|
settingsLogRetentionDaysRequest:
|
||||||
orgs.settingsLogRetentionDaysRequest
|
orgs.settingsLogRetentionDaysRequest,
|
||||||
|
settingsLogRetentionDaysConnection:
|
||||||
|
orgs.settingsLogRetentionDaysConnection
|
||||||
})
|
})
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
gt(orgs.settingsLogRetentionDaysAction, 0),
|
gt(orgs.settingsLogRetentionDaysAction, 0),
|
||||||
gt(orgs.settingsLogRetentionDaysAccess, 0),
|
gt(orgs.settingsLogRetentionDaysAccess, 0),
|
||||||
gt(orgs.settingsLogRetentionDaysRequest, 0)
|
gt(orgs.settingsLogRetentionDaysRequest, 0),
|
||||||
|
gt(orgs.settingsLogRetentionDaysConnection, 0)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -33,7 +41,8 @@ export function initLogCleanupInterval() {
|
|||||||
orgId,
|
orgId,
|
||||||
settingsLogRetentionDaysAction,
|
settingsLogRetentionDaysAction,
|
||||||
settingsLogRetentionDaysAccess,
|
settingsLogRetentionDaysAccess,
|
||||||
settingsLogRetentionDaysRequest
|
settingsLogRetentionDaysRequest,
|
||||||
|
settingsLogRetentionDaysConnection
|
||||||
} = org;
|
} = org;
|
||||||
|
|
||||||
if (settingsLogRetentionDaysAction > 0) {
|
if (settingsLogRetentionDaysAction > 0) {
|
||||||
@@ -56,6 +65,13 @@ export function initLogCleanupInterval() {
|
|||||||
settingsLogRetentionDaysRequest
|
settingsLogRetentionDaysRequest
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settingsLogRetentionDaysConnection > 0) {
|
||||||
|
await cleanUpOldConnectionLogs(
|
||||||
|
orgId,
|
||||||
|
settingsLogRetentionDaysConnection
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await cleanUpOldFingerprintSnapshots(365);
|
await cleanUpOldFingerprintSnapshots(365);
|
||||||
|
|||||||
20
server/lib/clientVersionChecks.ts
Normal file
20
server/lib/clientVersionChecks.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import semver from "semver";
|
||||||
|
|
||||||
|
export function canCompress(
|
||||||
|
clientVersion: string | null | undefined,
|
||||||
|
type: "newt" | "olm"
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
if (!clientVersion) return false;
|
||||||
|
// check if it is a valid semver
|
||||||
|
if (!semver.valid(clientVersion)) return false;
|
||||||
|
if (type === "newt") {
|
||||||
|
return semver.gte(clientVersion, "1.10.3");
|
||||||
|
} else if (type === "olm") {
|
||||||
|
return semver.gte(clientVersion, "1.4.3");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,9 +85,7 @@ export async function deleteOrgById(
|
|||||||
deletedNewtIds.push(deletedNewt.newtId);
|
deletedNewtIds.push(deletedNewt.newtId);
|
||||||
await trx
|
await trx
|
||||||
.delete(newtSessions)
|
.delete(newtSessions)
|
||||||
.where(
|
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
||||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,33 +119,38 @@ export async function deleteOrgById(
|
|||||||
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||||
|
|
||||||
const allOrgDomains = await trx
|
const allOrgDomains = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
.innerJoin(domains, eq(orgDomains.domainId, domains.domainId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(orgDomains.orgId, orgId),
|
eq(orgDomains.orgId, orgId),
|
||||||
eq(domains.configManaged, false)
|
eq(domains.configManaged, false)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
logger.info(`Found ${allOrgDomains.length} domains to delete`);
|
||||||
const domainIdsToDelete: string[] = [];
|
const domainIdsToDelete: string[] = [];
|
||||||
for (const orgDomain of allOrgDomains) {
|
for (const orgDomain of allOrgDomains) {
|
||||||
const domainId = orgDomain.domains.domainId;
|
const domainId = orgDomain.domains.domainId;
|
||||||
const orgCount = await trx
|
const [orgCount] = await trx
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: count() })
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
if (orgCount[0].count === 1) {
|
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
|
||||||
|
if (orgCount.count === 1) {
|
||||||
domainIdsToDelete.push(domainId);
|
domainIdsToDelete.push(domainId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.info(`Found ${domainIdsToDelete.length} domains to delete`);
|
||||||
if (domainIdsToDelete.length > 0) {
|
if (domainIdsToDelete.length > 0) {
|
||||||
await trx
|
await trx
|
||||||
.delete(domains)
|
.delete(domains)
|
||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
}
|
}
|
||||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
|
||||||
|
|
||||||
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||||
|
|
||||||
@@ -231,15 +234,13 @@ export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const olmId of result.olmsToTerminate) {
|
for (const olmId of result.olmsToTerminate) {
|
||||||
sendTerminateClient(
|
sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch(
|
||||||
0,
|
(error) => {
|
||||||
OlmErrorCodes.TERMINATED_REKEYED,
|
|
||||||
olmId
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to send termination message to olm:",
|
"Failed to send termination message to olm:",
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
server/lib/ip.ts
127
server/lib/ip.ts
@@ -571,6 +571,133 @@ export function generateSubnetProxyTargets(
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubnetProxyTargetV2 = {
|
||||||
|
sourcePrefixes: string[]; // must be cidrs
|
||||||
|
destPrefix: string; // must be a cidr
|
||||||
|
disableIcmp?: boolean;
|
||||||
|
rewriteTo?: string; // must be a cidr
|
||||||
|
portRange?: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
protocol: "tcp" | "udp";
|
||||||
|
}[];
|
||||||
|
resourceId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateSubnetProxyTargetV2(
|
||||||
|
siteResource: SiteResource,
|
||||||
|
clients: {
|
||||||
|
clientId: number;
|
||||||
|
pubKey: string | null;
|
||||||
|
subnet: string | null;
|
||||||
|
}[]
|
||||||
|
): SubnetProxyTargetV2 | undefined {
|
||||||
|
if (clients.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: SubnetProxyTargetV2 | null = null;
|
||||||
|
|
||||||
|
const portRange = [
|
||||||
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
|
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
|
||||||
|
];
|
||||||
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
|
if (siteResource.mode == "host") {
|
||||||
|
let destination = siteResource.destination;
|
||||||
|
// check if this is a valid ip
|
||||||
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
|
if (ipSchema.safeParse(destination).success) {
|
||||||
|
destination = `${destination}/32`;
|
||||||
|
|
||||||
|
target = {
|
||||||
|
sourcePrefixes: [],
|
||||||
|
destPrefix: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp,
|
||||||
|
resourceId: siteResource.siteResourceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResource.alias && siteResource.aliasAddress) {
|
||||||
|
// also push a match for the alias address
|
||||||
|
target = {
|
||||||
|
sourcePrefixes: [],
|
||||||
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
|
rewriteTo: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp,
|
||||||
|
resourceId: siteResource.siteResourceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (siteResource.mode == "cidr") {
|
||||||
|
target = {
|
||||||
|
sourcePrefixes: [],
|
||||||
|
destPrefix: siteResource.destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp,
|
||||||
|
resourceId: siteResource.siteResourceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const clientSite of clients) {
|
||||||
|
if (!clientSite.subnet) {
|
||||||
|
logger.debug(
|
||||||
|
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||||
|
|
||||||
|
// add client prefix to source prefixes
|
||||||
|
target.sourcePrefixes.push(clientPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
// print a nice representation of the targets
|
||||||
|
// logger.debug(
|
||||||
|
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
|
||||||
|
// );
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
|
||||||
|
* by expanding each source prefix into its own target entry.
|
||||||
|
* @param targetV2 - The v2 target to convert
|
||||||
|
* @returns Array of v1 SubnetProxyTarget objects
|
||||||
|
*/
|
||||||
|
export function convertSubnetProxyTargetsV2ToV1(
|
||||||
|
targetsV2: SubnetProxyTargetV2[]
|
||||||
|
): SubnetProxyTarget[] {
|
||||||
|
return targetsV2.flatMap((targetV2) =>
|
||||||
|
targetV2.sourcePrefixes.map((sourcePrefix) => ({
|
||||||
|
sourcePrefix,
|
||||||
|
destPrefix: targetV2.destPrefix,
|
||||||
|
...(targetV2.disableIcmp !== undefined && {
|
||||||
|
disableIcmp: targetV2.disableIcmp
|
||||||
|
}),
|
||||||
|
...(targetV2.rewriteTo !== undefined && {
|
||||||
|
rewriteTo: targetV2.rewriteTo
|
||||||
|
}),
|
||||||
|
...(targetV2.portRange !== undefined && {
|
||||||
|
portRange: targetV2.portRange
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Custom schema for validating port range strings
|
// Custom schema for validating port range strings
|
||||||
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
||||||
export const portRangeStringSchema = z
|
export const portRangeStringSchema = z
|
||||||
|
|||||||
@@ -302,8 +302,8 @@ export const configSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.default({
|
.default({
|
||||||
block_size: 24,
|
block_size: 24,
|
||||||
subnet_group: "100.90.128.0/24",
|
subnet_group: "100.90.128.0/20",
|
||||||
utility_subnet_group: "100.96.128.0/24"
|
utility_subnet_group: "100.96.128.0/20"
|
||||||
}),
|
}),
|
||||||
rate_limits: z
|
rate_limits: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import logger from "@server/logger";
|
|||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
generateRemoteSubnets,
|
generateRemoteSubnets,
|
||||||
generateSubnetProxyTargets,
|
generateSubnetProxyTargetV2,
|
||||||
parseEndpoint,
|
parseEndpoint,
|
||||||
formatEndpoint
|
formatEndpoint
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
@@ -477,6 +477,7 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -571,7 +572,7 @@ export async function updateClientSiteDestinations(
|
|||||||
destinations: [
|
destinations: [
|
||||||
{
|
{
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 0
|
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -579,7 +580,7 @@ export async function updateClientSiteDestinations(
|
|||||||
// add to the existing destinations
|
// add to the existing destinations
|
||||||
destinations.destinations.push({
|
destinations.destinations.push({
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 0
|
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,17 +660,18 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (addedClients.length > 0) {
|
if (addedClients.length > 0) {
|
||||||
const targetsToAdd = generateSubnetProxyTargets(
|
const targetToAdd = generateSubnetProxyTargetV2(
|
||||||
siteResource,
|
siteResource,
|
||||||
addedClients
|
addedClients
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetsToAdd.length > 0) {
|
if (targetToAdd) {
|
||||||
logger.info(
|
|
||||||
`Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
|
|
||||||
);
|
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
addSubnetProxyTargets(newt.newtId, targetsToAdd)
|
addSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[targetToAdd],
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,17 +697,18 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (removedClients.length > 0) {
|
if (removedClients.length > 0) {
|
||||||
const targetsToRemove = generateSubnetProxyTargets(
|
const targetToRemove = generateSubnetProxyTargetV2(
|
||||||
siteResource,
|
siteResource,
|
||||||
removedClients
|
removedClients
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetsToRemove.length > 0) {
|
if (targetToRemove) {
|
||||||
logger.info(
|
|
||||||
`Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
|
|
||||||
);
|
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
removeSubnetProxyTargets(newt.newtId, targetsToRemove)
|
removeSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[targetToRemove],
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,6 +1083,7 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1146,7 +1150,7 @@ async function handleMessagesForClientResources(
|
|||||||
// Add subnet proxy targets for each site
|
// Add subnet proxy targets for each site
|
||||||
for (const [siteId, resources] of addedBySite.entries()) {
|
for (const [siteId, resources] of addedBySite.entries()) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select({ newtId: newts.newtId })
|
.select({ newtId: newts.newtId, version: newts.version })
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -1159,7 +1163,7 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const targets = generateSubnetProxyTargets(resource, [
|
const target = generateSubnetProxyTargetV2(resource, [
|
||||||
{
|
{
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
pubKey: client.pubKey,
|
pubKey: client.pubKey,
|
||||||
@@ -1167,8 +1171,14 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (targets.length > 0) {
|
if (target) {
|
||||||
proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets));
|
proxyJobs.push(
|
||||||
|
addSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[target],
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1217,7 +1227,7 @@ async function handleMessagesForClientResources(
|
|||||||
// Remove subnet proxy targets for each site
|
// Remove subnet proxy targets for each site
|
||||||
for (const [siteId, resources] of removedBySite.entries()) {
|
for (const [siteId, resources] of removedBySite.entries()) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select({ newtId: newts.newtId })
|
.select({ newtId: newts.newtId, version: newts.version })
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -1230,7 +1240,7 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const targets = generateSubnetProxyTargets(resource, [
|
const target = generateSubnetProxyTargetV2(resource, [
|
||||||
{
|
{
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
pubKey: client.pubKey,
|
pubKey: client.pubKey,
|
||||||
@@ -1238,9 +1248,13 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (targets.length > 0) {
|
if (target) {
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
removeSubnetProxyTargets(newt.newtId, targets)
|
removeSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[target],
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export enum AudienceIds {
|
|
||||||
SignUps = "",
|
|
||||||
Subscribed = "",
|
|
||||||
Churned = "",
|
|
||||||
Newsletter = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
let resend;
|
|
||||||
export default resend;
|
|
||||||
|
|
||||||
export async function moveEmailToAudience(
|
|
||||||
email: string,
|
|
||||||
audienceId: AudienceIds
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
40
server/lib/sanitize.ts
Normal file
40
server/lib/sanitize.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Sanitize a string field before inserting into a database TEXT column.
|
||||||
|
*
|
||||||
|
* Two passes are applied:
|
||||||
|
*
|
||||||
|
* 1. Lone UTF-16 surrogates – JavaScript strings can hold unpaired surrogates
|
||||||
|
* (e.g. \uD800 without a following \uDC00-\uDFFF codepoint). These are
|
||||||
|
* valid in JS but cannot be encoded as UTF-8, triggering
|
||||||
|
* `report_invalid_encoding` in SQLite / Postgres. They are replaced with
|
||||||
|
* the Unicode replacement character U+FFFD so the data is preserved as a
|
||||||
|
* visible signal that something was malformed.
|
||||||
|
*
|
||||||
|
* 2. Null bytes and C0 control characters – SQLite stores TEXT as
|
||||||
|
* null-terminated C strings, so \x00 in a value causes
|
||||||
|
* `report_invalid_encoding`. Bots and scanners routinely inject null bytes
|
||||||
|
* into URLs (e.g. `/path\u0000.jpg`). All C0 control characters in the
|
||||||
|
* range \x00-\x1F are stripped except for the three that are legitimate in
|
||||||
|
* text payloads: HT (\x09), LF (\x0A), and CR (\x0D). DEL (\x7F) is also
|
||||||
|
* stripped.
|
||||||
|
*/
|
||||||
|
export function sanitizeString(value: string): string;
|
||||||
|
export function sanitizeString(
|
||||||
|
value: string | null | undefined
|
||||||
|
): string | undefined;
|
||||||
|
export function sanitizeString(
|
||||||
|
value: string | null | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
// Replace lone high surrogates (not followed by a low surrogate)
|
||||||
|
// and lone low surrogates (not preceded by a high surrogate).
|
||||||
|
.replace(
|
||||||
|
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
|
||||||
|
"\uFFFD"
|
||||||
|
)
|
||||||
|
// Strip null bytes, C0 control chars (except HT/LF/CR), and DEL.
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -218,10 +218,11 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch if it's been more than 24 hours (for renewals)
|
|
||||||
const dayInMs = 24 * 60 * 60 * 1000;
|
const dayInMs = 24 * 60 * 60 * 1000;
|
||||||
const timeSinceLastFetch =
|
const timeSinceLastFetch =
|
||||||
Date.now() - this.lastCertificateFetch.getTime();
|
Date.now() - this.lastCertificateFetch.getTime();
|
||||||
|
|
||||||
|
// Fetch if it's been more than 24 hours (daily routine check)
|
||||||
if (timeSinceLastFetch > dayInMs) {
|
if (timeSinceLastFetch > dayInMs) {
|
||||||
logger.info("Fetching certificates due to 24-hour renewal check");
|
logger.info("Fetching certificates due to 24-hour renewal check");
|
||||||
return true;
|
return true;
|
||||||
@@ -265,7 +266,7 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any local certificates are missing or appear to be outdated
|
// Check if any local certificates are missing (needs immediate fetch)
|
||||||
for (const domain of domainsNeedingCerts) {
|
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) {
|
||||||
@@ -274,13 +275,24 @@ export class TraefikConfigManager {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if certificate is expiring soon (within 30 days)
|
// For expiry checks, throttle to every 6 hours to avoid querying the
|
||||||
if (localState.expiresAt) {
|
// API/DB on every monitor loop. The certificate-service renews certs
|
||||||
|
// 45 days before expiry, so checking every 6 hours is plenty frequent
|
||||||
|
// to pick up renewed certs promptly.
|
||||||
|
const renewalCheckIntervalMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||||
|
if (timeSinceLastFetch > renewalCheckIntervalMs) {
|
||||||
|
// Check non-wildcard certs for expiry (within 45 days to match
|
||||||
|
// the server-side renewal window in certificate-service)
|
||||||
|
for (const domain of domainsNeedingCerts) {
|
||||||
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
|
if (localState?.expiresAt) {
|
||||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
const secondsUntilExpiry = localState.expiresAt - nowInSeconds;
|
const secondsUntilExpiry =
|
||||||
|
localState.expiresAt - nowInSeconds;
|
||||||
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
||||||
if (daysUntilExpiry < 30) {
|
if (daysUntilExpiry < 45) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
);
|
);
|
||||||
@@ -289,6 +301,24 @@ export class TraefikConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also check wildcard certificates for expiry. These are not
|
||||||
|
// included in domainsNeedingCerts since their subdomains are
|
||||||
|
// filtered out, so we must check them separately.
|
||||||
|
for (const [certDomain, state] of this.lastLocalCertificateState) {
|
||||||
|
if (state.exists && state.wildcard && state.expiresAt) {
|
||||||
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const secondsUntilExpiry = state.expiresAt - nowInSeconds;
|
||||||
|
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 45) {
|
||||||
|
logger.info(
|
||||||
|
`Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +391,26 @@ export class TraefikConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also include wildcard cert base domains that are
|
||||||
|
// expiring or expired so they get re-fetched even though
|
||||||
|
// their subdomains were filtered out above.
|
||||||
|
for (const [certDomain, state] of this
|
||||||
|
.lastLocalCertificateState) {
|
||||||
|
if (state.exists && state.wildcard && state.expiresAt) {
|
||||||
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const secondsUntilExpiry =
|
||||||
|
state.expiresAt - nowInSeconds;
|
||||||
|
const daysUntilExpiry =
|
||||||
|
secondsUntilExpiry / (60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 45) {
|
||||||
|
domainsToFetch.add(certDomain);
|
||||||
|
logger.info(
|
||||||
|
`Including expiring wildcard cert domain ${certDomain} in fetch (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (domainsToFetch.size > 0) {
|
if (domainsToFetch.size > 0) {
|
||||||
// Get valid certificates for domains not covered by wildcards
|
// Get valid certificates for domains not covered by wildcards
|
||||||
validCertificates =
|
validCertificates =
|
||||||
@@ -507,11 +557,18 @@ export class TraefikConfigManager {
|
|||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.session_cookie_name,
|
.session_cookie_name,
|
||||||
|
|
||||||
// deprecated
|
|
||||||
accessTokenQueryParam:
|
accessTokenQueryParam:
|
||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.resource_access_token_param,
|
.resource_access_token_param,
|
||||||
|
|
||||||
|
accessTokenIdHeader:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_headers.id,
|
||||||
|
|
||||||
|
accessTokenHeader:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_headers.token,
|
||||||
|
|
||||||
resourceSessionRequestParam:
|
resourceSessionRequestParam:
|
||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.resource_session_request_param
|
.resource_session_request_param
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logger from "@server/logger";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { resources, sites, Target, targets } from "@server/db";
|
import { resources, sites, Target, targets } from "@server/db";
|
||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
import { sanitize, validatePathRewriteConfig } from "./utils";
|
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -44,7 +44,7 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -127,7 +127,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
@@ -145,7 +145,7 @@ export async function getTraefikConfig(
|
|||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(key)) {
|
if (!resourcesMap.has(mapKey)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -160,9 +160,10 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(key, {
|
resourcesMap.set(mapKey, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
|
key: key,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -190,7 +191,7 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.get(key).targets.push({
|
resourcesMap.get(mapKey).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -227,8 +228,9 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [key, resource] of resourcesMap.entries()) {
|
for (const [, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets as TargetWithSite[];
|
||||||
|
const key = resource.key;
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-${resource.name}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
|
|||||||
323
server/lib/traefik/pathEncoding.test.ts
Normal file
323
server/lib/traefik/pathEncoding.test.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { assertEquals } from "../../../test/assert";
|
||||||
|
|
||||||
|
// ── Pure function copies (inlined to avoid pulling in server dependencies) ──
|
||||||
|
|
||||||
|
function sanitize(input: string | null | undefined): string | undefined {
|
||||||
|
if (!input) return undefined;
|
||||||
|
if (input.length > 50) {
|
||||||
|
input = input.substring(0, 50);
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
.replace(/[^a-zA-Z0-9-]/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePath(path: string | null | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||||
|
return ch.charCodeAt(0).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact replica of the OLD key computation from upstream main.
|
||||||
|
* Uses sanitize() for paths — this is what had the collision bug.
|
||||||
|
*/
|
||||||
|
function oldKeyComputation(
|
||||||
|
resourceId: number,
|
||||||
|
path: string | null,
|
||||||
|
pathMatchType: string | null,
|
||||||
|
rewritePath: string | null,
|
||||||
|
rewritePathType: string | null
|
||||||
|
): string {
|
||||||
|
const targetPath = sanitize(path) || "";
|
||||||
|
const pmt = pathMatchType || "";
|
||||||
|
const rp = rewritePath || "";
|
||||||
|
const rpt = rewritePathType || "";
|
||||||
|
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||||
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
return sanitize(mapKey) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replica of the NEW key computation from our fix.
|
||||||
|
* Uses encodePath() for paths — collision-free.
|
||||||
|
*/
|
||||||
|
function newKeyComputation(
|
||||||
|
resourceId: number,
|
||||||
|
path: string | null,
|
||||||
|
pathMatchType: string | null,
|
||||||
|
rewritePath: string | null,
|
||||||
|
rewritePathType: string | null
|
||||||
|
): string {
|
||||||
|
const targetPath = encodePath(path);
|
||||||
|
const pmt = pathMatchType || "";
|
||||||
|
const rp = rewritePath || "";
|
||||||
|
const rpt = rewritePathType || "";
|
||||||
|
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||||
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
return sanitize(mapKey) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log("Running path encoding tests...\n");
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
|
||||||
|
// ── encodePath unit tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
// Test 1: null/undefined/empty
|
||||||
|
{
|
||||||
|
assertEquals(encodePath(null), "", "null should return empty");
|
||||||
|
assertEquals(
|
||||||
|
encodePath(undefined),
|
||||||
|
"",
|
||||||
|
"undefined should return empty"
|
||||||
|
);
|
||||||
|
assertEquals(encodePath(""), "", "empty string should return empty");
|
||||||
|
console.log(" PASS: encodePath handles null/undefined/empty");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: root path
|
||||||
|
{
|
||||||
|
assertEquals(encodePath("/"), "2f", "/ should encode to 2f");
|
||||||
|
console.log(" PASS: encodePath encodes root path");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: alphanumeric passthrough
|
||||||
|
{
|
||||||
|
assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only");
|
||||||
|
assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only");
|
||||||
|
assertEquals(encodePath("abc"), "abc", "plain alpha passes through");
|
||||||
|
console.log(" PASS: encodePath preserves alphanumeric chars");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: all special chars produce unique hex
|
||||||
|
{
|
||||||
|
const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"];
|
||||||
|
const results = paths.map((p) => encodePath(p));
|
||||||
|
const unique = new Set(results);
|
||||||
|
assertEquals(
|
||||||
|
unique.size,
|
||||||
|
paths.length,
|
||||||
|
"all special-char paths must produce unique encodings"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" PASS: encodePath produces unique output for different special chars"
|
||||||
|
);
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: output is always alphanumeric (safe for Traefik names)
|
||||||
|
{
|
||||||
|
const paths = [
|
||||||
|
"/",
|
||||||
|
"/api",
|
||||||
|
"/a/b",
|
||||||
|
"/a-b",
|
||||||
|
"/a.b",
|
||||||
|
"/complex/path/here"
|
||||||
|
];
|
||||||
|
for (const p of paths) {
|
||||||
|
const e = encodePath(p);
|
||||||
|
assertEquals(
|
||||||
|
/^[a-zA-Z0-9]+$/.test(e),
|
||||||
|
true,
|
||||||
|
`encodePath("${p}") = "${e}" must be alphanumeric`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(" PASS: encodePath output is always alphanumeric");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: deterministic
|
||||||
|
{
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/api"),
|
||||||
|
encodePath("/api"),
|
||||||
|
"same input same output"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/a/b/c"),
|
||||||
|
encodePath("/a/b/c"),
|
||||||
|
"same input same output"
|
||||||
|
);
|
||||||
|
console.log(" PASS: encodePath is deterministic");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: many distinct paths never collide
|
||||||
|
{
|
||||||
|
const paths = [
|
||||||
|
"/",
|
||||||
|
"/api",
|
||||||
|
"/api/v1",
|
||||||
|
"/api/v2",
|
||||||
|
"/a/b",
|
||||||
|
"/a-b",
|
||||||
|
"/a.b",
|
||||||
|
"/a_b",
|
||||||
|
"/health",
|
||||||
|
"/health/check",
|
||||||
|
"/admin",
|
||||||
|
"/admin/users",
|
||||||
|
"/api/v1/users",
|
||||||
|
"/api/v1/posts",
|
||||||
|
"/app",
|
||||||
|
"/app/dashboard"
|
||||||
|
];
|
||||||
|
const encoded = new Set(paths.map((p) => encodePath(p)));
|
||||||
|
assertEquals(
|
||||||
|
encoded.size,
|
||||||
|
paths.length,
|
||||||
|
`expected ${paths.length} unique encodings, got ${encoded.size}`
|
||||||
|
);
|
||||||
|
console.log(" PASS: 16 realistic paths all produce unique encodings");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collision fix: the actual bug we're fixing ───────────────────
|
||||||
|
|
||||||
|
// Test 8: /a/b and /a-b now have different keys (THE BUG FIX)
|
||||||
|
{
|
||||||
|
const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null);
|
||||||
|
const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
keyAB !== keyDash,
|
||||||
|
true,
|
||||||
|
"/a/b and /a-b MUST have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: collision fix — /a/b vs /a-b have different keys");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key
|
||||||
|
{
|
||||||
|
const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null);
|
||||||
|
const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
oldKeyAB,
|
||||||
|
oldKeyDash,
|
||||||
|
"old code MUST have this collision (confirms the bug exists)"
|
||||||
|
);
|
||||||
|
console.log(" PASS: confirmed old code bug — /a/b and /a-b collided");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it
|
||||||
|
{
|
||||||
|
const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||||
|
const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
oldKey1,
|
||||||
|
oldKey2,
|
||||||
|
"old code collision for /api/v1 vs /api-v1"
|
||||||
|
);
|
||||||
|
|
||||||
|
const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||||
|
const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
newKey1 !== newKey2,
|
||||||
|
true,
|
||||||
|
"new code must separate /api/v1 and /api-v1"
|
||||||
|
);
|
||||||
|
console.log(" PASS: collision fix — /api/v1 vs /api-v1");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed
|
||||||
|
{
|
||||||
|
const a = newKeyComputation(1, "/app.v2", "prefix", null, null);
|
||||||
|
const b = newKeyComputation(1, "/app/v2", "prefix", null, null);
|
||||||
|
const c = newKeyComputation(1, "/app-v2", "prefix", null, null);
|
||||||
|
const keys = new Set([a, b, c]);
|
||||||
|
assertEquals(
|
||||||
|
keys.size,
|
||||||
|
3,
|
||||||
|
"three paths must produce three unique keys"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" PASS: collision fix — three-way /app.v2, /app/v2, /app-v2"
|
||||||
|
);
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge cases ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Test 12: same path in different resources — always separate
|
||||||
|
{
|
||||||
|
const key1 = newKeyComputation(1, "/api", "prefix", null, null);
|
||||||
|
const key2 = newKeyComputation(2, "/api", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
key1 !== key2,
|
||||||
|
true,
|
||||||
|
"different resources with same path must have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — same path, different resources");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 13: same resource, different pathMatchType — separate keys
|
||||||
|
{
|
||||||
|
const exact = newKeyComputation(1, "/api", "exact", null, null);
|
||||||
|
const prefix = newKeyComputation(1, "/api", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
exact !== prefix,
|
||||||
|
true,
|
||||||
|
"exact vs prefix must have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — same path, different match types");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 14: same resource and path, different rewrite config — separate keys
|
||||||
|
{
|
||||||
|
const noRewrite = newKeyComputation(1, "/api", "prefix", null, null);
|
||||||
|
const withRewrite = newKeyComputation(
|
||||||
|
1,
|
||||||
|
"/api",
|
||||||
|
"prefix",
|
||||||
|
"/backend",
|
||||||
|
"prefix"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
noRewrite !== withRewrite,
|
||||||
|
true,
|
||||||
|
"with vs without rewrite must have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — same path, different rewrite config");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 15: paths with special URL characters
|
||||||
|
{
|
||||||
|
const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"];
|
||||||
|
const keys = new Set(
|
||||||
|
paths.map((p) => newKeyComputation(1, p, "prefix", null, null))
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
keys.size,
|
||||||
|
paths.length,
|
||||||
|
"special URL chars must produce unique keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — special URL characters in paths");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nAll ${passed} tests passed!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -13,6 +13,26 @@ export function sanitize(input: string | null | undefined): string | undefined {
|
|||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a URL path into a collision-free alphanumeric string suitable for use
|
||||||
|
* in Traefik map keys.
|
||||||
|
*
|
||||||
|
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
|
||||||
|
* character as its hex code. Different paths always produce different outputs.
|
||||||
|
*
|
||||||
|
* encodePath("/api") => "2fapi"
|
||||||
|
* encodePath("/a/b") => "2fa2fb"
|
||||||
|
* encodePath("/a-b") => "2fa2db" (different from /a/b)
|
||||||
|
* encodePath("/") => "2f"
|
||||||
|
* encodePath(null) => ""
|
||||||
|
*/
|
||||||
|
export function encodePath(path: string | null | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||||
|
return ch.charCodeAt(0).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function validatePathRewriteConfig(
|
export function validatePathRewriteConfig(
|
||||||
path: string | null,
|
path: string | null,
|
||||||
pathMatchType: string | null,
|
pathMatchType: string | null,
|
||||||
|
|||||||
@@ -13,8 +13,14 @@
|
|||||||
|
|
||||||
import { rateLimitService } from "#private/lib/rateLimit";
|
import { rateLimitService } from "#private/lib/rateLimit";
|
||||||
import { cleanup as wsCleanup } from "#private/routers/ws";
|
import { cleanup as wsCleanup } from "#private/routers/ws";
|
||||||
|
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||||
|
import { flushConnectionLogToDb } from "#dynamic/routers/newt";
|
||||||
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
|
await flushBandwidthToDb();
|
||||||
|
await flushConnectionLogToDb();
|
||||||
|
await flushSiteBandwidthToDb();
|
||||||
await rateLimitService.cleanup();
|
await rateLimitService.cleanup();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
|
|
||||||
|
|||||||
@@ -24,23 +24,31 @@ setInterval(() => {
|
|||||||
*/
|
*/
|
||||||
class AdaptiveCache {
|
class AdaptiveCache {
|
||||||
private useRedis(): boolean {
|
private useRedis(): boolean {
|
||||||
return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy;
|
return (
|
||||||
|
redisManager.isRedisEnabled() &&
|
||||||
|
redisManager.getHealthStatus().isHealthy
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a value in the cache
|
* Set a value in the cache
|
||||||
* @param key - Cache key
|
* @param key - Cache key
|
||||||
* @param value - Value to cache (will be JSON stringified for Redis)
|
* @param value - Value to cache (will be JSON stringified for Redis)
|
||||||
* @param ttl - Time to live in seconds (0 = no expiration)
|
* @param ttl - Time to live in seconds (0 = no expiration; omit = 3600s for Redis)
|
||||||
* @returns boolean indicating success
|
* @returns boolean indicating success
|
||||||
*/
|
*/
|
||||||
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||||
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||||
|
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||||
|
|
||||||
if (this.useRedis()) {
|
if (this.useRedis()) {
|
||||||
try {
|
try {
|
||||||
const serialized = JSON.stringify(value);
|
const serialized = JSON.stringify(value);
|
||||||
const success = await redisManager.set(key, serialized, effectiveTtl);
|
const success = await redisManager.set(
|
||||||
|
key,
|
||||||
|
serialized,
|
||||||
|
redisTtl
|
||||||
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logger.debug(`Set key in Redis: ${key}`);
|
logger.debug(`Set key in Redis: ${key}`);
|
||||||
@@ -48,7 +56,9 @@ class AdaptiveCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redis failed, fall through to local cache
|
// Redis failed, fall through to local cache
|
||||||
logger.debug(`Redis set failed for key ${key}, falling back to local cache`);
|
logger.debug(
|
||||||
|
`Redis set failed for key ${key}, falling back to local cache`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Redis set error for key ${key}:`, error);
|
logger.error(`Redis set error for key ${key}:`, error);
|
||||||
// Fall through to local cache
|
// Fall through to local cache
|
||||||
@@ -120,9 +130,14 @@ class AdaptiveCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Some Redis deletes failed, fall through to local cache
|
// Some Redis deletes failed, fall through to local cache
|
||||||
logger.debug(`Some Redis deletes failed, falling back to local cache`);
|
logger.debug(
|
||||||
|
`Some Redis deletes failed, falling back to local cache`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Redis del error for keys ${keys.join(", ")}:`, error);
|
logger.error(
|
||||||
|
`Redis del error for keys ${keys.join(", ")}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
// Fall through to local cache
|
// Fall through to local cache
|
||||||
deletedCount = 0;
|
deletedCount = 0;
|
||||||
}
|
}
|
||||||
@@ -195,7 +210,9 @@ class AdaptiveCache {
|
|||||||
*/
|
*/
|
||||||
async flushAll(): Promise<void> {
|
async flushAll(): Promise<void> {
|
||||||
if (this.useRedis()) {
|
if (this.useRedis()) {
|
||||||
logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed");
|
logger.warn(
|
||||||
|
"Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
localCache.flushAll();
|
localCache.flushAll();
|
||||||
@@ -239,7 +256,9 @@ class AdaptiveCache {
|
|||||||
getTtl(key: string): number {
|
getTtl(key: string): number {
|
||||||
// Note: This only works for local cache, Redis TTL is not supported
|
// Note: This only works for local cache, Redis TTL is not supported
|
||||||
if (this.useRedis()) {
|
if (this.useRedis()) {
|
||||||
logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`);
|
logger.warn(
|
||||||
|
`getTtl called for key ${key} but Redis TTL lookup is not implemented`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttl = localCache.getTtl(key);
|
const ttl = localCache.getTtl(key);
|
||||||
@@ -255,7 +274,9 @@ class AdaptiveCache {
|
|||||||
*/
|
*/
|
||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
if (this.useRedis()) {
|
if (this.useRedis()) {
|
||||||
logger.warn("keys() called but Redis keys are not included, only local cache keys returned");
|
logger.warn(
|
||||||
|
"keys() called but Redis keys are not included, only local cache keys returned"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ export const privateConfigSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||||
resend_api_key: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
|
||||||
reo_client_id: z
|
reo_client_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -61,7 +57,10 @@ export const privateConfigSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
host: z.string(),
|
host: z.string(),
|
||||||
port: portSchema,
|
port: portSchema,
|
||||||
password: z.string().optional(),
|
password: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("REDIS_PASSWORD")),
|
||||||
db: z.int().nonnegative().optional().default(0),
|
db: z.int().nonnegative().optional().default(0),
|
||||||
replicas: z
|
replicas: z
|
||||||
.array(
|
.array(
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Resend } from "resend";
|
|
||||||
import privateConfig from "#private/lib/config";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export enum AudienceIds {
|
|
||||||
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
|
|
||||||
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
|
|
||||||
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
|
|
||||||
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(
|
|
||||||
privateConfig.getRawPrivateConfig().server.resend_api_key || "missing"
|
|
||||||
);
|
|
||||||
|
|
||||||
export default resend;
|
|
||||||
|
|
||||||
export async function moveEmailToAudience(
|
|
||||||
email: string,
|
|
||||||
audienceId: AudienceIds
|
|
||||||
) {
|
|
||||||
if (process.env.ENVIRONMENT !== "prod") {
|
|
||||||
logger.debug(
|
|
||||||
`Skipping moving email ${email} to audience ${audienceId} in non-prod environment`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { error, data } = await retryWithBackoff(async () => {
|
|
||||||
const { data, error } = await resend.contacts.create({
|
|
||||||
email,
|
|
||||||
unsubscribed: false,
|
|
||||||
audienceId
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { error, data };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error(
|
|
||||||
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
logger.debug(
|
|
||||||
`Added email ${email} to audience ${audienceId} with contact ID ${data.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherAudiences = Object.values(AudienceIds).filter(
|
|
||||||
(id) => id !== audienceId
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const otherAudienceId of otherAudiences) {
|
|
||||||
const { error, data } = await retryWithBackoff(async () => {
|
|
||||||
const { data, error } = await resend.contacts.remove({
|
|
||||||
email,
|
|
||||||
audienceId: otherAudienceId
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { error, data };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error(
|
|
||||||
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
logger.info(
|
|
||||||
`Removed email ${email} from audience ${otherAudienceId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RetryOptions = {
|
|
||||||
retries?: number;
|
|
||||||
initialDelayMs?: number;
|
|
||||||
factor?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function retryWithBackoff<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options: RetryOptions = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const { retries = 5, initialDelayMs = 500, factor = 2 } = options;
|
|
||||||
|
|
||||||
let attempt = 0;
|
|
||||||
let delay = initialDelayMs;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (err) {
|
|
||||||
attempt++;
|
|
||||||
|
|
||||||
if (attempt > retries) throw err;
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
delay *= factor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||||
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
|
import {
|
||||||
|
sanitize,
|
||||||
|
encodePath,
|
||||||
|
validatePathRewriteConfig
|
||||||
|
} from "@server/lib/traefik/utils";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||||
import {
|
import {
|
||||||
@@ -170,7 +174,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
@@ -192,7 +196,7 @@ export async function getTraefikConfig(
|
|||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(key)) {
|
if (!resourcesMap.has(mapKey)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -207,9 +211,10 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(key, {
|
resourcesMap.set(mapKey, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
|
key: key,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -243,7 +248,7 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add target with its associated site data
|
// Add target with its associated site data
|
||||||
resourcesMap.get(key).targets.push({
|
resourcesMap.get(mapKey).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -296,8 +301,9 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [key, resource] of resourcesMap.entries()) {
|
for (const [, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets as TargetWithSite[];
|
||||||
|
const key = resource.key;
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-${resource.name}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
|
|||||||
99
server/private/routers/auditLogs/exportConnectionAuditLog.ts
Normal file
99
server/private/routers/auditLogs/exportConnectionAuditLog.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registry } from "@server/openApi";
|
||||||
|
import { NextFunction } from "express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { OpenAPITags } from "@server/openApi";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import {
|
||||||
|
queryConnectionAuditLogsParams,
|
||||||
|
queryConnectionAuditLogsQuery,
|
||||||
|
queryConnection,
|
||||||
|
countConnectionQuery
|
||||||
|
} from "./queryConnectionAuditLog";
|
||||||
|
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
|
||||||
|
import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs";
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/logs/connection/export",
|
||||||
|
description: "Export the connection audit log for an organization as CSV",
|
||||||
|
tags: [OpenAPITags.Logs],
|
||||||
|
request: {
|
||||||
|
query: queryConnectionAuditLogsQuery,
|
||||||
|
params: queryConnectionAuditLogsParams
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function exportConnectionAuditLogs(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = queryConnectionAuditLogsParams.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
const [{ count }] = await countConnectionQuery(data);
|
||||||
|
if (count > MAX_EXPORT_LIMIT) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryConnection(data);
|
||||||
|
|
||||||
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/csv");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="connection-audit-logs-${data.orgId}-${Date.now()}.csv"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send(csvData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,3 +15,5 @@ export * from "./queryActionAuditLog";
|
|||||||
export * from "./exportActionAuditLog";
|
export * from "./exportActionAuditLog";
|
||||||
export * from "./queryAccessAuditLog";
|
export * from "./queryAccessAuditLog";
|
||||||
export * from "./exportAccessAuditLog";
|
export * from "./exportAccessAuditLog";
|
||||||
|
export * from "./queryConnectionAuditLog";
|
||||||
|
export * from "./exportConnectionAuditLog";
|
||||||
|
|||||||
524
server/private/routers/auditLogs/queryConnectionAuditLog.ts
Normal file
524
server/private/routers/auditLogs/queryConnectionAuditLog.ts
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
connectionAuditLog,
|
||||||
|
logsDb,
|
||||||
|
siteResources,
|
||||||
|
sites,
|
||||||
|
clients,
|
||||||
|
users,
|
||||||
|
primaryDb
|
||||||
|
} from "@server/db";
|
||||||
|
import { registry } from "@server/openApi";
|
||||||
|
import { NextFunction } from "express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
|
||||||
|
import { OpenAPITags } from "@server/openApi";
|
||||||
|
import { z } from "zod";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
|
||||||
|
export const queryConnectionAuditLogsQuery = z.object({
|
||||||
|
// iso string just validate its a parseable date
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
|
.prefault(() => getSevenDaysAgo().toISOString())
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description:
|
||||||
|
"Start time as ISO date string (defaults to 7 days ago)"
|
||||||
|
}),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
|
.optional()
|
||||||
|
.prefault(() => new Date().toISOString())
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description:
|
||||||
|
"End time as ISO date string (defaults to current time)"
|
||||||
|
}),
|
||||||
|
protocol: z.string().optional(),
|
||||||
|
sourceAddr: z.string().optional(),
|
||||||
|
destAddr: z.string().optional(),
|
||||||
|
clientId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional(),
|
||||||
|
siteId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional(),
|
||||||
|
siteResourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryConnectionAuditLogsParams = z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryConnectionAuditLogsCombined =
|
||||||
|
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
|
||||||
|
type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
|
||||||
|
|
||||||
|
function getWhere(data: Q) {
|
||||||
|
return and(
|
||||||
|
gt(connectionAuditLog.startedAt, data.timeStart),
|
||||||
|
lt(connectionAuditLog.startedAt, data.timeEnd),
|
||||||
|
eq(connectionAuditLog.orgId, data.orgId),
|
||||||
|
data.protocol
|
||||||
|
? eq(connectionAuditLog.protocol, data.protocol)
|
||||||
|
: undefined,
|
||||||
|
data.sourceAddr
|
||||||
|
? eq(connectionAuditLog.sourceAddr, data.sourceAddr)
|
||||||
|
: undefined,
|
||||||
|
data.destAddr
|
||||||
|
? eq(connectionAuditLog.destAddr, data.destAddr)
|
||||||
|
: undefined,
|
||||||
|
data.clientId
|
||||||
|
? eq(connectionAuditLog.clientId, data.clientId)
|
||||||
|
: undefined,
|
||||||
|
data.siteId
|
||||||
|
? eq(connectionAuditLog.siteId, data.siteId)
|
||||||
|
: undefined,
|
||||||
|
data.siteResourceId
|
||||||
|
? eq(connectionAuditLog.siteResourceId, data.siteResourceId)
|
||||||
|
: undefined,
|
||||||
|
data.userId
|
||||||
|
? eq(connectionAuditLog.userId, data.userId)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryConnection(data: Q) {
|
||||||
|
return logsDb
|
||||||
|
.select({
|
||||||
|
sessionId: connectionAuditLog.sessionId,
|
||||||
|
siteResourceId: connectionAuditLog.siteResourceId,
|
||||||
|
orgId: connectionAuditLog.orgId,
|
||||||
|
siteId: connectionAuditLog.siteId,
|
||||||
|
clientId: connectionAuditLog.clientId,
|
||||||
|
userId: connectionAuditLog.userId,
|
||||||
|
sourceAddr: connectionAuditLog.sourceAddr,
|
||||||
|
destAddr: connectionAuditLog.destAddr,
|
||||||
|
protocol: connectionAuditLog.protocol,
|
||||||
|
startedAt: connectionAuditLog.startedAt,
|
||||||
|
endedAt: connectionAuditLog.endedAt,
|
||||||
|
bytesTx: connectionAuditLog.bytesTx,
|
||||||
|
bytesRx: connectionAuditLog.bytesRx
|
||||||
|
})
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(getWhere(data))
|
||||||
|
.orderBy(
|
||||||
|
desc(connectionAuditLog.startedAt),
|
||||||
|
desc(connectionAuditLog.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countConnectionQuery(data: Q) {
|
||||||
|
const countQuery = logsDb
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(getWhere(data));
|
||||||
|
return countQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichWithDetails(
|
||||||
|
logs: Awaited<ReturnType<typeof queryConnection>>
|
||||||
|
) {
|
||||||
|
// Collect unique IDs from logs
|
||||||
|
const siteResourceIds = [
|
||||||
|
...new Set(
|
||||||
|
logs
|
||||||
|
.map((log) => log.siteResourceId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
const siteIds = [
|
||||||
|
...new Set(
|
||||||
|
logs
|
||||||
|
.map((log) => log.siteId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
const clientIds = [
|
||||||
|
...new Set(
|
||||||
|
logs
|
||||||
|
.map((log) => log.clientId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
const userIds = [
|
||||||
|
...new Set(
|
||||||
|
logs
|
||||||
|
.map((log) => log.userId)
|
||||||
|
.filter((id): id is string => id !== null && id !== undefined)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch resource details from main database
|
||||||
|
const resourceMap = new Map<
|
||||||
|
number,
|
||||||
|
{ name: string; niceId: string }
|
||||||
|
>();
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const resourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
niceId: siteResources.niceId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
for (const r of resourceDetails) {
|
||||||
|
resourceMap.set(r.siteResourceId, {
|
||||||
|
name: r.name,
|
||||||
|
niceId: r.niceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch site details from main database
|
||||||
|
const siteMap = new Map<number, { name: string; niceId: string }>();
|
||||||
|
if (siteIds.length > 0) {
|
||||||
|
const siteDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteId: sites.siteId,
|
||||||
|
name: sites.name,
|
||||||
|
niceId: sites.niceId
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.where(inArray(sites.siteId, siteIds));
|
||||||
|
|
||||||
|
for (const s of siteDetails) {
|
||||||
|
siteMap.set(s.siteId, { name: s.name, niceId: s.niceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch client details from main database
|
||||||
|
const clientMap = new Map<
|
||||||
|
number,
|
||||||
|
{ name: string; niceId: string; type: string }
|
||||||
|
>();
|
||||||
|
if (clientIds.length > 0) {
|
||||||
|
const clientDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
name: clients.name,
|
||||||
|
niceId: clients.niceId,
|
||||||
|
type: clients.type
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(inArray(clients.clientId, clientIds));
|
||||||
|
|
||||||
|
for (const c of clientDetails) {
|
||||||
|
clientMap.set(c.clientId, {
|
||||||
|
name: c.name,
|
||||||
|
niceId: c.niceId,
|
||||||
|
type: c.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user details from main database
|
||||||
|
const userMap = new Map<
|
||||||
|
string,
|
||||||
|
{ email: string | null }
|
||||||
|
>();
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const userDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
userId: users.userId,
|
||||||
|
email: users.email
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.userId, userIds));
|
||||||
|
|
||||||
|
for (const u of userDetails) {
|
||||||
|
userMap.set(u.userId, { email: u.email });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich logs with details
|
||||||
|
return logs.map((log) => ({
|
||||||
|
...log,
|
||||||
|
resourceName: log.siteResourceId
|
||||||
|
? resourceMap.get(log.siteResourceId)?.name ?? null
|
||||||
|
: null,
|
||||||
|
resourceNiceId: log.siteResourceId
|
||||||
|
? resourceMap.get(log.siteResourceId)?.niceId ?? null
|
||||||
|
: null,
|
||||||
|
siteName: log.siteId
|
||||||
|
? siteMap.get(log.siteId)?.name ?? null
|
||||||
|
: null,
|
||||||
|
siteNiceId: log.siteId
|
||||||
|
? siteMap.get(log.siteId)?.niceId ?? null
|
||||||
|
: null,
|
||||||
|
clientName: log.clientId
|
||||||
|
? clientMap.get(log.clientId)?.name ?? null
|
||||||
|
: null,
|
||||||
|
clientNiceId: log.clientId
|
||||||
|
? clientMap.get(log.clientId)?.niceId ?? null
|
||||||
|
: null,
|
||||||
|
clientType: log.clientId
|
||||||
|
? clientMap.get(log.clientId)?.type ?? null
|
||||||
|
: null,
|
||||||
|
userEmail: log.userId
|
||||||
|
? userMap.get(log.userId)?.email ?? null
|
||||||
|
: null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryUniqueFilterAttributes(
|
||||||
|
timeStart: number,
|
||||||
|
timeEnd: number,
|
||||||
|
orgId: string
|
||||||
|
) {
|
||||||
|
const baseConditions = and(
|
||||||
|
gt(connectionAuditLog.startedAt, timeStart),
|
||||||
|
lt(connectionAuditLog.startedAt, timeEnd),
|
||||||
|
eq(connectionAuditLog.orgId, orgId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get unique protocols
|
||||||
|
const uniqueProtocols = await logsDb
|
||||||
|
.selectDistinct({
|
||||||
|
protocol: connectionAuditLog.protocol
|
||||||
|
})
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique destination addresses
|
||||||
|
const uniqueDestAddrs = await logsDb
|
||||||
|
.selectDistinct({
|
||||||
|
destAddr: connectionAuditLog.destAddr
|
||||||
|
})
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique client IDs
|
||||||
|
const uniqueClients = await logsDb
|
||||||
|
.selectDistinct({
|
||||||
|
clientId: connectionAuditLog.clientId
|
||||||
|
})
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique resource IDs
|
||||||
|
const uniqueResources = await logsDb
|
||||||
|
.selectDistinct({
|
||||||
|
siteResourceId: connectionAuditLog.siteResourceId
|
||||||
|
})
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Get unique user IDs
|
||||||
|
const uniqueUsers = await logsDb
|
||||||
|
.selectDistinct({
|
||||||
|
userId: connectionAuditLog.userId
|
||||||
|
})
|
||||||
|
.from(connectionAuditLog)
|
||||||
|
.where(baseConditions);
|
||||||
|
|
||||||
|
// Enrich client IDs with names from main database
|
||||||
|
const clientIds = uniqueClients
|
||||||
|
.map((row) => row.clientId)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
let clientsWithNames: Array<{ id: number; name: string }> = [];
|
||||||
|
if (clientIds.length > 0) {
|
||||||
|
const clientDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
name: clients.name
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(inArray(clients.clientId, clientIds));
|
||||||
|
|
||||||
|
clientsWithNames = clientDetails.map((c) => ({
|
||||||
|
id: c.clientId,
|
||||||
|
name: c.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich resource IDs with names from main database
|
||||||
|
const resourceIds = uniqueResources
|
||||||
|
.map((row) => row.siteResourceId)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||||
|
if (resourceIds.length > 0) {
|
||||||
|
const resourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, resourceIds));
|
||||||
|
|
||||||
|
resourcesWithNames = resourceDetails.map((r) => ({
|
||||||
|
id: r.siteResourceId,
|
||||||
|
name: r.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich user IDs with emails from main database
|
||||||
|
const userIdsList = uniqueUsers
|
||||||
|
.map((row) => row.userId)
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
let usersWithEmails: Array<{ id: string; email: string | null }> = [];
|
||||||
|
if (userIdsList.length > 0) {
|
||||||
|
const userDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
userId: users.userId,
|
||||||
|
email: users.email
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.userId, userIdsList));
|
||||||
|
|
||||||
|
usersWithEmails = userDetails.map((u) => ({
|
||||||
|
id: u.userId,
|
||||||
|
email: u.email
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocols: uniqueProtocols
|
||||||
|
.map((row) => row.protocol)
|
||||||
|
.filter((protocol): protocol is string => protocol !== null),
|
||||||
|
destAddrs: uniqueDestAddrs
|
||||||
|
.map((row) => row.destAddr)
|
||||||
|
.filter((addr): addr is string => addr !== null),
|
||||||
|
clients: clientsWithNames,
|
||||||
|
resources: resourcesWithNames,
|
||||||
|
users: usersWithEmails
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/logs/connection",
|
||||||
|
description: "Query the connection audit log for an organization",
|
||||||
|
tags: [OpenAPITags.Logs],
|
||||||
|
request: {
|
||||||
|
query: queryConnectionAuditLogsQuery,
|
||||||
|
params: queryConnectionAuditLogsParams
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function queryConnectionAuditLogs(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsedParams = queryConnectionAuditLogsParams.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
|
const baseQuery = queryConnection(data);
|
||||||
|
|
||||||
|
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
|
// Enrich with resource, site, client, and user details
|
||||||
|
const log = await enrichWithDetails(logsRaw);
|
||||||
|
|
||||||
|
const totalCountResult = await countConnectionQuery(data);
|
||||||
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
const filterAttributes = await queryUniqueFilterAttributes(
|
||||||
|
data.timeStart,
|
||||||
|
data.timeEnd,
|
||||||
|
data.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<QueryConnectionAuditLogResponse>(res, {
|
||||||
|
data: {
|
||||||
|
log: log,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit: data.limit,
|
||||||
|
offset: data.offset
|
||||||
|
},
|
||||||
|
filterAttributes
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Connection audit logs retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
|
||||||
import { getSubType } from "./getSubType";
|
import { getSubType } from "./getSubType";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||||
@@ -172,7 +171,7 @@ export async function handleSubscriptionCreated(
|
|||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
moveEmailToAudience(email, AudienceIds.Subscribed);
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
|
||||||
import { getSubType } from "./getSubType";
|
import { getSubType } from "./getSubType";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
@@ -109,7 +108,7 @@ export async function handleSubscriptionDeleted(
|
|||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
moveEmailToAudience(email, AudienceIds.Churned);
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
|
|||||||
@@ -478,6 +478,25 @@ authenticated.get(
|
|||||||
logs.exportAccessAuditLogs
|
logs.exportAccessAuditLogs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/connection",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.connectionLogs),
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
|
logs.queryConnectionAuditLogs
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/connection/export",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.logExport),
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
|
logs.exportConnectionAuditLogs
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/re-key/:clientId/regenerate-client-secret",
|
"/re-key/:clientId/regenerate-client-secret",
|
||||||
verifyClientAccess, // this is first to set the org id
|
verifyClientAccess, // this is first to set the org id
|
||||||
@@ -515,6 +534,6 @@ authenticated.post(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.signSshKey),
|
verifyUserHasAction(ActionsEnum.signSshKey),
|
||||||
logActionAudit(ActionsEnum.signSshKey),
|
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
|
||||||
ssh.signSshKey
|
ssh.signSshKey
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { verifySessionRemoteExitNodeMiddleware } from "#private/middlewares/veri
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
|
logsDb,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
Resource,
|
Resource,
|
||||||
ResourcePassword,
|
ResourcePassword,
|
||||||
@@ -81,6 +82,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
|
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
|
||||||
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
|
||||||
|
import { sanitizeString } from "@server/lib/sanitize";
|
||||||
|
|
||||||
// Zod schemas for request validation
|
// Zod schemas for request validation
|
||||||
const getResourceByDomainParamsSchema = z.strictObject({
|
const getResourceByDomainParamsSchema = z.strictObject({
|
||||||
@@ -1859,24 +1861,24 @@ hybridRouter.post(
|
|||||||
})
|
})
|
||||||
.map((logEntry) => ({
|
.map((logEntry) => ({
|
||||||
timestamp: logEntry.timestamp,
|
timestamp: logEntry.timestamp,
|
||||||
orgId: logEntry.orgId,
|
orgId: sanitizeString(logEntry.orgId),
|
||||||
actorType: logEntry.actorType,
|
actorType: sanitizeString(logEntry.actorType),
|
||||||
actor: logEntry.actor,
|
actor: sanitizeString(logEntry.actor),
|
||||||
actorId: logEntry.actorId,
|
actorId: sanitizeString(logEntry.actorId),
|
||||||
metadata: logEntry.metadata,
|
metadata: sanitizeString(logEntry.metadata),
|
||||||
action: logEntry.action,
|
action: logEntry.action,
|
||||||
resourceId: logEntry.resourceId,
|
resourceId: logEntry.resourceId,
|
||||||
reason: logEntry.reason,
|
reason: logEntry.reason,
|
||||||
location: logEntry.location,
|
location: sanitizeString(logEntry.location),
|
||||||
// userAgent: data.userAgent, // TODO: add this
|
// userAgent: data.userAgent, // TODO: add this
|
||||||
// headers: data.body.headers,
|
// headers: data.body.headers,
|
||||||
// query: data.body.query,
|
// query: data.body.query,
|
||||||
originalRequestURL: logEntry.originalRequestURL,
|
originalRequestURL: sanitizeString(logEntry.originalRequestURL) ?? "",
|
||||||
scheme: logEntry.scheme,
|
scheme: sanitizeString(logEntry.scheme) ?? "",
|
||||||
host: logEntry.host,
|
host: sanitizeString(logEntry.host) ?? "",
|
||||||
path: logEntry.path,
|
path: sanitizeString(logEntry.path) ?? "",
|
||||||
method: logEntry.method,
|
method: sanitizeString(logEntry.method) ?? "",
|
||||||
ip: logEntry.ip,
|
ip: sanitizeString(logEntry.ip),
|
||||||
tls: logEntry.tls
|
tls: logEntry.tls
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1884,7 +1886,7 @@ hybridRouter.post(
|
|||||||
const batchSize = 100;
|
const batchSize = 100;
|
||||||
for (let i = 0; i < logEntries.length; i += batchSize) {
|
for (let i = 0; i < logEntries.length; i += batchSize) {
|
||||||
const batch = logEntries.slice(i, i + batchSize);
|
const batch = logEntries.slice(i, i + batchSize);
|
||||||
await db.insert(requestAuditLog).values(batch);
|
await logsDb.insert(requestAuditLog).values(batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ authenticated.get(
|
|||||||
logs.exportAccessAuditLogs
|
logs.exportAccessAuditLogs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/connection",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.connectionLogs),
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
|
logs.queryConnectionAuditLogs
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/connection/export",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.logExport),
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
|
logs.exportConnectionAuditLogs
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/idp/oidc",
|
"/org/:orgId/idp/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
394
server/private/routers/newt/handleConnectionLogMessage.ts
Normal file
394
server/private/routers/newt/handleConnectionLogMessage.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { db, logsDb } from "@server/db";
|
||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { connectionAuditLog, sites, Newt, clients, orgs } from "@server/db";
|
||||||
|
import { and, eq, lt, inArray } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { inflate } from "zlib";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
|
|
||||||
|
const zlibInflate = promisify(inflate);
|
||||||
|
|
||||||
|
// Retry configuration for deadlock handling
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
// How often to flush accumulated connection log data to the database
|
||||||
|
const FLUSH_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
// Maximum number of records to buffer before forcing a flush
|
||||||
|
const MAX_BUFFERED_RECORDS = 500;
|
||||||
|
|
||||||
|
// Maximum number of records to insert in a single batch
|
||||||
|
const INSERT_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
interface ConnectionSessionData {
|
||||||
|
sessionId: string;
|
||||||
|
resourceId: number;
|
||||||
|
sourceAddr: string;
|
||||||
|
destAddr: string;
|
||||||
|
protocol: string;
|
||||||
|
startedAt: string; // ISO 8601 timestamp
|
||||||
|
endedAt?: string; // ISO 8601 timestamp
|
||||||
|
bytesTx?: number;
|
||||||
|
bytesRx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionLogRecord {
|
||||||
|
sessionId: string;
|
||||||
|
siteResourceId: number;
|
||||||
|
orgId: string;
|
||||||
|
siteId: number;
|
||||||
|
clientId: number | null;
|
||||||
|
userId: string | null;
|
||||||
|
sourceAddr: string;
|
||||||
|
destAddr: string;
|
||||||
|
protocol: string;
|
||||||
|
startedAt: number; // epoch seconds
|
||||||
|
endedAt: number | null;
|
||||||
|
bytesTx: number | null;
|
||||||
|
bytesRx: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory buffer of records waiting to be flushed
|
||||||
|
let buffer: ConnectionLogRecord[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a deadlock error
|
||||||
|
*/
|
||||||
|
function isDeadlockError(error: any): boolean {
|
||||||
|
return (
|
||||||
|
error?.code === "40P01" ||
|
||||||
|
error?.cause?.code === "40P01" ||
|
||||||
|
(error?.message && error.message.includes("deadlock"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function with retry logic for deadlock handling
|
||||||
|
*/
|
||||||
|
async function withDeadlockRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string
|
||||||
|
): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isDeadlockError(error) && attempt < MAX_RETRIES) {
|
||||||
|
attempt++;
|
||||||
|
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
||||||
|
const jitter = Math.random() * baseDelay;
|
||||||
|
const delay = baseDelay + jitter;
|
||||||
|
logger.warn(
|
||||||
|
`Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||||
|
*/
|
||||||
|
async function decompressConnectionLog(
|
||||||
|
compressed: string
|
||||||
|
): Promise<ConnectionSessionData[]> {
|
||||||
|
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||||
|
const decompressed = await zlibInflate(compressedBuffer);
|
||||||
|
const jsonString = decompressed.toString("utf-8");
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("Decompressed connection log data is not an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ISO 8601 timestamp string to epoch seconds.
|
||||||
|
* Returns null if the input is falsy.
|
||||||
|
*/
|
||||||
|
function toEpochSeconds(isoString: string | undefined | null): number | null {
|
||||||
|
if (!isoString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ms = new Date(isoString).getTime();
|
||||||
|
if (isNaN(ms)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.floor(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all buffered connection log records to the database.
|
||||||
|
*
|
||||||
|
* Swaps out the buffer before writing so that any records added during the
|
||||||
|
* flush are captured in the new buffer rather than being lost. Entries that
|
||||||
|
* fail to write are re-queued back into the buffer so they will be retried
|
||||||
|
* on the next flush.
|
||||||
|
*
|
||||||
|
* This function is exported so that the application's graceful-shutdown
|
||||||
|
* cleanup handler can call it before the process exits.
|
||||||
|
*/
|
||||||
|
export async function flushConnectionLogToDb(): Promise<void> {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically swap out the buffer so new data keeps flowing in
|
||||||
|
const snapshot = buffer;
|
||||||
|
buffer = [];
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Flushing ${snapshot.length} connection log record(s) to the database`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert in batches to avoid overly large SQL statements
|
||||||
|
for (let i = 0; i < snapshot.length; i += INSERT_BATCH_SIZE) {
|
||||||
|
const batch = snapshot.slice(i, i + INSERT_BATCH_SIZE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDeadlockRetry(async () => {
|
||||||
|
await logsDb.insert(connectionAuditLog).values(batch);
|
||||||
|
}, `flush connection log batch (${batch.length} records)`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to flush connection log batch of ${batch.length} records:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-queue the failed batch so it is retried on the next flush
|
||||||
|
buffer = [...batch, ...buffer];
|
||||||
|
|
||||||
|
// Cap buffer to prevent unbounded growth if DB is unreachable
|
||||||
|
if (buffer.length > MAX_BUFFERED_RECORDS * 5) {
|
||||||
|
const dropped = buffer.length - MAX_BUFFERED_RECORDS * 5;
|
||||||
|
buffer = buffer.slice(0, MAX_BUFFERED_RECORDS * 5);
|
||||||
|
logger.warn(
|
||||||
|
`Connection log buffer overflow, dropped ${dropped} oldest records`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop trying further batches from this snapshot — they'll be
|
||||||
|
// picked up by the next flush via the re-queued records above
|
||||||
|
const remaining = snapshot.slice(i + INSERT_BATCH_SIZE);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
buffer = [...remaining, ...buffer];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await flushConnectionLogToDb();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Unexpected error during periodic connection log flush:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Calling unref() means this timer will not keep the Node.js event loop alive
|
||||||
|
// on its own — the process can still exit normally when there is no other work
|
||||||
|
// left. The graceful-shutdown path will call flushConnectionLogToDb() explicitly
|
||||||
|
// before process.exit(), so no data is lost.
|
||||||
|
flushTimer.unref();
|
||||||
|
|
||||||
|
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||||
|
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logsDb
|
||||||
|
.delete(connectionAuditLog)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
lt(connectionAuditLog.startedAt, cutoffTimestamp),
|
||||||
|
eq(connectionAuditLog.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// logger.debug(
|
||||||
|
// `Cleaned up connection audit logs older than ${retentionDays} days`
|
||||||
|
// );
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error cleaning up old connection audit logs:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||||
|
const { message, client } = context;
|
||||||
|
const newt = client as Newt;
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Connection log received but no newt client in context");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newt.siteId) {
|
||||||
|
logger.warn("Connection log received but newt has no siteId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.data?.compressed) {
|
||||||
|
logger.warn("Connection log message missing compressed data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the org for this site
|
||||||
|
const [site] = await db
|
||||||
|
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.warn(
|
||||||
|
`Connection log received but site ${newt.siteId} not found in database`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = site.orgId;
|
||||||
|
|
||||||
|
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
||||||
|
// reconstruct the exact subnet string stored on each client record.
|
||||||
|
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||||
|
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let sessions: ConnectionSessionData[];
|
||||||
|
try {
|
||||||
|
sessions = await decompressConnectionLog(message.data.compressed);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to decompress connection log data:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Sessions: ${JSON.stringify(sessions)}`)
|
||||||
|
|
||||||
|
// Build a map from sourceAddr → { clientId, userId } by querying clients
|
||||||
|
// whose subnet field matches exactly. Client subnets are stored with the
|
||||||
|
// org's CIDR suffix (e.g. "100.90.128.5/16"), so we reconstruct that from
|
||||||
|
// each unique sourceAddr + the org's CIDR suffix and do a targeted IN query.
|
||||||
|
const ipToClient = new Map<string, { clientId: number; userId: string | null }>();
|
||||||
|
|
||||||
|
if (cidrSuffix) {
|
||||||
|
// Collect unique source addresses so we only query for what we need
|
||||||
|
const uniqueSourceAddrs = new Set<string>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.sourceAddr) {
|
||||||
|
uniqueSourceAddrs.add(session.sourceAddr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueSourceAddrs.size > 0) {
|
||||||
|
// Construct the exact subnet strings as stored in the DB
|
||||||
|
const subnetQueries = Array.from(uniqueSourceAddrs).map(
|
||||||
|
(addr) => {
|
||||||
|
// Strip port if present (e.g. "100.90.128.1:38004" → "100.90.128.1")
|
||||||
|
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||||
|
return `${ip}${cidrSuffix}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(`Subnet queries: ${JSON.stringify(subnetQueries)}`);
|
||||||
|
|
||||||
|
const matchedClients = await db
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
userId: clients.userId,
|
||||||
|
subnet: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
inArray(clients.subnet, subnetQueries)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const c of matchedClients) {
|
||||||
|
const ip = c.subnet.split("/")[0];
|
||||||
|
logger.debug(`Client ${c.clientId} subnet ${c.subnet} matches ${ip}`);
|
||||||
|
ipToClient.set(ip, { clientId: c.clientId, userId: c.userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to DB records and add to the buffer
|
||||||
|
for (const session of sessions) {
|
||||||
|
// Validate required fields
|
||||||
|
if (
|
||||||
|
!session.sessionId ||
|
||||||
|
!session.resourceId ||
|
||||||
|
!session.sourceAddr ||
|
||||||
|
!session.destAddr ||
|
||||||
|
!session.protocol
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping connection log session with missing required fields: ${JSON.stringify(session)}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = toEpochSeconds(session.startedAt);
|
||||||
|
if (startedAt === null) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping connection log session with invalid startedAt: ${session.startedAt}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the source address to a client. The sourceAddr is the
|
||||||
|
// client's IP on the WireGuard network, which corresponds to the IP
|
||||||
|
// portion of the client's subnet CIDR (e.g. "100.90.128.5/24").
|
||||||
|
// Strip port if present (e.g. "100.90.128.1:38004" → "100.90.128.1")
|
||||||
|
const sourceIp = session.sourceAddr.includes(":") ? session.sourceAddr.split(":")[0] : session.sourceAddr;
|
||||||
|
const clientInfo = ipToClient.get(sourceIp) ?? null;
|
||||||
|
|
||||||
|
|
||||||
|
buffer.push({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
siteResourceId: session.resourceId,
|
||||||
|
orgId,
|
||||||
|
siteId: newt.siteId,
|
||||||
|
clientId: clientInfo?.clientId ?? null,
|
||||||
|
userId: clientInfo?.userId ?? null,
|
||||||
|
sourceAddr: session.sourceAddr,
|
||||||
|
destAddr: session.destAddr,
|
||||||
|
protocol: session.protocol,
|
||||||
|
startedAt,
|
||||||
|
endedAt: toEpochSeconds(session.endedAt),
|
||||||
|
bytesTx: session.bytesTx ?? null,
|
||||||
|
bytesRx: session.bytesRx ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Buffered ${sessions.length} connection log session(s) from newt ${newt.newtId} (site ${newt.siteId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the buffer has grown large enough, trigger an immediate flush
|
||||||
|
if (buffer.length >= MAX_BUFFERED_RECORDS) {
|
||||||
|
// Fire and forget — errors are handled inside flushConnectionLogToDb
|
||||||
|
flushConnectionLogToDb().catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
"Unexpected error during size-triggered connection log flush:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
1
server/private/routers/newt/index.ts
Normal file
1
server/private/routers/newt/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./handleConnectionLogMessage";
|
||||||
@@ -38,7 +38,7 @@ export const startRemoteExitNodeOfflineChecker = (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
||||||
const newlyOfflineNodes = await db
|
const offlineNodes = await db
|
||||||
.update(exitNodes)
|
.update(exitNodes)
|
||||||
.set({ online: false })
|
.set({ online: false })
|
||||||
.where(
|
.where(
|
||||||
@@ -53,32 +53,15 @@ export const startRemoteExitNodeOfflineChecker = (): void => {
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Update the sites to offline if they have not pinged either
|
if (offlineNodes.length > 0) {
|
||||||
const exitNodeIds = newlyOfflineNodes.map(
|
logger.info(
|
||||||
(node) => node.exitNodeId
|
`checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity`
|
||||||
);
|
);
|
||||||
|
|
||||||
const sitesOnNode = await db
|
for (const offlineClient of offlineNodes) {
|
||||||
.select()
|
logger.debug(
|
||||||
.from(sites)
|
`checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})`
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sites.online, true),
|
|
||||||
inArray(sites.exitNodeId, exitNodeIds)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// loop through the sites and process their lastBandwidthUpdate as an iso string and if its more than 1 minute old then mark the site offline
|
|
||||||
for (const site of sitesOnNode) {
|
|
||||||
if (!site.lastBandwidthUpdate) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate);
|
|
||||||
if (Date.now() - lastBandwidthUpdate.getTime() > 60 * 1000) {
|
|
||||||
await db
|
|
||||||
.update(sites)
|
|
||||||
.set({ online: false })
|
|
||||||
.where(eq(sites.siteId, site.siteId));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
actionAuditLog,
|
||||||
db,
|
db,
|
||||||
|
logsDb,
|
||||||
newts,
|
newts,
|
||||||
roles,
|
roles,
|
||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
@@ -29,12 +31,12 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { eq, or, and } from "drizzle-orm";
|
import { eq, or, and } from "drizzle-orm";
|
||||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||||
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { sendToClient } from "#private/routers/ws";
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -64,6 +66,7 @@ export type SignSshKeyResponse = {
|
|||||||
sshUsername: string;
|
sshUsername: string;
|
||||||
sshHost: string;
|
sshHost: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
keyId: string;
|
keyId: string;
|
||||||
validPrincipals: string[];
|
validPrincipals: string[];
|
||||||
validAfter: string;
|
validAfter: string;
|
||||||
@@ -446,6 +449,20 @@ export async function signSshKey(
|
|||||||
sshHost = resource.destination;
|
sshHost = resource.destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await logsDb.insert(actionAuditLog).values({
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
orgId: orgId,
|
||||||
|
actorType: "user",
|
||||||
|
actor: req.user?.username ?? "",
|
||||||
|
actorId: req.user?.userId ?? "",
|
||||||
|
action: ActionsEnum.signSshKey,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
resourceId: resource.siteResourceId,
|
||||||
|
resource: resource.name,
|
||||||
|
siteId: resource.siteId,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
return response<SignSshKeyResponse>(res, {
|
return response<SignSshKeyResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
certificate: cert.certificate,
|
certificate: cert.certificate,
|
||||||
@@ -453,6 +470,7 @@ export async function signSshKey(
|
|||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost,
|
sshHost: sshHost,
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
|
siteId: resource.siteId,
|
||||||
keyId: cert.keyId,
|
keyId: cert.keyId,
|
||||||
validPrincipals: cert.validPrincipals,
|
validPrincipals: cert.validPrincipals,
|
||||||
validAfter: cert.validAfter.toISOString(),
|
validAfter: cert.validAfter.toISOString(),
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import {
|
|||||||
startRemoteExitNodeOfflineChecker
|
startRemoteExitNodeOfflineChecker
|
||||||
} from "#private/routers/remoteExitNode";
|
} from "#private/routers/remoteExitNode";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { handleConnectionLogMessage } from "#dynamic/routers/newt";
|
||||||
|
|
||||||
export const messageHandlers: Record<string, MessageHandler> = {
|
export const messageHandlers: Record<string, MessageHandler> = {
|
||||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage
|
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
|
||||||
|
"newt/access-log": handleConnectionLogMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (build != "saas") {
|
||||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
|
import zlib from "zlib";
|
||||||
import { Server as HttpServer } from "http";
|
import { Server as HttpServer } from "http";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
@@ -24,7 +25,8 @@ import {
|
|||||||
OlmSession,
|
OlmSession,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
RemoteExitNodeSession,
|
RemoteExitNodeSession,
|
||||||
remoteExitNodes
|
remoteExitNodes,
|
||||||
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
@@ -57,11 +59,13 @@ const MAX_PENDING_MESSAGES = 50; // Maximum messages to queue during connection
|
|||||||
const processMessage = async (
|
const processMessage = async (
|
||||||
ws: AuthenticatedWebSocket,
|
ws: AuthenticatedWebSocket,
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
|
isBinary: boolean,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientType: ClientType
|
clientType: ClientType
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const message: WSMessage = JSON.parse(data.toString());
|
const messageBuffer = isBinary ? zlib.gunzipSync(data) : data;
|
||||||
|
const message: WSMessage = JSON.parse(messageBuffer.toString());
|
||||||
|
|
||||||
// logger.debug(
|
// logger.debug(
|
||||||
// `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}`
|
// `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}`
|
||||||
@@ -76,7 +80,7 @@ const processMessage = async (
|
|||||||
clientId,
|
clientId,
|
||||||
message.type, // Pass message type for granular limiting
|
message.type, // Pass message type for granular limiting
|
||||||
100, // max requests per window
|
100, // max requests per window
|
||||||
20, // max requests per message type per window
|
100, // max requests per message type per window
|
||||||
60 * 1000 // window in milliseconds
|
60 * 1000 // window in milliseconds
|
||||||
);
|
);
|
||||||
if (rateLimitResult.isLimited) {
|
if (rateLimitResult.isLimited) {
|
||||||
@@ -163,8 +167,16 @@ const processPendingMessages = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const jobs = [];
|
const jobs = [];
|
||||||
for (const messageData of ws.pendingMessages) {
|
for (const pending of ws.pendingMessages) {
|
||||||
jobs.push(processMessage(ws, messageData, clientId, clientType));
|
jobs.push(
|
||||||
|
processMessage(
|
||||||
|
ws,
|
||||||
|
pending.data,
|
||||||
|
pending.isBinary,
|
||||||
|
clientId,
|
||||||
|
clientType
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(jobs);
|
await Promise.all(jobs);
|
||||||
@@ -185,6 +197,12 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
|||||||
// Config version tracking map (local to this node, resets on server restart)
|
// Config version tracking map (local to this node, resets on server restart)
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
const clientConfigVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
// Tracks the last Unix timestamp (seconds) at which a ping was flushed to the
|
||||||
|
// DB for a given siteId. Resets on server restart which is fine – the first
|
||||||
|
// ping after startup will always write, re-establishing the online state.
|
||||||
|
const lastPingDbWrite: Map<number, number> = new Map();
|
||||||
|
const PING_DB_WRITE_INTERVAL = 45; // seconds
|
||||||
|
|
||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
@@ -325,7 +343,9 @@ const addClient = async (
|
|||||||
// Check Redis first if enabled
|
// Check Redis first if enabled
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
const redisVersion = await redisManager.get(getConfigVersionKey(clientId));
|
const redisVersion = await redisManager.get(
|
||||||
|
getConfigVersionKey(clientId)
|
||||||
|
);
|
||||||
if (redisVersion !== null) {
|
if (redisVersion !== null) {
|
||||||
configVersion = parseInt(redisVersion, 10);
|
configVersion = parseInt(redisVersion, 10);
|
||||||
// Sync to local cache
|
// Sync to local cache
|
||||||
@@ -337,7 +357,10 @@ const addClient = async (
|
|||||||
} else {
|
} else {
|
||||||
// Use local cache version and sync to Redis
|
// Use local cache version and sync to Redis
|
||||||
configVersion = clientConfigVersions.get(clientId) || 0;
|
configVersion = clientConfigVersions.get(clientId) || 0;
|
||||||
await redisManager.set(getConfigVersionKey(clientId), configVersion.toString());
|
await redisManager.set(
|
||||||
|
getConfigVersionKey(clientId),
|
||||||
|
configVersion.toString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to get/set config version in Redis:", error);
|
logger.error("Failed to get/set config version in Redis:", error);
|
||||||
@@ -432,7 +455,9 @@ const removeClient = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper to get the current config version for a client
|
// Helper to get the current config version for a client
|
||||||
const getClientConfigVersion = async (clientId: string): Promise<number | undefined> => {
|
const getClientConfigVersion = async (
|
||||||
|
clientId: string
|
||||||
|
): Promise<number | undefined> => {
|
||||||
// Try Redis first if available
|
// Try Redis first if available
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
@@ -502,11 +527,26 @@ const sendToClientLocal = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const messageString = JSON.stringify(messageWithVersion);
|
const messageString = JSON.stringify(messageWithVersion);
|
||||||
|
if (options.compress) {
|
||||||
|
logger.debug(
|
||||||
|
`Message size before compression: ${messageString.length} bytes`
|
||||||
|
);
|
||||||
|
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
|
||||||
|
logger.debug(
|
||||||
|
`Message size after compression: ${compressed.length} bytes`
|
||||||
|
);
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(compressed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(messageString);
|
client.send(messageString);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -532,6 +572,16 @@ const broadcastToAllExceptLocal = async (
|
|||||||
configVersion
|
configVersion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.compress) {
|
||||||
|
const compressed = zlib.gzipSync(
|
||||||
|
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
|
||||||
|
);
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(compressed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(JSON.stringify(messageWithVersion));
|
client.send(JSON.stringify(messageWithVersion));
|
||||||
@@ -539,6 +589,7 @@ const broadcastToAllExceptLocal = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cross-node message sending (via Redis)
|
// Cross-node message sending (via Redis)
|
||||||
@@ -762,7 +813,7 @@ const setupConnection = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up message handler FIRST to prevent race condition
|
// Set up message handler FIRST to prevent race condition
|
||||||
ws.on("message", async (data) => {
|
ws.on("message", async (data, isBinary) => {
|
||||||
if (!ws.isFullyConnected) {
|
if (!ws.isFullyConnected) {
|
||||||
// Queue message for later processing with limits
|
// Queue message for later processing with limits
|
||||||
ws.pendingMessages = ws.pendingMessages || [];
|
ws.pendingMessages = ws.pendingMessages || [];
|
||||||
@@ -777,11 +828,17 @@ const setupConnection = async (
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)`
|
`Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)`
|
||||||
);
|
);
|
||||||
ws.pendingMessages.push(data as Buffer);
|
ws.pendingMessages.push({ data: data as Buffer, isBinary });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await processMessage(ws, data as Buffer, clientId, clientType);
|
await processMessage(
|
||||||
|
ws,
|
||||||
|
data as Buffer,
|
||||||
|
isBinary,
|
||||||
|
clientId,
|
||||||
|
clientType
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up other event handlers before async operations
|
// Set up other event handlers before async operations
|
||||||
@@ -796,6 +853,35 @@ const setupConnection = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket protocol-level pings from older newt clients that do
|
||||||
|
// not send application-level "newt/ping" messages. Update the site's
|
||||||
|
// online state and lastPing timestamp so the offline checker treats them
|
||||||
|
// the same as modern newt clients.
|
||||||
|
if (clientType === "newt") {
|
||||||
|
const newtClient = client as Newt;
|
||||||
|
ws.on("ping", async () => {
|
||||||
|
if (!newtClient.siteId) return;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const lastWrite = lastPingDbWrite.get(newtClient.siteId) ?? 0;
|
||||||
|
if (now - lastWrite < PING_DB_WRITE_INTERVAL) return;
|
||||||
|
lastPingDbWrite.set(newtClient.siteId, now);
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: now
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newtClient.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Error updating newt site online state on WS ping",
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ws.on("error", (error: Error) => {
|
ws.on("error", (error: Error) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,
|
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,
|
||||||
|
|||||||
@@ -91,3 +91,50 @@ export type QueryAccessAuditLogResponse = {
|
|||||||
locations: string[];
|
locations: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueryConnectionAuditLogResponse = {
|
||||||
|
log: {
|
||||||
|
sessionId: string;
|
||||||
|
siteResourceId: number | null;
|
||||||
|
orgId: string | null;
|
||||||
|
siteId: number | null;
|
||||||
|
clientId: number | null;
|
||||||
|
userId: string | null;
|
||||||
|
sourceAddr: string;
|
||||||
|
destAddr: string;
|
||||||
|
protocol: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number | null;
|
||||||
|
bytesTx: number | null;
|
||||||
|
bytesRx: number | null;
|
||||||
|
resourceName: string | null;
|
||||||
|
resourceNiceId: string | null;
|
||||||
|
siteName: string | null;
|
||||||
|
siteNiceId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
|
clientNiceId: string | null;
|
||||||
|
clientType: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
}[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
filterAttributes: {
|
||||||
|
protocols: string[];
|
||||||
|
destAddrs: string[];
|
||||||
|
clients: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
users: {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db, users } from "@server/db";
|
import { bannedEmails, bannedIps, db, users } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { email, z } from "zod";
|
import { email, z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
@@ -22,7 +22,6 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
|||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.email().toLowerCase(),
|
email: z.email().toLowerCase(),
|
||||||
@@ -66,6 +65,30 @@ export async function signup(
|
|||||||
skipVerificationEmail
|
skipVerificationEmail
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
const [bannedEmail] = await db
|
||||||
|
.select()
|
||||||
|
.from(bannedEmails)
|
||||||
|
.where(eq(bannedEmails.email, email))
|
||||||
|
.limit(1);
|
||||||
|
if (bannedEmail) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.ip) {
|
||||||
|
const [bannedIp] = await db
|
||||||
|
.select()
|
||||||
|
.from(bannedIps)
|
||||||
|
.where(eq(bannedIps.ip, req.ip))
|
||||||
|
.limit(1);
|
||||||
|
if (bannedIp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
@@ -189,6 +212,7 @@ export async function signup(
|
|||||||
dateCreated: moment().toISOString(),
|
dateCreated: moment().toISOString(),
|
||||||
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
||||||
termsVersion: "1",
|
termsVersion: "1",
|
||||||
|
marketingEmailConsent: marketingEmailConsent ?? false,
|
||||||
lastPasswordChange: new Date().getTime()
|
lastPasswordChange: new Date().getTime()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +236,7 @@ export async function signup(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`User ${email} opted in to marketing emails during signup.`
|
`User ${email} opted in to marketing emails during signup.`
|
||||||
);
|
);
|
||||||
moveEmailToAudience(email, AudienceIds.SignUps);
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import cache from "#dynamic/lib/cache";
|
|||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
import { stripPortFromHost } from "@server/lib/ip";
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
|
import { sanitizeString } from "@server/lib/sanitize";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
||||||
Reasons:
|
Reasons:
|
||||||
@@ -253,24 +255,23 @@ export async function logRequestAudit(
|
|||||||
// Add to buffer instead of writing directly to DB
|
// Add to buffer instead of writing directly to DB
|
||||||
auditLogBuffer.push({
|
auditLogBuffer.push({
|
||||||
timestamp,
|
timestamp,
|
||||||
orgId: data.orgId,
|
orgId: sanitizeString(data.orgId),
|
||||||
actorType,
|
actorType: sanitizeString(actorType),
|
||||||
actor,
|
actor: sanitizeString(actor),
|
||||||
actorId,
|
actorId: sanitizeString(actorId),
|
||||||
metadata,
|
metadata: sanitizeString(metadata),
|
||||||
action: data.action,
|
action: data.action,
|
||||||
resourceId: data.resourceId,
|
resourceId: data.resourceId,
|
||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
location: data.location,
|
location: sanitizeString(data.location),
|
||||||
originalRequestURL: body.originalRequestURL,
|
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
||||||
scheme: body.scheme,
|
scheme: sanitizeString(body.scheme) ?? "",
|
||||||
host: body.host,
|
host: sanitizeString(body.host) ?? "",
|
||||||
path: body.path,
|
path: sanitizeString(body.path) ?? "",
|
||||||
method: body.method,
|
method: sanitizeString(body.method) ?? "",
|
||||||
ip: clientIp,
|
ip: sanitizeString(clientIp),
|
||||||
tls: body.tls
|
tls: body.tls
|
||||||
});
|
});
|
||||||
|
|
||||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||||
if (auditLogBuffer.length >= BATCH_SIZE) {
|
if (auditLogBuffer.length >= BATCH_SIZE) {
|
||||||
// Fire and forget - don't block the caller
|
// Fire and forget - don't block the caller
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function getLatestOlmVersion(): Promise<string | null> {
|
|||||||
tags = tags.filter((version) => !version.name.includes("rc"));
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
const latestVersion = tags[0].name;
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
olmVersionCache.set("latestOlmVersion", latestVersion);
|
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
|
||||||
|
|
||||||
return latestVersion;
|
return latestVersion;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async function getLatestOlmVersion(): Promise<string | null> {
|
|||||||
tags = tags.filter((version) => !version.name.includes("rc"));
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
const latestVersion = tags[0].name;
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
olmVersionCache.set("latestOlmVersion", latestVersion);
|
olmVersionCache.set("latestOlmVersion", latestVersion, 3600);
|
||||||
|
|
||||||
return latestVersion;
|
return latestVersion;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1,86 +1,139 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, olms, Transaction } from "@server/db";
|
import { db, newts, olms } from "@server/db";
|
||||||
import { Alias, SubnetProxyTarget } from "@server/lib/ip";
|
import {
|
||||||
|
Alias,
|
||||||
|
convertSubnetProxyTargetsV2ToV1,
|
||||||
|
SubnetProxyTarget,
|
||||||
|
SubnetProxyTargetV2
|
||||||
|
} from "@server/lib/ip";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import semver from "semver";
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
|
||||||
const BATCH_DELAY_MS = 50;
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
export async function convertTargetsIfNessicary(
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
newtId: string,
|
||||||
|
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
|
||||||
|
) {
|
||||||
|
// get the newt
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.newtId, newtId));
|
||||||
|
if (!newt) {
|
||||||
|
throw new Error(`No newt found for id: ${newtId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
// check the semver
|
||||||
const chunks: T[][] = [];
|
if (
|
||||||
for (let i = 0; i < array.length; i += size) {
|
newt.version &&
|
||||||
chunks.push(array.slice(i, i + size));
|
!semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION)
|
||||||
}
|
) {
|
||||||
return chunks;
|
logger.debug(
|
||||||
|
`addTargets Newt version ${newt.version} does not support targets v2 falling back`
|
||||||
|
);
|
||||||
|
targets = convertSubnetProxyTargetsV2ToV1(
|
||||||
|
targets as SubnetProxyTargetV2[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
|
return targets;
|
||||||
const batches = chunkArray(targets, BATCH_SIZE);
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
|
||||||
if (i > 0) {
|
|
||||||
await sleep(BATCH_DELAY_MS);
|
|
||||||
}
|
}
|
||||||
await sendToClient(newtId, {
|
|
||||||
|
export async function addTargets(
|
||||||
|
newtId: string,
|
||||||
|
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
||||||
|
version?: string | null
|
||||||
|
) {
|
||||||
|
targets = await convertTargetsIfNessicary(newtId, targets);
|
||||||
|
|
||||||
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
|
{
|
||||||
type: `newt/wg/targets/add`,
|
type: `newt/wg/targets/add`,
|
||||||
data: batches[i]
|
data: targets
|
||||||
}, { incrementConfigVersion: true });
|
},
|
||||||
}
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: SubnetProxyTarget[]
|
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
const batches = chunkArray(targets, BATCH_SIZE);
|
targets = await convertTargetsIfNessicary(newtId, targets);
|
||||||
for (let i = 0; i < batches.length; i++) {
|
|
||||||
if (i > 0) {
|
await sendToClient(
|
||||||
await sleep(BATCH_DELAY_MS);
|
newtId,
|
||||||
}
|
{
|
||||||
await sendToClient(newtId, {
|
|
||||||
type: `newt/wg/targets/remove`,
|
type: `newt/wg/targets/remove`,
|
||||||
data: batches[i]
|
data: targets
|
||||||
},{ incrementConfigVersion: true });
|
},
|
||||||
}
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTargets(
|
export async function updateTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: {
|
targets: {
|
||||||
oldTargets: SubnetProxyTarget[];
|
oldTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
|
||||||
newTargets: SubnetProxyTarget[];
|
newTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
|
||||||
}
|
},
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
|
// get the newt
|
||||||
const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
|
const [newt] = await db
|
||||||
const maxBatches = Math.max(oldBatches.length, newBatches.length);
|
.select()
|
||||||
|
.from(newts)
|
||||||
for (let i = 0; i < maxBatches; i++) {
|
.where(eq(newts.newtId, newtId));
|
||||||
if (i > 0) {
|
if (!newt) {
|
||||||
await sleep(BATCH_DELAY_MS);
|
logger.error(`addTargetsL No newt found for id: ${newtId}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await sendToClient(newtId, {
|
|
||||||
|
// check the semver
|
||||||
|
if (
|
||||||
|
newt.version &&
|
||||||
|
!semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION)
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`addTargets Newt version ${newt.version} does not support targets v2 falling back`
|
||||||
|
);
|
||||||
|
targets = {
|
||||||
|
oldTargets: convertSubnetProxyTargetsV2ToV1(
|
||||||
|
targets.oldTargets as SubnetProxyTargetV2[]
|
||||||
|
),
|
||||||
|
newTargets: convertSubnetProxyTargetsV2ToV1(
|
||||||
|
targets.newTargets as SubnetProxyTargetV2[]
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
|
{
|
||||||
type: `newt/wg/targets/update`,
|
type: `newt/wg/targets/update`,
|
||||||
data: {
|
data: {
|
||||||
oldTargets: oldBatches[i] || [],
|
oldTargets: targets.oldTargets,
|
||||||
newTargets: newBatches[i] || []
|
newTargets: targets.newTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function addPeerData(
|
export async function addPeerData(
|
||||||
clientId: number,
|
clientId: number,
|
||||||
siteId: number,
|
siteId: number,
|
||||||
remoteSubnets: string[],
|
remoteSubnets: string[],
|
||||||
aliases: Alias[],
|
aliases: Alias[],
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -92,16 +145,21 @@ export async function addPeerData(
|
|||||||
return; // ignore this because an olm might not be associated with the client anymore
|
return; // ignore this because an olm might not be associated with the client anymore
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(olmId, {
|
await sendToClient(
|
||||||
|
olmId,
|
||||||
|
{
|
||||||
type: `olm/wg/peer/data/add`,
|
type: `olm/wg/peer/data/add`,
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
remoteSubnets: remoteSubnets,
|
remoteSubnets: remoteSubnets,
|
||||||
aliases: aliases
|
aliases: aliases
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,7 +169,8 @@ export async function removePeerData(
|
|||||||
siteId: number,
|
siteId: number,
|
||||||
remoteSubnets: string[],
|
remoteSubnets: string[],
|
||||||
aliases: Alias[],
|
aliases: Alias[],
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -123,16 +182,21 @@ export async function removePeerData(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(olmId, {
|
await sendToClient(
|
||||||
|
olmId,
|
||||||
|
{
|
||||||
type: `olm/wg/peer/data/remove`,
|
type: `olm/wg/peer/data/remove`,
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
remoteSubnets: remoteSubnets,
|
remoteSubnets: remoteSubnets,
|
||||||
aliases: aliases
|
aliases: aliases
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,7 +216,8 @@ export async function updatePeerData(
|
|||||||
newAliases: Alias[];
|
newAliases: Alias[];
|
||||||
}
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -164,16 +229,21 @@ export async function updatePeerData(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(olmId, {
|
await sendToClient(
|
||||||
|
olmId,
|
||||||
|
{
|
||||||
type: `olm/wg/peer/data/update`,
|
type: `olm/wg/peer/data/update`,
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
...remoteSubnets,
|
...remoteSubnets,
|
||||||
...aliases
|
...aliases
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
|||||||
tries: domains.tries,
|
tries: domains.tries,
|
||||||
configManaged: domains.configManaged,
|
configManaged: domains.configManaged,
|
||||||
certResolver: domains.certResolver,
|
certResolver: domains.certResolver,
|
||||||
preferWildcardCert: domains.preferWildcardCert
|
preferWildcardCert: domains.preferWildcardCert,
|
||||||
|
errorMessage: domains.errorMessage
|
||||||
})
|
})
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.orgId, orgId))
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
// Add site as a destination for this client
|
// Add site as a destination for this client
|
||||||
const destination: PeerDestination = {
|
const destination: PeerDestination = {
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinationIP: site.subnet.split("/")[0],
|
||||||
destinationPort: site.listenPort
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this destination is already in the array to avoid duplicates
|
// Check if this destination is already in the array to avoid duplicates
|
||||||
@@ -165,7 +165,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
|
|
||||||
const destination: PeerDestination = {
|
const destination: PeerDestination = {
|
||||||
destinationIP: peer.subnet.split("/")[0],
|
destinationIP: peer.subnet.split("/")[0],
|
||||||
destinationPort: peer.listenPort
|
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { eq, and, lt, inArray, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -11,19 +11,31 @@ import { FeatureId } from "@server/lib/billing/features";
|
|||||||
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
|
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// Track sites that are already offline to avoid unnecessary queries
|
|
||||||
const offlineSites = new Set<string>();
|
|
||||||
|
|
||||||
// Retry configuration for deadlock handling
|
|
||||||
const MAX_RETRIES = 3;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
interface PeerBandwidth {
|
interface PeerBandwidth {
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
bytesIn: number;
|
bytesIn: number;
|
||||||
bytesOut: number;
|
bytesOut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccumulatorEntry {
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
/** Present when the update came through a remote exit node. */
|
||||||
|
exitNodeId?: number;
|
||||||
|
/** Whether to record egress usage for billing purposes. */
|
||||||
|
calcUsage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry configuration for deadlock handling
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
// How often to flush accumulated bandwidth data to the database
|
||||||
|
const FLUSH_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
// In-memory accumulator: publicKey -> AccumulatorEntry
|
||||||
|
let accumulator = new Map<string, AccumulatorEntry>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is a deadlock error
|
* Check if an error is a deadlock error
|
||||||
*/
|
*/
|
||||||
@@ -63,6 +75,220 @@ async function withDeadlockRetry<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all accumulated site bandwidth data to the database.
|
||||||
|
*
|
||||||
|
* Swaps out the accumulator before writing so that any bandwidth messages
|
||||||
|
* received during the flush are captured in the new accumulator rather than
|
||||||
|
* being lost or causing contention. Entries that fail to write are re-queued
|
||||||
|
* back into the accumulator so they will be retried on the next flush.
|
||||||
|
*
|
||||||
|
* This function is exported so that the application's graceful-shutdown
|
||||||
|
* cleanup handler can call it before the process exits.
|
||||||
|
*/
|
||||||
|
export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||||
|
if (accumulator.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically swap out the accumulator so new data keeps flowing in
|
||||||
|
// while we write the snapshot to the database.
|
||||||
|
const snapshot = accumulator;
|
||||||
|
accumulator = new Map<string, AccumulatorEntry>();
|
||||||
|
|
||||||
|
const currentTime = new Date().toISOString();
|
||||||
|
|
||||||
|
// Sort by publicKey for consistent lock ordering across concurrent
|
||||||
|
// writers — deadlock-prevention strategy.
|
||||||
|
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregate billing usage by org, collected during the DB update loop.
|
||||||
|
const orgUsageMap = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) {
|
||||||
|
try {
|
||||||
|
const updatedSite = await withDeadlockRetry(async () => {
|
||||||
|
const [result] = await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`,
|
||||||
|
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`,
|
||||||
|
lastBandwidthUpdate: currentTime,
|
||||||
|
})
|
||||||
|
.where(eq(sites.pubKey, publicKey))
|
||||||
|
.returning({
|
||||||
|
orgId: sites.orgId,
|
||||||
|
siteId: sites.siteId
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, `flush bandwidth for site ${publicKey}`);
|
||||||
|
|
||||||
|
if (updatedSite) {
|
||||||
|
if (exitNodeId) {
|
||||||
|
const notAllowed = await checkExitNodeOrg(
|
||||||
|
exitNodeId,
|
||||||
|
updatedSite.orgId
|
||||||
|
);
|
||||||
|
if (notAllowed) {
|
||||||
|
logger.warn(
|
||||||
|
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
||||||
|
);
|
||||||
|
// Skip usage tracking for this site but continue
|
||||||
|
// processing the rest.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calcUsage) {
|
||||||
|
const totalBandwidth = bytesIn + bytesOut;
|
||||||
|
const current = orgUsageMap.get(updatedSite.orgId) ?? 0;
|
||||||
|
orgUsageMap.set(updatedSite.orgId, current + totalBandwidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to flush bandwidth for site ${publicKey}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-queue the failed entry so it is retried on the next flush
|
||||||
|
// rather than silently dropped.
|
||||||
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.bytesIn += bytesIn;
|
||||||
|
existing.bytesOut += bytesOut;
|
||||||
|
} else {
|
||||||
|
accumulator.set(publicKey, {
|
||||||
|
bytesIn,
|
||||||
|
bytesOut,
|
||||||
|
exitNodeId,
|
||||||
|
calcUsage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process billing usage updates outside the site-update loop to keep
|
||||||
|
// lock scope small and concerns separated.
|
||||||
|
if (orgUsageMap.size > 0) {
|
||||||
|
// Sort org IDs for consistent lock ordering.
|
||||||
|
const sortedOrgIds = [...orgUsageMap.keys()].sort();
|
||||||
|
|
||||||
|
for (const orgId of sortedOrgIds) {
|
||||||
|
try {
|
||||||
|
const totalBandwidth = orgUsageMap.get(orgId)!;
|
||||||
|
const bandwidthUsage = await usageService.add(
|
||||||
|
orgId,
|
||||||
|
FeatureId.EGRESS_DATA_MB,
|
||||||
|
totalBandwidth
|
||||||
|
);
|
||||||
|
if (bandwidthUsage) {
|
||||||
|
// Fire-and-forget — don't block the flush on limit checking.
|
||||||
|
usageService
|
||||||
|
.checkLimitSet(
|
||||||
|
orgId,
|
||||||
|
FeatureId.EGRESS_DATA_MB,
|
||||||
|
bandwidthUsage
|
||||||
|
)
|
||||||
|
.catch((error: any) => {
|
||||||
|
logger.error(
|
||||||
|
`Error checking bandwidth limits for org ${orgId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error processing usage for org ${orgId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Continue with other orgs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Periodic flush timer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const flushTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await flushSiteBandwidthToDb();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Unexpected error during periodic site bandwidth flush:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Allow the process to exit normally even while the timer is pending.
|
||||||
|
// The graceful-shutdown path (see server/cleanup.ts) will call
|
||||||
|
// flushSiteBandwidthToDb() explicitly before process.exit(), so no data
|
||||||
|
// is lost.
|
||||||
|
flushTimer.unref();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accumulate bandwidth data reported by a gerbil or remote exit node.
|
||||||
|
*
|
||||||
|
* Only peers that actually transferred data (bytesIn > 0) are added to the
|
||||||
|
* accumulator; peers with no activity are silently ignored, which means the
|
||||||
|
* flush will only write rows that have genuinely changed.
|
||||||
|
*
|
||||||
|
* The function is intentionally synchronous in its fast path so that the
|
||||||
|
* HTTP handler can respond immediately without waiting for any I/O.
|
||||||
|
*/
|
||||||
|
export async function updateSiteBandwidth(
|
||||||
|
bandwidthData: PeerBandwidth[],
|
||||||
|
calcUsageAndLimits: boolean,
|
||||||
|
exitNodeId?: number
|
||||||
|
): Promise<void> {
|
||||||
|
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
|
||||||
|
// Skip peers that haven't transferred any data — writing zeros to the
|
||||||
|
// database would be a no-op anyway.
|
||||||
|
if (bytesIn <= 0 && bytesOut <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.bytesIn += bytesIn;
|
||||||
|
existing.bytesOut += bytesOut;
|
||||||
|
// Retain the most-recent exitNodeId for this peer.
|
||||||
|
if (exitNodeId !== undefined) {
|
||||||
|
existing.exitNodeId = exitNodeId;
|
||||||
|
}
|
||||||
|
// Once calcUsage has been requested for a peer, keep it set for
|
||||||
|
// the lifetime of this flush window.
|
||||||
|
if (calcUsageAndLimits) {
|
||||||
|
existing.calcUsage = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accumulator.set(publicKey, {
|
||||||
|
bytesIn,
|
||||||
|
bytesOut,
|
||||||
|
exitNodeId,
|
||||||
|
calcUsage: calcUsageAndLimits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const receiveBandwidth = async (
|
export const receiveBandwidth = async (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -75,7 +301,9 @@ export const receiveBandwidth = async (
|
|||||||
throw new Error("Invalid bandwidth data");
|
throw new Error("Invalid bandwidth data");
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSiteBandwidth(bandwidthData, build == "saas"); // we are checking the usage on saas only
|
// Accumulate in memory; the periodic timer (and the shutdown hook)
|
||||||
|
// will write to the database.
|
||||||
|
await updateSiteBandwidth(bandwidthData, build == "saas");
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
@@ -94,201 +322,3 @@ export const receiveBandwidth = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function updateSiteBandwidth(
|
|
||||||
bandwidthData: PeerBandwidth[],
|
|
||||||
calcUsageAndLimits: boolean,
|
|
||||||
exitNodeId?: number
|
|
||||||
) {
|
|
||||||
const currentTime = new Date();
|
|
||||||
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
|
|
||||||
|
|
||||||
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
|
|
||||||
// This is critical for preventing deadlocks when multiple instances update the same sites
|
|
||||||
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
|
|
||||||
a.publicKey.localeCompare(b.publicKey)
|
|
||||||
);
|
|
||||||
|
|
||||||
// First, handle sites that are actively reporting bandwidth
|
|
||||||
const activePeers = sortedBandwidthData.filter((peer) => peer.bytesIn > 0);
|
|
||||||
|
|
||||||
// Aggregate usage data by organization (collected outside transaction)
|
|
||||||
const orgUsageMap = new Map<string, number>();
|
|
||||||
|
|
||||||
if (activePeers.length > 0) {
|
|
||||||
// Remove any active peers from offline tracking since they're sending data
|
|
||||||
activePeers.forEach((peer) => offlineSites.delete(peer.publicKey));
|
|
||||||
|
|
||||||
// Update each active site individually with retry logic
|
|
||||||
// This reduces transaction scope and allows retries per-site
|
|
||||||
for (const peer of activePeers) {
|
|
||||||
try {
|
|
||||||
const updatedSite = await withDeadlockRetry(async () => {
|
|
||||||
const [result] = await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
|
|
||||||
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
|
|
||||||
lastBandwidthUpdate: currentTime.toISOString(),
|
|
||||||
online: true
|
|
||||||
})
|
|
||||||
.where(eq(sites.pubKey, peer.publicKey))
|
|
||||||
.returning({
|
|
||||||
online: sites.online,
|
|
||||||
orgId: sites.orgId,
|
|
||||||
siteId: sites.siteId,
|
|
||||||
lastBandwidthUpdate: sites.lastBandwidthUpdate
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, `update active site ${peer.publicKey}`);
|
|
||||||
|
|
||||||
if (updatedSite) {
|
|
||||||
if (exitNodeId) {
|
|
||||||
const notAllowed = await checkExitNodeOrg(
|
|
||||||
exitNodeId,
|
|
||||||
updatedSite.orgId
|
|
||||||
);
|
|
||||||
if (notAllowed) {
|
|
||||||
logger.warn(
|
|
||||||
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
|
||||||
);
|
|
||||||
// Skip this site but continue processing others
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate bandwidth usage for the org
|
|
||||||
const totalBandwidth = peer.bytesIn + peer.bytesOut;
|
|
||||||
const currentOrgUsage =
|
|
||||||
orgUsageMap.get(updatedSite.orgId) || 0;
|
|
||||||
orgUsageMap.set(
|
|
||||||
updatedSite.orgId,
|
|
||||||
currentOrgUsage + totalBandwidth
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to update bandwidth for site ${peer.publicKey}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Continue with other sites
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process usage updates outside of site update transactions
|
|
||||||
// This separates the concerns and reduces lock contention
|
|
||||||
if (calcUsageAndLimits && orgUsageMap.size > 0) {
|
|
||||||
// Sort org IDs to ensure consistent lock ordering
|
|
||||||
const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort();
|
|
||||||
|
|
||||||
for (const orgId of allOrgIds) {
|
|
||||||
try {
|
|
||||||
// Process bandwidth usage for this org
|
|
||||||
const totalBandwidth = orgUsageMap.get(orgId);
|
|
||||||
if (totalBandwidth) {
|
|
||||||
const bandwidthUsage = await usageService.add(
|
|
||||||
orgId,
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
|
||||||
totalBandwidth
|
|
||||||
);
|
|
||||||
if (bandwidthUsage) {
|
|
||||||
// Fire and forget - don't block on limit checking
|
|
||||||
usageService
|
|
||||||
.checkLimitSet(
|
|
||||||
orgId,
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
|
||||||
bandwidthUsage
|
|
||||||
)
|
|
||||||
.catch((error: any) => {
|
|
||||||
logger.error(
|
|
||||||
`Error checking bandwidth limits for org ${orgId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error processing usage for org ${orgId}:`, error);
|
|
||||||
// Continue with other orgs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle sites that reported zero bandwidth but need online status updated
|
|
||||||
const zeroBandwidthPeers = sortedBandwidthData.filter(
|
|
||||||
(peer) => peer.bytesIn === 0 && !offlineSites.has(peer.publicKey)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (zeroBandwidthPeers.length > 0) {
|
|
||||||
// Fetch all zero bandwidth sites in one query
|
|
||||||
const zeroBandwidthSites = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
sites.pubKey,
|
|
||||||
zeroBandwidthPeers.map((p) => p.publicKey)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by siteId to ensure consistent lock ordering
|
|
||||||
const sortedZeroBandwidthSites = zeroBandwidthSites.sort(
|
|
||||||
(a, b) => a.siteId - b.siteId
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const site of sortedZeroBandwidthSites) {
|
|
||||||
let newOnlineStatus = site.online;
|
|
||||||
|
|
||||||
// Check if site should go offline based on last bandwidth update WITH DATA
|
|
||||||
if (site.lastBandwidthUpdate) {
|
|
||||||
const lastUpdateWithData = new Date(site.lastBandwidthUpdate);
|
|
||||||
if (lastUpdateWithData < oneMinuteAgo) {
|
|
||||||
newOnlineStatus = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No previous data update recorded, set to offline
|
|
||||||
newOnlineStatus = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update online status if it changed
|
|
||||||
if (site.online !== newOnlineStatus) {
|
|
||||||
try {
|
|
||||||
const updatedSite = await withDeadlockRetry(async () => {
|
|
||||||
const [result] = await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: newOnlineStatus
|
|
||||||
})
|
|
||||||
.where(eq(sites.siteId, site.siteId))
|
|
||||||
.returning();
|
|
||||||
return result;
|
|
||||||
}, `update offline status for site ${site.siteId}`);
|
|
||||||
|
|
||||||
if (updatedSite && exitNodeId) {
|
|
||||||
const notAllowed = await checkExitNodeOrg(
|
|
||||||
exitNodeId,
|
|
||||||
updatedSite.orgId
|
|
||||||
);
|
|
||||||
if (notAllowed) {
|
|
||||||
logger.warn(
|
|
||||||
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If site went offline, add it to our tracking set
|
|
||||||
if (!newOnlineStatus && site.pubKey) {
|
|
||||||
offlineSites.add(site.pubKey);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to update offline status for site ${site.siteId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Continue with other sites
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function updateHolePunch(
|
|||||||
destinations: destinations
|
destinations: destinations
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// logger.error(error); // FIX THIS
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
@@ -262,7 +262,7 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
if (site.subnet && site.listenPort) {
|
if (site.subnet && site.listenPort) {
|
||||||
destinations.push({
|
destinations.push({
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinationIP: site.subnet.split("/")[0],
|
||||||
destinationPort: site.listenPort
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,10 +339,10 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedSite || !updatedSite.subnet) {
|
// if (!updatedSite || !updatedSite.subnet) {
|
||||||
logger.warn(`Site not found: ${newt.siteId}`);
|
// logger.warn(`Site not found: ${newt.siteId}`);
|
||||||
throw new Error("Site not found");
|
// throw new Error("Site not found");
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Find all clients that connect to this site
|
// Find all clients that connect to this site
|
||||||
// const sitesClientPairs = await db
|
// const sitesClientPairs = await db
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ authenticated.post(
|
|||||||
logActionAudit(ActionsEnum.updateSite),
|
logActionAudit(ActionsEnum.updateSite),
|
||||||
site.updateSite
|
site.updateSite
|
||||||
);
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/reset-bandwidth",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.resetSiteBandwidth),
|
||||||
|
logActionAudit(ActionsEnum.resetSiteBandwidth),
|
||||||
|
org.resetOrgBandwidth
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/site/:siteId",
|
"/site/:siteId",
|
||||||
@@ -309,6 +316,14 @@ authenticated.post(
|
|||||||
siteResource.removeClientFromSiteResource
|
siteResource.removeClientFromSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/client/:clientId/site-resources",
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
|
siteResource.batchAddClientToSiteResources
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/resource",
|
"/org/:orgId/resource",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db";
|
import {
|
||||||
|
clients,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
ExitNode,
|
||||||
|
resources,
|
||||||
|
Site,
|
||||||
|
siteResources,
|
||||||
|
targetHealthCheck,
|
||||||
|
targets
|
||||||
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip";
|
import {
|
||||||
|
formatEndpoint,
|
||||||
|
generateSubnetProxyTargetV2,
|
||||||
|
SubnetProxyTargetV2
|
||||||
|
} from "@server/lib/ip";
|
||||||
|
|
||||||
export async function buildClientConfigurationForNewtClient(
|
export async function buildClientConfigurationForNewtClient(
|
||||||
site: Site,
|
site: Site,
|
||||||
@@ -69,6 +84,7 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
// )
|
// )
|
||||||
// );
|
// );
|
||||||
|
|
||||||
|
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
|
||||||
// update the peer info on the olm
|
// update the peer info on the olm
|
||||||
// if the peer has not been added yet this will be a no-op
|
// if the peer has not been added yet this will be a no-op
|
||||||
await updatePeer(client.clients.clientId, {
|
await updatePeer(client.clients.clientId, {
|
||||||
@@ -103,6 +119,7 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKey: client.clients.pubKey!,
|
publicKey: client.clients.pubKey!,
|
||||||
@@ -126,7 +143,7 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(eq(siteResources.siteId, siteId));
|
.where(eq(siteResources.siteId, siteId));
|
||||||
|
|
||||||
const targetsToSend: SubnetProxyTarget[] = [];
|
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||||
|
|
||||||
for (const resource of allSiteResources) {
|
for (const resource of allSiteResources) {
|
||||||
// Get clients associated with this specific resource
|
// Get clients associated with this specific resource
|
||||||
@@ -151,12 +168,14 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const resourceTargets = generateSubnetProxyTargets(
|
const resourceTarget = generateSubnetProxyTargetV2(
|
||||||
resource,
|
resource,
|
||||||
resourceClients
|
resourceClients
|
||||||
);
|
);
|
||||||
|
|
||||||
targetsToSend.push(...resourceTargets);
|
if (resourceTarget) {
|
||||||
|
targetsToSend.push(resourceTarget);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -188,7 +207,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
hcTimeout: targetHealthCheck.hcTimeout,
|
hcTimeout: targetHealthCheck.hcTimeout,
|
||||||
hcHeaders: targetHealthCheck.hcHeaders,
|
hcHeaders: targetHealthCheck.hcHeaders,
|
||||||
hcMethod: targetHealthCheck.hcMethod,
|
hcMethod: targetHealthCheck.hcMethod,
|
||||||
hcTlsServerName: targetHealthCheck.hcTlsServerName
|
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
||||||
|
hcStatus: targetHealthCheck.hcStatus
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
@@ -205,8 +225,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format target into string
|
// Format target into string (handles IPv6 bracketing)
|
||||||
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
|
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
|
||||||
|
|
||||||
// Add to the appropriate protocol array
|
// Add to the appropriate protocol array
|
||||||
if (target.protocol === "tcp") {
|
if (target.protocol === "tcp") {
|
||||||
@@ -229,9 +249,9 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
!target.hcInterval ||
|
!target.hcInterval ||
|
||||||
!target.hcMethod
|
!target.hcMethod
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`Skipping target ${target.targetId} due to missing health check fields`
|
// `Skipping adding target health check ${target.targetId} due to missing health check fields`
|
||||||
);
|
// );
|
||||||
return null; // Skip targets with missing health check fields
|
return null; // Skip targets with missing health check fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +281,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
hcTimeout: target.hcTimeout, // in seconds
|
hcTimeout: target.hcTimeout, // in seconds
|
||||||
hcHeaders: hcHeadersSend,
|
hcHeaders: hcHeadersSend,
|
||||||
hcMethod: target.hcMethod,
|
hcMethod: target.hcMethod,
|
||||||
hcTlsServerName: target.hcTlsServerName
|
hcTlsServerName: target.hcTlsServerName,
|
||||||
|
hcStatus: target.hcStatus
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
13
server/routers/newt/handleConnectionLogMessage.ts
Normal file
13
server/routers/newt/handleConnectionLogMessage.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
|
||||||
|
export async function flushConnectionLogToDb(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||||
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
|
import { convertTargetsIfNessicary } from "../client/targets";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
@@ -104,11 +106,11 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
oldDestination: {
|
oldDestination: {
|
||||||
destinationIP: existingSite.subnet?.split("/")[0],
|
destinationIP: existingSite.subnet?.split("/")[0],
|
||||||
destinationPort: existingSite.listenPort
|
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
},
|
},
|
||||||
newDestination: {
|
newDestination: {
|
||||||
destinationIP: site.subnet?.split("/")[0],
|
destinationIP: site.subnet?.split("/")[0],
|
||||||
destinationPort: site.listenPort
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,15 +128,20 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
exitNode
|
exitNode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "newt/wg/receive-config",
|
type: "newt/wg/receive-config",
|
||||||
data: {
|
data: {
|
||||||
ipAddress: site.address,
|
ipAddress: site.address,
|
||||||
peers,
|
peers,
|
||||||
targets
|
targets: targetsToSend
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(newt.version, "newt")
|
||||||
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
excludeSender: false
|
excludeSender: false
|
||||||
};
|
};
|
||||||
|
|||||||
36
server/routers/newt/handleNewtDisconnectingMessage.ts
Normal file
36
server/routers/newt/handleNewtDisconnectingMessage.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { db, Newt, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles disconnecting messages from sites to show disconnected in the ui
|
||||||
|
*/
|
||||||
|
export const handleNewtDisconnectingMessage: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
const { message, client: c, sendToClient } = context;
|
||||||
|
const newt = c as Newt;
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Newt not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newt.siteId) {
|
||||||
|
logger.warn("Newt has no client ID!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the client's last ping timestamp
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: false
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error handling disconnecting message", { error });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,105 +1,107 @@
|
|||||||
import { db, sites } from "@server/db";
|
import { db, newts, sites } from "@server/db";
|
||||||
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
|
import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
|
||||||
import { sendNewtSyncMessage } from "./sync";
|
import { sendNewtSyncMessage } from "./sync";
|
||||||
|
|
||||||
// Track if the offline checker interval is running
|
// Track if the offline checker interval is running
|
||||||
// let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||||
// const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||||
// const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the background interval that checks for clients that haven't pinged recently
|
* Starts the background interval that checks for newt sites that haven't
|
||||||
* and marks them as offline
|
* pinged recently and marks them as offline. For backward compatibility,
|
||||||
|
* a site is only marked offline when there is no active WebSocket connection
|
||||||
|
* either — so older newt versions that don't send pings but remain connected
|
||||||
|
* continue to be treated as online.
|
||||||
*/
|
*/
|
||||||
// export const startNewtOfflineChecker = (): void => {
|
export const startNewtOfflineChecker = (): void => {
|
||||||
// if (offlineCheckerInterval) {
|
if (offlineCheckerInterval) {
|
||||||
// return; // Already running
|
return; // Already running
|
||||||
// }
|
}
|
||||||
|
|
||||||
// offlineCheckerInterval = setInterval(async () => {
|
offlineCheckerInterval = setInterval(async () => {
|
||||||
// try {
|
try {
|
||||||
// const twoMinutesAgo = Math.floor(
|
const twoMinutesAgo = Math.floor(
|
||||||
// (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||||
// );
|
);
|
||||||
|
|
||||||
// // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
|
// Find all online newt-type sites that haven't pinged recently
|
||||||
|
// (or have never pinged at all). Join newts to obtain the newtId
|
||||||
|
// needed for the WebSocket connection check.
|
||||||
|
const staleSites = await db
|
||||||
|
.select({
|
||||||
|
siteId: sites.siteId,
|
||||||
|
newtId: newts.newtId,
|
||||||
|
lastPing: sites.lastPing
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(newts, eq(newts.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.online, true),
|
||||||
|
eq(sites.type, "newt"),
|
||||||
|
or(
|
||||||
|
lt(sites.lastPing, twoMinutesAgo),
|
||||||
|
isNull(sites.lastPing)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// // Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
for (const staleSite of staleSites) {
|
||||||
// const offlineClients = await db
|
// Backward-compatibility check: if the newt still has an
|
||||||
// .update(clients)
|
// active WebSocket connection (older clients that don't send
|
||||||
// .set({ online: false })
|
// pings), keep the site online.
|
||||||
// .where(
|
const isConnected = await hasActiveConnections(staleSite.newtId);
|
||||||
// and(
|
if (isConnected) {
|
||||||
// eq(clients.online, true),
|
logger.debug(
|
||||||
// or(
|
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online`
|
||||||
// lt(clients.lastPing, twoMinutesAgo),
|
);
|
||||||
// isNull(clients.lastPing)
|
continue;
|
||||||
// )
|
}
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .returning();
|
|
||||||
|
|
||||||
// for (const offlineClient of offlineClients) {
|
logger.info(
|
||||||
// logger.info(
|
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
|
||||||
// `Kicking offline newt client ${offlineClient.clientId} due to inactivity`
|
);
|
||||||
// );
|
|
||||||
|
|
||||||
// if (!offlineClient.newtId) {
|
await db
|
||||||
// logger.warn(
|
.update(sites)
|
||||||
// `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect`
|
.set({ online: false })
|
||||||
// );
|
.where(eq(sites.siteId, staleSite.siteId));
|
||||||
// continue;
|
}
|
||||||
// }
|
} catch (error) {
|
||||||
|
logger.error("Error in newt offline checker interval", { error });
|
||||||
|
}
|
||||||
|
}, OFFLINE_CHECK_INTERVAL);
|
||||||
|
|
||||||
// // Send a disconnect message to the client if connected
|
logger.debug("Started newt offline checker interval");
|
||||||
// try {
|
};
|
||||||
// await sendTerminateClient(
|
|
||||||
// offlineClient.clientId,
|
|
||||||
// offlineClient.newtId
|
|
||||||
// ); // terminate first
|
|
||||||
// // wait a moment to ensure the message is sent
|
|
||||||
// await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
// await disconnectClient(offlineClient.newtId);
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error(
|
|
||||||
// `Error sending disconnect to offline newt ${offlineClient.clientId}`,
|
|
||||||
// { error }
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error("Error in offline checker interval", { error });
|
|
||||||
// }
|
|
||||||
// }, OFFLINE_CHECK_INTERVAL);
|
|
||||||
|
|
||||||
// logger.debug("Started offline checker interval");
|
|
||||||
// };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the background interval that checks for offline clients
|
* Stops the background interval that checks for offline newt sites.
|
||||||
*/
|
*/
|
||||||
// export const stopNewtOfflineChecker = (): void => {
|
export const stopNewtOfflineChecker = (): void => {
|
||||||
// if (offlineCheckerInterval) {
|
if (offlineCheckerInterval) {
|
||||||
// clearInterval(offlineCheckerInterval);
|
clearInterval(offlineCheckerInterval);
|
||||||
// offlineCheckerInterval = null;
|
offlineCheckerInterval = null;
|
||||||
// logger.info("Stopped offline checker interval");
|
logger.info("Stopped newt offline checker interval");
|
||||||
// }
|
}
|
||||||
// };
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from newt clients.
|
||||||
|
*
|
||||||
|
* On each ping:
|
||||||
|
* - Marks the associated site as online.
|
||||||
|
* - Records the current timestamp as the newt's last-ping time.
|
||||||
|
* - Triggers a config sync if the newt is running an outdated config version.
|
||||||
|
* - Responds with a pong message.
|
||||||
*/
|
*/
|
||||||
export const handleNewtPingMessage: MessageHandler = async (context) => {
|
export const handleNewtPingMessage: MessageHandler = async (context) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c } = context;
|
||||||
const newt = c as Newt;
|
const newt = c as Newt;
|
||||||
|
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
@@ -112,15 +114,31 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the version
|
try {
|
||||||
|
// Mark the site as online and record the ping timestamp.
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating online state on newt ping", { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config version and sync if stale.
|
||||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
const configVersion = await getClientConfigVersion(newt.newtId);
|
||||||
|
|
||||||
if (message.configVersion && configVersion != null && configVersion != message.configVersion) {
|
if (
|
||||||
|
message.configVersion != null &&
|
||||||
|
configVersion != null &&
|
||||||
|
configVersion !== message.configVersion
|
||||||
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
|
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// get the site
|
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
@@ -137,19 +155,6 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
await sendNewtSyncMessage(newt, site);
|
await sendNewtSyncMessage(newt, site);
|
||||||
}
|
}
|
||||||
|
|
||||||
// try {
|
|
||||||
// // Update the client's last ping timestamp
|
|
||||||
// await db
|
|
||||||
// .update(clients)
|
|
||||||
// .set({
|
|
||||||
// lastPing: Math.floor(Date.now() / 1000),
|
|
||||||
// online: true
|
|
||||||
// })
|
|
||||||
// .where(eq(clients.clientId, newt.clientId));
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error("Error handling ping message", { error });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "pong",
|
type: "pong",
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
findNextAvailableCidr,
|
|
||||||
} from "@server/lib/ip";
|
|
||||||
import {
|
import {
|
||||||
selectBestExitNode,
|
selectBestExitNode,
|
||||||
verifyExitNodeOrgAccess
|
verifyExitNodeOrgAccess
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
import { fetchContainers } from "./dockerSocket";
|
import { fetchContainers } from "./dockerSocket";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export type ExitNodePingResult = {
|
export type ExitNodePingResult = {
|
||||||
exitNodeId: number;
|
exitNodeId: number;
|
||||||
@@ -215,6 +214,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
healthCheckTargets: validHealthCheckTargets
|
healthCheckTargets: validHealthCheckTargets
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(newt.version, "newt")
|
||||||
|
},
|
||||||
broadcast: false, // Send to all clients
|
broadcast: false, // Send to all clients
|
||||||
excludeSender: false // Include sender in broadcast
|
excludeSender: false // Include sender in broadcast
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,10 +10,21 @@ interface PeerBandwidth {
|
|||||||
bytesOut: number;
|
bytesOut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BandwidthAccumulator {
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Retry configuration for deadlock handling
|
// Retry configuration for deadlock handling
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const BASE_DELAY_MS = 50;
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
// How often to flush accumulated bandwidth data to the database
|
||||||
|
const FLUSH_INTERVAL_MS = 120_000; // 120 seconds
|
||||||
|
|
||||||
|
// In-memory accumulator: publicKey -> { bytesIn, bytesOut }
|
||||||
|
let accumulator = new Map<string, BandwidthAccumulator>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is a deadlock error
|
* Check if an error is a deadlock error
|
||||||
*/
|
*/
|
||||||
@@ -53,6 +64,90 @@ async function withDeadlockRetry<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all accumulated bandwidth data to the database.
|
||||||
|
*
|
||||||
|
* Swaps out the accumulator before writing so that any bandwidth messages
|
||||||
|
* received during the flush are captured in the new accumulator rather than
|
||||||
|
* being lost or causing contention. Entries that fail to write are re-queued
|
||||||
|
* back into the accumulator so they will be retried on the next flush.
|
||||||
|
*
|
||||||
|
* This function is exported so that the application's graceful-shutdown
|
||||||
|
* cleanup handler can call it before the process exits.
|
||||||
|
*/
|
||||||
|
export async function flushBandwidthToDb(): Promise<void> {
|
||||||
|
if (accumulator.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically swap out the accumulator so new data keeps flowing in
|
||||||
|
// while we write the snapshot to the database.
|
||||||
|
const snapshot = accumulator;
|
||||||
|
accumulator = new Map<string, BandwidthAccumulator>();
|
||||||
|
|
||||||
|
const currentTime = new Date().toISOString();
|
||||||
|
|
||||||
|
// Sort by publicKey for consistent lock ordering across concurrent
|
||||||
|
// writers — this is the same deadlock-prevention strategy used in the
|
||||||
|
// original per-message implementation.
|
||||||
|
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Flushing accumulated bandwidth data for ${sortedEntries.length} client(s) to the database`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [publicKey, { bytesIn, bytesOut }] of sortedEntries) {
|
||||||
|
try {
|
||||||
|
await withDeadlockRetry(async () => {
|
||||||
|
// Use atomic SQL increment to avoid the SELECT-then-UPDATE
|
||||||
|
// anti-pattern and the races it would introduce.
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
// Note: bytesIn from peer goes to megabytesOut (data
|
||||||
|
// sent to client) and bytesOut from peer goes to
|
||||||
|
// megabytesIn (data received from client).
|
||||||
|
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
|
||||||
|
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
|
||||||
|
lastBandwidthUpdate: currentTime
|
||||||
|
})
|
||||||
|
.where(eq(clients.pubKey, publicKey));
|
||||||
|
}, `flush bandwidth for client ${publicKey}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to flush bandwidth for client ${publicKey}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-queue the failed entry so it is retried on the next flush
|
||||||
|
// rather than silently dropped.
|
||||||
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.bytesIn += bytesIn;
|
||||||
|
existing.bytesOut += bytesOut;
|
||||||
|
} else {
|
||||||
|
accumulator.set(publicKey, { bytesIn, bytesOut });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await flushBandwidthToDb();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Unexpected error during periodic bandwidth flush:", error);
|
||||||
|
}
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Calling unref() means this timer will not keep the Node.js event loop alive
|
||||||
|
// on its own — the process can still exit normally when there is no other work
|
||||||
|
// left. The graceful-shutdown path (see server/cleanup.ts) will call
|
||||||
|
// flushBandwidthToDb() explicitly before process.exit(), so no data is lost.
|
||||||
|
flushTimer.unref();
|
||||||
|
|
||||||
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
@@ -69,40 +164,21 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
|
|||||||
throw new Error("Invalid bandwidth data");
|
throw new Error("Invalid bandwidth data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
|
// Accumulate the incoming data in memory; the periodic timer (and the
|
||||||
// This is critical for preventing deadlocks when multiple instances update the same clients
|
// shutdown hook) will take care of writing it to the database.
|
||||||
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
|
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
|
||||||
a.publicKey.localeCompare(b.publicKey)
|
// Skip peers that haven't transferred any data — writing zeros to the
|
||||||
);
|
// database would be a no-op anyway.
|
||||||
|
if (bytesIn <= 0 && bytesOut <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const currentTime = new Date().toISOString();
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
// Update each client individually with retry logic
|
existing.bytesIn += bytesIn;
|
||||||
// This reduces transaction scope and allows retries per-client
|
existing.bytesOut += bytesOut;
|
||||||
for (const peer of sortedBandwidthData) {
|
} else {
|
||||||
const { publicKey, bytesIn, bytesOut } = peer;
|
accumulator.set(publicKey, { bytesIn, bytesOut });
|
||||||
|
|
||||||
try {
|
|
||||||
await withDeadlockRetry(async () => {
|
|
||||||
// Use atomic SQL increment to avoid SELECT then UPDATE pattern
|
|
||||||
// This eliminates the need to read the current value first
|
|
||||||
await db
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
// Note: bytesIn from peer goes to megabytesOut (data sent to client)
|
|
||||||
// and bytesOut from peer goes to megabytesIn (data received from client)
|
|
||||||
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
|
|
||||||
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
|
|
||||||
lastBandwidthUpdate: currentTime
|
|
||||||
})
|
|
||||||
.where(eq(clients.pubKey, publicKey));
|
|
||||||
}, `update client bandwidth ${publicKey}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to update bandwidth for client ${publicKey}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Continue with other clients even if one fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ export * from "./handleSocketMessages";
|
|||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
export * from "./handleApplyBlueprintMessage";
|
export * from "./handleApplyBlueprintMessage";
|
||||||
export * from "./handleNewtPingMessage";
|
export * from "./handleNewtPingMessage";
|
||||||
|
export * from "./handleNewtDisconnectingMessage";
|
||||||
|
export * from "./handleConnectionLogMessage";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildClientConfigurationForNewtClient,
|
buildClientConfigurationForNewtClient,
|
||||||
buildTargetConfigurationForNewtClient
|
buildTargetConfigurationForNewtClient
|
||||||
} from "./buildConfiguration";
|
} from "./buildConfiguration";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||||
@@ -24,7 +25,9 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
|||||||
exitNode
|
exitNode
|
||||||
);
|
);
|
||||||
|
|
||||||
await sendToClient(newt.newtId, {
|
await sendToClient(
|
||||||
|
newt.newtId,
|
||||||
|
{
|
||||||
type: "newt/sync",
|
type: "newt/sync",
|
||||||
data: {
|
data: {
|
||||||
proxyTargets: {
|
proxyTargets: {
|
||||||
@@ -35,7 +38,11 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
|||||||
peers: peers,
|
peers: peers,
|
||||||
clientTargets: targets
|
clientTargets: targets
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
},
|
||||||
|
{
|
||||||
|
compress: canCompress(newt.version, "newt")
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending newt sync message:`, error);
|
logger.warn(`Error sending newt sync message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db";
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function addTargets(
|
export async function addTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: Target[],
|
targets: Target[],
|
||||||
healthCheckData: TargetHealthCheck[],
|
healthCheckData: TargetHealthCheck[],
|
||||||
protocol: string,
|
protocol: string,
|
||||||
port: number | null = null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const payloadTargets = targets.map((target) => {
|
const payloadTargets = targets.map((target) => {
|
||||||
@@ -22,7 +23,7 @@ export async function addTargets(
|
|||||||
data: {
|
data: {
|
||||||
targets: payloadTargets
|
targets: payloadTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true });
|
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup
|
||||||
const healthCheckMap = new Map<number, TargetHealthCheck>();
|
const healthCheckMap = new Map<number, TargetHealthCheck>();
|
||||||
@@ -103,14 +104,14 @@ export async function addTargets(
|
|||||||
data: {
|
data: {
|
||||||
targets: validHealthCheckTargets
|
targets: validHealthCheckTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true });
|
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: Target[],
|
targets: Target[],
|
||||||
protocol: string,
|
protocol: string,
|
||||||
port: number | null = null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const payloadTargets = targets.map((target) => {
|
const payloadTargets = targets.map((target) => {
|
||||||
@@ -135,5 +136,5 @@ export async function removeTargets(
|
|||||||
data: {
|
data: {
|
||||||
ids: healthCheckTargets
|
ids: healthCheckTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true });
|
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Client, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, siteResources, sites } from "@server/db";
|
import {
|
||||||
import { generateAliasConfig, generateRemoteSubnets } from "@server/lib/ip";
|
Client,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
siteResources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import {
|
||||||
|
Alias,
|
||||||
|
generateAliasConfig,
|
||||||
|
generateRemoteSubnets
|
||||||
|
} from "@server/lib/ip";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
import { addPeer, deletePeer } from "../newt/peers";
|
||||||
@@ -8,9 +20,19 @@ import config from "@server/lib/config";
|
|||||||
export async function buildSiteConfigurationForOlmClient(
|
export async function buildSiteConfigurationForOlmClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
publicKey: string | null,
|
publicKey: string | null,
|
||||||
relay: boolean
|
relay: boolean,
|
||||||
|
jitMode: boolean = false
|
||||||
) {
|
) {
|
||||||
const siteConfigurations = [];
|
const siteConfigurations: {
|
||||||
|
siteId: number;
|
||||||
|
name?: string
|
||||||
|
endpoint?: string
|
||||||
|
publicKey?: string
|
||||||
|
serverIP?: string | null
|
||||||
|
serverPort?: number | null
|
||||||
|
remoteSubnets?: string[];
|
||||||
|
aliases: Alias[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// Get all sites data
|
// Get all sites data
|
||||||
const sitesData = await db
|
const sitesData = await db
|
||||||
@@ -27,6 +49,40 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
sites: site,
|
sites: site,
|
||||||
clientSitesAssociationsCache: association
|
clientSitesAssociationsCache: association
|
||||||
} of sitesData) {
|
} of sitesData) {
|
||||||
|
const allSiteResources = await db // only get the site resources that this client has access to
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.siteId, site.siteId),
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (jitMode) {
|
||||||
|
// Add site configuration to the array
|
||||||
|
siteConfigurations.push({
|
||||||
|
siteId: site.siteId,
|
||||||
|
// remoteSubnets: generateRemoteSubnets(
|
||||||
|
// allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
|
// ),
|
||||||
|
aliases: generateAliasConfig(
|
||||||
|
allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Site ${site.siteId} does not have exit node, skipping`
|
`Site ${site.siteId} does not have exit node, skipping`
|
||||||
@@ -42,6 +98,13 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no public key, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
||||||
// logger.warn(
|
// logger.warn(
|
||||||
// `Site ${site.siteId} last hole punch is too old, skipping`
|
// `Site ${site.siteId} last hole punch is too old, skipping`
|
||||||
@@ -103,26 +166,6 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
|
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSiteResources = await db // only get the site resources that this client has access to
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.innerJoin(
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
clientSiteResourcesAssociationsCache.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteId, site.siteId),
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add site configuration to the array
|
// Add site configuration to the array
|
||||||
siteConfigurations.push({
|
siteConfigurations.push({
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logger from "@server/logger";
|
|||||||
/**
|
/**
|
||||||
* Handles disconnecting messages from clients to show disconnected in the ui
|
* Handles disconnecting messages from clients to show disconnected in the ui
|
||||||
*/
|
*/
|
||||||
export const handleOlmDisconnecingMessage: MessageHandler = async (context) => {
|
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { getUserDeviceName } from "@server/db/names";
|
|||||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||||
|
import { Alias } from "@server/lib/ip";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
logger.info("Handling register olm message!");
|
logger.info("Handling register olm message!");
|
||||||
@@ -207,6 +210,32 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all sites data
|
||||||
|
const sitesCountResult = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
||||||
|
)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
|
// Extract the count value from the result array
|
||||||
|
const sitesCount =
|
||||||
|
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
||||||
|
|
||||||
|
// Prepare an array to store site configurations
|
||||||
|
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
||||||
|
|
||||||
|
let jitMode = false;
|
||||||
|
if (sitesCount > 250 && build == "saas") {
|
||||||
|
// THIS IS THE MAX ON THE BUSINESS TIER
|
||||||
|
// we have too many sites
|
||||||
|
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
||||||
|
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
|
||||||
|
jitMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
||||||
);
|
);
|
||||||
@@ -233,28 +262,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
await db
|
await db
|
||||||
.update(clientSitesAssociationsCache)
|
.update(clientSitesAssociationsCache)
|
||||||
.set({
|
.set({
|
||||||
isRelayed: relay == true
|
isRelayed: relay == true,
|
||||||
|
isJitMode: jitMode
|
||||||
})
|
})
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all sites data
|
|
||||||
const sitesCountResult = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
|
||||||
.innerJoin(
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
|
||||||
)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
|
||||||
|
|
||||||
// Extract the count value from the result array
|
|
||||||
const sitesCount =
|
|
||||||
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
|
||||||
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
|
||||||
|
|
||||||
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
||||||
// the olm will pump the register so we can keep checking
|
// the olm will pump the register so we can keep checking
|
||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
@@ -269,15 +282,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||||
client,
|
client,
|
||||||
publicKey,
|
publicKey,
|
||||||
relay
|
relay,
|
||||||
|
jitMode
|
||||||
);
|
);
|
||||||
|
|
||||||
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
|
||||||
// if (siteConfigurations.length === 0) {
|
|
||||||
// logger.warn("No valid site configurations found");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
// Return connect message with all site configurations
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
@@ -288,6 +296,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
utilitySubnet: org.utilitySubnet
|
utilitySubnet: org.utilitySubnet
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(olm.version, "olm")
|
||||||
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
excludeSender: false
|
excludeSender: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
|
logger.warn("Olm has no client!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId, chainId } = message.data;
|
||||||
|
|
||||||
// Get the site
|
// Get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -90,7 +90,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
relayEndpoint: exitNode.endpoint,
|
relayEndpoint: exitNode.endpoint,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
|
chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
241
server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
Normal file
241
server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
Site,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { clients, Olm, sites } from "@server/db";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { initPeerAddHandshake } from "./peers";
|
||||||
|
|
||||||
|
export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
logger.info("Handling register olm message!");
|
||||||
|
const { message, client: c, sendToClient } = context;
|
||||||
|
const olm = c as Olm;
|
||||||
|
|
||||||
|
if (!olm) {
|
||||||
|
logger.warn("Olm not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!olm.clientId) {
|
||||||
|
logger.warn("Olm has no client!"); // TODO: Maybe we create the site here?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = olm.clientId;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
logger.warn("Client not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, resourceId, chainId } = message.data;
|
||||||
|
|
||||||
|
let site: Site | null = null;
|
||||||
|
if (siteId) {
|
||||||
|
// get the site
|
||||||
|
const [siteRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
if (siteRes) {
|
||||||
|
site = siteRes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId && !site) {
|
||||||
|
const resources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(siteResources.niceId, resourceId),
|
||||||
|
eq(siteResources.alias, resourceId)
|
||||||
|
),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.length > 1) {
|
||||||
|
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resources[0];
|
||||||
|
|
||||||
|
const currentResourceAssociationCaches = await db
|
||||||
|
.select()
|
||||||
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||||
|
resource.siteResourceId
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentResourceAssociationCaches.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteIdFromResource = resource.siteId;
|
||||||
|
|
||||||
|
// get the site
|
||||||
|
const [siteRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteIdFromResource));
|
||||||
|
if (!siteRes) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
site = siteRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the client can access this site using the cache
|
||||||
|
const currentSiteAssociationCaches = await db
|
||||||
|
.select()
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSitesAssociationsCache.clientId, client.clientId),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, site.siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSiteAssociationCaches.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
|
||||||
|
);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site.exitNodeId) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||||
|
);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the exit node from the side
|
||||||
|
const [exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||||
|
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
||||||
|
// if it has already been added this will be a no-op
|
||||||
|
await initPeerAddHandshake(
|
||||||
|
// this will kick off the add peer process for the client
|
||||||
|
client.clientId,
|
||||||
|
{
|
||||||
|
siteId: site.siteId,
|
||||||
|
exitNode: {
|
||||||
|
publicKey: exitNode.publicKey,
|
||||||
|
endpoint: exitNode.endpoint
|
||||||
|
}
|
||||||
|
},
|
||||||
|
olm.olmId,
|
||||||
|
chainId
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -54,7 +54,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId, chainId } = message.data;
|
||||||
|
|
||||||
// get the site
|
// get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -179,7 +179,8 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
),
|
),
|
||||||
aliases: generateAliasConfig(
|
aliases: generateAliasConfig(
|
||||||
allSiteResources.map(({ siteResources }) => siteResources)
|
allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
)
|
),
|
||||||
|
chainId: chainId,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
|
logger.warn("Olm has no client!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId, chainId } = message.data;
|
||||||
|
|
||||||
// Get the site
|
// Get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -87,7 +87,8 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
type: "olm/wg/peer/unrelay",
|
type: "olm/wg/peer/unrelay",
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
endpoint: site.endpoint
|
endpoint: site.endpoint,
|
||||||
|
chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./handleOlmServerPeerAddMessage";
|
|||||||
export * from "./handleOlmUnRelayMessage";
|
export * from "./handleOlmUnRelayMessage";
|
||||||
export * from "./recoverOlmWithFingerprint";
|
export * from "./recoverOlmWithFingerprint";
|
||||||
export * from "./handleOlmDisconnectingMessage";
|
export * from "./handleOlmDisconnectingMessage";
|
||||||
|
export * from "./handleOlmServerInitAddPeerHandshake";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, olms } from "@server/db";
|
import { clientSitesAssociationsCache, db, olms } from "@server/db";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { Alias } from "yaml";
|
import { Alias } from "yaml";
|
||||||
|
|
||||||
export async function addPeer(
|
export async function addPeer(
|
||||||
@@ -18,7 +19,8 @@ export async function addPeer(
|
|||||||
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
|
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
|
||||||
aliases: Alias[];
|
aliases: Alias[];
|
||||||
},
|
},
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -30,6 +32,7 @@ export async function addPeer(
|
|||||||
return; // ignore this because an olm might not be associated with the client anymore
|
return; // ignore this because an olm might not be associated with the client anymore
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(
|
||||||
@@ -48,7 +51,7 @@ export async function addPeer(
|
|||||||
aliases: peer.aliases
|
aliases: peer.aliases
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
@@ -60,7 +63,8 @@ export async function deletePeer(
|
|||||||
clientId: number,
|
clientId: number,
|
||||||
siteId: number,
|
siteId: number,
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -72,6 +76,7 @@ export async function deletePeer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(
|
||||||
@@ -83,7 +88,7 @@ export async function deletePeer(
|
|||||||
siteId: siteId
|
siteId: siteId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
@@ -103,7 +108,8 @@ export async function updatePeer(
|
|||||||
remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that
|
remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that
|
||||||
aliases?: Alias[] | null;
|
aliases?: Alias[] | null;
|
||||||
},
|
},
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -115,6 +121,7 @@ export async function updatePeer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(
|
||||||
@@ -132,7 +139,7 @@ export async function updatePeer(
|
|||||||
aliases: peer.aliases
|
aliases: peer.aliases
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
@@ -149,7 +156,8 @@ export async function initPeerAddHandshake(
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
chainId?: string
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -173,7 +181,8 @@ export async function initPeerAddHandshake(
|
|||||||
publicKey: peer.exitNode.publicKey,
|
publicKey: peer.exitNode.publicKey,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
endpoint: peer.exitNode.endpoint
|
endpoint: peer.exitNode.endpoint
|
||||||
}
|
},
|
||||||
|
chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true }
|
||||||
@@ -181,6 +190,17 @@ export async function initPeerAddHandshake(
|
|||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// update the clientSiteAssociationsCache to make the isJitMode flag false so that JIT mode is disabled for this site if it restarts or something after the connection
|
||||||
|
await db
|
||||||
|
.update(clientSitesAssociationsCache)
|
||||||
|
.set({ isJitMode: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSitesAssociationsCache.clientId, clientId),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, peer.siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
|
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { Client, db, exitNodes, Olm, sites, clientSitesAssociationsCache } from "@server/db";
|
import {
|
||||||
|
Client,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
Olm,
|
||||||
|
sites,
|
||||||
|
clientSitesAssociationsCache
|
||||||
|
} from "@server/db";
|
||||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
||||||
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
|
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
|
||||||
@@ -17,10 +25,7 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
|||||||
const clientSites = await db
|
const clientSites = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clientSitesAssociationsCache)
|
.from(clientSitesAssociationsCache)
|
||||||
.innerJoin(
|
.innerJoin(sites, eq(sites.siteId, clientSitesAssociationsCache.siteId))
|
||||||
sites,
|
|
||||||
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
|
||||||
)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
// Extract unique exit node IDs
|
// Extract unique exit node IDs
|
||||||
@@ -68,13 +73,20 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
|||||||
|
|
||||||
logger.debug("sendOlmSyncMessage: sending sync message");
|
logger.debug("sendOlmSyncMessage: sending sync message");
|
||||||
|
|
||||||
await sendToClient(olm.olmId, {
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
type: "olm/sync",
|
type: "olm/sync",
|
||||||
data: {
|
data: {
|
||||||
sites: siteConfigurations,
|
sites: siteConfigurations,
|
||||||
exitNodes: exitNodesData
|
exitNodes: exitNodesData
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
compress: canCompress(olm.version, "olm")
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending olm sync message:`, error);
|
logger.warn(`Error sending olm sync message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./getOrgOverview";
|
|||||||
export * from "./listOrgs";
|
export * from "./listOrgs";
|
||||||
export * from "./pickOrgDefaults";
|
export * from "./pickOrgDefaults";
|
||||||
export * from "./checkOrgUserAccess";
|
export * from "./checkOrgUserAccess";
|
||||||
|
export * from "./resetOrgBandwidth";
|
||||||
|
|||||||
83
server/routers/org/resetOrgBandwidth.ts
Normal file
83
server/routers/org/resetOrgBandwidth.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const resetOrgBandwidthParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/org/{orgId}/reset-bandwidth",
|
||||||
|
description: "Reset all sites in selected organization bandwidth counters.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.Site],
|
||||||
|
request: {
|
||||||
|
params: resetOrgBandwidthParamsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function resetOrgBandwidth(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = resetOrgBandwidthParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [site] = await db
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`No sites found in org ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
megabytesIn: 0,
|
||||||
|
megabytesOut: 0
|
||||||
|
})
|
||||||
|
.where(eq(sites.orgId, orgId));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Sites bandwidth reset successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,6 +223,20 @@ async function createHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent creating resource with same domain as dashboard
|
||||||
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
|
if (dashboardUrl) {
|
||||||
|
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||||
|
if (fullDomain === dashboardHost) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource domain cannot be the same as the dashboard domain"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (build != "oss") {
|
if (build != "oss") {
|
||||||
const existingLoginPages = await db
|
const existingLoginPages = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -101,6 +101,49 @@ const updateHttpResourceBodySchema = z
|
|||||||
{
|
{
|
||||||
error: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
error: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.headers) {
|
||||||
|
// HTTP header names must be valid token characters (RFC 7230)
|
||||||
|
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||||
|
return data.headers.every((h) => validHeaderName.test(h.name));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.headers) {
|
||||||
|
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
|
||||||
|
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
||||||
|
return data.headers.every((h) => validHeaderValue.test(h.value));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
error: "Header values may only contain printable ASCII characters and horizontal whitespace."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.headers) {
|
||||||
|
// Reject Traefik template syntax {{word}} in names or values
|
||||||
|
const templatePattern = /\{\{[^}]+\}\}/;
|
||||||
|
return data.headers.every(
|
||||||
|
(h) =>
|
||||||
|
!templatePattern.test(h.name) &&
|
||||||
|
!templatePattern.test(h.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
error: "Header names and values must not contain template expressions such as {{value}}."
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type UpdateResourceResponse = Resource;
|
export type UpdateResourceResponse = Resource;
|
||||||
@@ -310,6 +353,20 @@ async function updateHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent updating resource with same domain as dashboard
|
||||||
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
|
if (dashboardUrl) {
|
||||||
|
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||||
|
if (fullDomain === dashboardHost) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource domain cannot be the same as the dashboard domain"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (build != "oss") {
|
if (build != "oss") {
|
||||||
const existingLoginPages = await db
|
const existingLoginPages = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
|||||||
tags = tags.filter((version) => !version.name.includes("rc"));
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
const latestVersion = tags[0].name;
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
await cache.set("latestNewtVersion", latestVersion);
|
await cache.set("latestNewtVersion", latestVersion, 3600);
|
||||||
|
|
||||||
return latestVersion;
|
return latestVersion;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -180,7 +180,7 @@ registry.registerPath({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/org/{orgId}/sites",
|
path: "/org/{orgId}/sites",
|
||||||
description: "List all sites in an organization",
|
description: "List all sites in an organization",
|
||||||
tags: [OpenAPITags.Site],
|
tags: [OpenAPITags.Org, OpenAPITags.Site],
|
||||||
request: {
|
request: {
|
||||||
params: listSitesParamsSchema,
|
params: listSitesParamsSchema,
|
||||||
query: listSitesSchema
|
query: listSitesSchema
|
||||||
|
|||||||
247
server/routers/siteResource/batchAddClientToSiteResources.ts
Normal file
247
server/routers/siteResource/batchAddClientToSiteResources.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
clients,
|
||||||
|
clientSiteResources,
|
||||||
|
siteResources,
|
||||||
|
apiKeyOrg
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
rebuildClientAssociationsFromClient,
|
||||||
|
rebuildClientAssociationsFromSiteResource
|
||||||
|
} from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const batchAddClientToSiteResourcesParamsSchema = z
|
||||||
|
.object({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const batchAddClientToSiteResourcesBodySchema = z
|
||||||
|
.object({
|
||||||
|
siteResourceIds: z
|
||||||
|
.array(z.number().int().positive())
|
||||||
|
.min(1, "At least one siteResourceId is required")
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/client/{clientId}/site-resources",
|
||||||
|
description: "Add a machine client to multiple site resources at once.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: batchAddClientToSiteResourcesParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: batchAddClientToSiteResourcesBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function batchAddClientToSiteResources(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams =
|
||||||
|
batchAddClientToSiteResourcesParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = batchAddClientToSiteResourcesBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
const { siteResourceIds } = parsedBody.data;
|
||||||
|
const uniqueSiteResourceIds = [...new Set(siteResourceIds)];
|
||||||
|
|
||||||
|
const batchSiteResources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, uniqueSiteResourceIds)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (batchSiteResources.length !== uniqueSiteResourceIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"One or more site resources not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey.isRoot) {
|
||||||
|
const orgIds = [
|
||||||
|
...new Set(batchSiteResources.map((sr) => sr.orgId))
|
||||||
|
];
|
||||||
|
if (orgIds.length > 1) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"All site resources must belong to the same organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const orgId = orgIds[0];
|
||||||
|
const [apiKeyOrgRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!apiKeyOrgRow) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to the organization of the specified site resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [clientInOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.clientId, clientId),
|
||||||
|
eq(clients.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!clientInOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to the specified client"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Client not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.userId !== null) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"This endpoint only supports machine (non-user) clients; the specified client is associated with a user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntries = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: clientSiteResources.siteResourceId
|
||||||
|
})
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSiteResources.clientId, clientId),
|
||||||
|
inArray(
|
||||||
|
clientSiteResources.siteResourceId,
|
||||||
|
batchSiteResources.map((sr) => sr.siteResourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingSiteResourceIds = new Set(
|
||||||
|
existingEntries.map((e) => e.siteResourceId)
|
||||||
|
);
|
||||||
|
const siteResourcesToAdd = batchSiteResources.filter(
|
||||||
|
(sr) => !existingSiteResourceIds.has(sr.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siteResourcesToAdd.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Client is already assigned to all specified site resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (const siteResource of siteResourcesToAdd) {
|
||||||
|
await trx.insert(clientSiteResources).values({
|
||||||
|
clientId,
|
||||||
|
siteResourceId: siteResource.siteResourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(client, trx);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {
|
||||||
|
addedCount: siteResourcesToAdd.length,
|
||||||
|
skippedCount:
|
||||||
|
batchSiteResources.length - siteResourcesToAdd.length,
|
||||||
|
siteResourceIds: siteResourcesToAdd.map(
|
||||||
|
(sr) => sr.siteResourceId
|
||||||
|
)
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: `Client added to ${siteResourcesToAdd.length} site resource(s) successfully`,
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user