Compare commits

..

47 Commits

Author SHA1 Message Date
dependabot[bot]
a37ecb1ad5 Bump the dev-minor-updates group across 1 directory with 12 updates
Bumps the dev-minor-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.54.1` | `1.66.0` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.2.2` | `4.3.0` |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.91.3` | `5.100.11` |
| [@types/express-session](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/express-session) | `1.18.2` | `1.19.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.5` | `25.9.1` |
| [esbuild](https://github.com/evanw/esbuild) | `0.27.4` | `0.28.0` |
| [esbuild-node-externals](https://github.com/pradel/esbuild-node-externals) | `1.20.1` | `1.22.0` |
| [eslint](https://github.com/eslint/eslint) | `10.0.3` | `10.4.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.1.7` | `16.2.6` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.2` | `4.3.0` |
| [tsx](https://github.com/privatenumber/tsx) | `4.21.0` | `4.22.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.56.1` | `8.59.4` |



Updates `@dotenvx/dotenvx` from 1.54.1 to 1.66.0
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.54.1...v1.66.0)

Updates `@tailwindcss/postcss` from 4.2.2 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/@tailwindcss-postcss)

Updates `@tanstack/react-query-devtools` from 5.91.3 to 5.100.11
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.100.11/packages/react-query-devtools)

Updates `@types/express-session` from 1.18.2 to 1.19.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/express-session)

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

Updates `esbuild` from 0.27.4 to 0.28.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0)

Updates `esbuild-node-externals` from 1.20.1 to 1.22.0
- [Release notes](https://github.com/pradel/esbuild-node-externals/releases)
- [Commits](https://github.com/pradel/esbuild-node-externals/compare/esbuild-node-externals-v1.20.1...esbuild-node-externals-v1.22.0)

Updates `eslint` from 10.0.3 to 10.4.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.3...v10.4.0)

Updates `eslint-config-next` from 16.1.7 to 16.2.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.6/packages/eslint-config-next)

Updates `tailwindcss` from 4.2.2 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss)

Updates `tsx` from 4.21.0 to 4.22.3
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.3)

Updates `typescript-eslint` from 8.56.1 to 8.59.4
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.4/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.100.11
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/express-session"
  dependency-version: 1.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 25.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: esbuild
  dependency-version: 0.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: esbuild-node-externals
  dependency-version: 1.22.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint-config-next
  dependency-version: 16.2.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: tsx
  dependency-version: 4.22.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.59.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-21 02:14:17 +00:00
Owen Schwartz
b8180d848a Merge pull request #3118 from Adityakk9031/#3105
Fix public resource health with unknown WireGuard targets
2026-05-20 16:20:25 -07:00
Owen Schwartz
fef7563e14 Merge pull request #3125 from fosrl/fix-3104
Fix #3104
2026-05-20 16:15:21 -07:00
Owen
6337cf4359 Fix #3104 2026-05-20 16:14:47 -07:00
Owen Schwartz
b3cfe82dff Merge pull request #3124 from fosrl/fix-logoUrl
Fix logo url
2026-05-20 14:19:29 -07:00
Owen
d65128671c Fix logo url 2026-05-20 14:18:55 -07:00
Owen Schwartz
41fdd5de74 Merge pull request #3122 from fosrl/button-to-rebuild-association
Add button to rebuid cache
2026-05-20 12:08:47 -07:00
Owen
2704202ba9 Add button to rebuid cache 2026-05-20 12:08:20 -07:00
Owen Schwartz
72ef0ae020 Merge pull request #3121 from fosrl/patch-rebuild-sites
patch rebuild sites
2026-05-20 11:48:33 -07:00
Owen
1442faa740 Prevent concurrent rebuilds 2026-05-20 11:46:59 -07:00
Owen
6aa589e612 Block adds to clients in jit mode 2026-05-20 11:35:15 -07:00
Owen
4b1a8e14c4 Put long running into the background to end transaction 2026-05-20 11:18:47 -07:00
Owen
1a0db10b1a Verify button to verify cache 2026-05-20 11:15:15 -07:00
Owen
b7634086db Just accept any url for now 2026-05-20 10:47:37 -07:00
Aditya kumar singh
a6469e67a8 Fix public resource health with unknown WireGuard targets 2026-05-20 09:05:05 +05:30
Owen Schwartz
1ba75092f9 Merge pull request #3113 from fosrl/dev
derived only from roles that the user holds AND are assigned to the target resource
2026-05-19 10:56:30 -07:00
Owen
08a08e73b3 derived only from roles that the user holds AND are assigned to the target resource 2026-05-19 10:53:54 -07:00
Owen Schwartz
82745c701a Merge pull request #3094 from fosrl/dev
Sync dev
2026-05-16 20:46:12 -07:00
Owen
68e775659b Merge branch 'main' into dev 2026-05-16 20:45:39 -07:00
Owen
1c5e3000b6 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-16 20:45:31 -07:00
Owen
3b93fd99a1 Remove workflows 2026-05-16 20:44:36 -07:00
Owen Schwartz
159e91a07c Merge pull request #3090 from fosrl/github-action-cosign
Upgrade cosign installer to v4.1.2 and pin cosign version
2026-05-16 14:53:24 -07:00
miloschwartz
530b5082bd make online/connected styling consistent 2026-05-16 12:34:17 -07:00
Marc Schäfer
3322f1ccb4 Update cosign installer version in CI workflow 2026-05-16 16:21:13 +02:00
Marc Schäfer
1b17fba19f Upgrade cosign installer to v4.1.2 and pin cosign version
Updated cosign installer to version 4.1.2 and specified cosign release version.
2026-05-16 16:17:45 +02:00
Owen
dd1f7ba544 Make crowdsec --crowdsec 2026-05-14 21:46:26 -07:00
Owen Schwartz
8c2e6965f1 Merge pull request #3081 from fosrl/dev
Update sidebar
2026-05-14 21:21:03 -07:00
Owen
b414f04cce Remove funding 2026-05-14 21:20:34 -07:00
Owen Schwartz
9c71922dda Merge pull request #3079 from fosrl/dev
Add site information to user api
2026-05-14 20:17:19 -07:00
Owen
6e4a28f227 Add site information as well 2026-05-14 18:02:42 -07:00
Owen Schwartz
64d8f035a2 Merge pull request #3077 from fosrl/dev
1.18.4-s.5
2026-05-14 17:41:51 -07:00
Owen
0a5780a3b3 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-14 17:41:00 -07:00
Owen
d58b96f4b1 Add port and icmp information to api endpoint 2026-05-14 17:39:22 -07:00
Owen Schwartz
f778f5c941 Merge pull request #3071 from fosrl/crowdin_dev
New Crowdin updates
2026-05-14 17:20:50 -07:00
Owen
6422208f69 Optimize get all relays 2026-05-14 16:59:15 -07:00
Owen
c3ebc423b5 Each node should only update its own sites 2026-05-14 16:51:09 -07:00
Owen Schwartz
92f992728f Merge pull request #3074 from fosrl/dev
Optimize building aliases in jit mode
2026-05-14 12:25:44 -07:00
Owen
78ad2d17c7 Optimize building aliases in jit mode 2026-05-14 12:25:05 -07:00
Owen Schwartz
b29bb7384d Merge pull request #3073 from fosrl/dev
Further optimizations
2026-05-14 12:00:25 -07:00
Owen
5a8de8210b Further optimizations 2026-05-14 11:59:59 -07:00
Owen Schwartz
d5181454f4 Merge pull request #3072 from fosrl/dev
Optimize this
2026-05-14 11:34:56 -07:00
Owen
0e0666cacf Optimize this 2026-05-14 11:34:09 -07:00
Owen Schwartz
02ba2393b9 New translations en-us.json (German)
[ci skip]
2026-05-14 11:08:12 -07:00
Owen Schwartz
daf260cf61 Merge pull request #3064 from fosrl/dev
1.18.4-s.1
2026-05-13 14:40:50 -07:00
Owen
92a06e0ea3 Handle jit mode with syncs 2026-05-13 14:00:43 -07:00
Owen
c16d2ff2ed Fix log message 2026-05-13 13:52:35 -07:00
Owen
73a4d7d351 Quiet log message 2026-05-13 11:57:02 -07:00
37 changed files with 1670 additions and 1179 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [fosrl]

View File

@@ -415,7 +415,9 @@ jobs:
- name: Install cosign - name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing # cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
- name: Sign (GHCR, keyless) - name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor. # Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.

View File

@@ -1,39 +0,0 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"

View File

@@ -1,160 +0,0 @@
name: SAAS Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
pre-run:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances started"
release-arm:
name: Build and Release (ARM64)
runs-on: [self-hosted, linux, arm64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download MaxMind GeoLite2 databases
env:
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
run: |
echo "Downloading MaxMind GeoLite2 databases..."
# Download GeoLite2-Country
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-Country.tar.gz
# Download GeoLite2-ASN
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-ASN.tar.gz
# Extract the .mmdb files
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
# Verify files exist
if [ ! -f "GeoLite2-Country.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
exit 1
fi
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
exit 1
fi
# Clean up tar files
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
echo "MaxMind databases downloaded successfully"
ls -lh GeoLite2-*.mmdb
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Build and push Docker images (Docker Hub - ARM64)
run: |
TAG=${{ env.TAG }}
make build-saas tag=$TAG
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
shell: bash
post-run:
needs: [pre-run, release-arm]
if: >-
${{
always() &&
needs.pre-run.result == 'success' &&
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
}}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances stopped"

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand" "crypto/rand"
"embed" "embed"
"encoding/base64" "encoding/base64"
"flag"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -68,6 +69,9 @@ const (
func main() { func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!") fmt.Println("Welcome to the Pangolin installer!")
@@ -206,7 +210,7 @@ func main() {
} }
} }
if !checkIsCrowdsecInstalledInCompose() { if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===") fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed // check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) { if readBool("Would you like to install CrowdSec?", false) {

View File

@@ -22,11 +22,11 @@
"componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.", "componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.",
"componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.",
"dismiss": "Verwerfen", "dismiss": "Verwerfen",
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.", "subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Standorte, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
"trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.", "trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.",
"trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.", "trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.",
"billingTrialBannerTitle": "Kostenlose Testversion aktiv", "billingTrialBannerTitle": "Kostenlose Testversion aktiv",
"billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Geschäftsstufe. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basisstufe zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.", "billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Business-Tarif. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basis-Tarif zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.",
"billingTrialBannerUpgrade": "Jetzt upgraden", "billingTrialBannerUpgrade": "Jetzt upgraden",
"billingTrialBadge": "Kostenlose Testversion", "billingTrialBadge": "Kostenlose Testversion",
"trialActive": "Kostenlose Testversion aktiv", "trialActive": "Kostenlose Testversion aktiv",
@@ -34,8 +34,8 @@
"trialHasEnded": "Ihre Testversion ist beendet.", "trialHasEnded": "Ihre Testversion ist beendet.",
"trialDaysRemaining": "{count, plural, one {# Tag übrig} other {# Tage übrig}}", "trialDaysRemaining": "{count, plural, one {# Tag übrig} other {# Tage übrig}}",
"trialDaysLeftShort": "Noch {days}d in der Testversion", "trialDaysLeftShort": "Noch {days}d in der Testversion",
"trialGoToBilling": "Zur Rechnungsseite gehen", "trialGoToBilling": "Zur Abrechnung gehen",
"subscriptionViolationViewBilling": "Rechnung anzeigen", "subscriptionViolationViewBilling": "Abrechnung anzeigen",
"componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.",
"componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!", "componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!",
"inviteErrorNotValid": "Es tut uns leid, aber es sieht so aus, als wäre die Einladung, auf die du zugreifen möchtest, entweder nicht angenommen worden oder nicht mehr gültig.", "inviteErrorNotValid": "Es tut uns leid, aber es sieht so aus, als wäre die Einladung, auf die du zugreifen möchtest, entweder nicht angenommen worden oder nicht mehr gültig.",
@@ -67,7 +67,7 @@
"edit": "Bearbeiten", "edit": "Bearbeiten",
"siteConfirmDelete": "Löschen des Standorts bestätigen", "siteConfirmDelete": "Löschen des Standorts bestätigen",
"siteDelete": "Standort löschen", "siteDelete": "Standort löschen",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird sie nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.", "siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.",
"siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?", "siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?",
"siteManageSites": "Standorte verwalten", "siteManageSites": "Standorte verwalten",
"siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen",
@@ -117,20 +117,20 @@
"siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren",
"siteSettingDescription": "Standorteinstellungen konfigurieren", "siteSettingDescription": "Standorteinstellungen konfigurieren",
"siteResourcesTab": "Ressourcen", "siteResourcesTab": "Ressourcen",
"siteResourcesNoneOnSite": "Diese Seite hat noch keine öffentlichen oder privaten Ressourcen.", "siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen",
"siteResourcesSectionPublic": "Öffentliche Ressourcen", "siteResourcesSectionPublic": "Öffentliche Ressourcen",
"siteResourcesSectionPrivate": "Private Ressourcen", "siteResourcesSectionPrivate": "Private Ressourcen",
"siteResourcesSectionPublicDescription": "Ressourcen, die extern über Domains oder Ports bereitgestellt werden.", "siteResourcesSectionPublicDescription": "Ressourcen, die extern über Domains oder Ports bereitgestellt werden.",
"siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über die Seite verfügbar sind.", "siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über den Standort verfügbar sind.",
"siteResourcesViewAllPublic": "Alle Ressourcen anzeigen", "siteResourcesViewAllPublic": "Alle Ressourcen anzeigen",
"siteResourcesViewAllPrivate": "Alle Ressourcen anzeigen", "siteResourcesViewAllPrivate": "Alle Ressourcen anzeigen",
"siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit dieser Seite verbunden sind.", "siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit diesem Standort verbunden sind.",
"siteResourcesShowMore": "Mehr anzeigen", "siteResourcesShowMore": "Mehr anzeigen",
"siteResourcesPermissionDenied": "Sie haben keine Berechtigung, diese Ressourcen aufzulisten.", "siteResourcesPermissionDenied": "Sie haben keine Berechtigung, diese Ressourcen aufzulisten.",
"siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diese Seite vorhanden.", "siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diesen Standort vorhanden.",
"siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit dieser Seite verbunden.", "siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit diesem Standort verbunden.",
"siteResourcesHowToAccess": "Zugriffsmöglichkeiten", "siteResourcesHowToAccess": "Zugriffsmöglichkeiten",
"siteResourcesTargetsOnSite": "Ziele auf dieser Seite", "siteResourcesTargetsOnSite": "Ziele an diesem Standort",
"siteSetting": "{siteName} Einstellungen", "siteSetting": "{siteName} Einstellungen",
"siteNewtTunnel": "Newt Standort (empfohlen)", "siteNewtTunnel": "Newt Standort (empfohlen)",
"siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.", "siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.",
@@ -148,10 +148,10 @@
"siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.", "siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.",
"siteInfo": "Standortinformationen", "siteInfo": "Standortinformationen",
"status": "Status", "status": "Status",
"shareTitle": "Links zum Teilen verwalten", "shareTitle": "Freigabelinks verwalten",
"shareDescription": "Erstelle teilbare Links, um temporären oder permanenten Zugriff auf Proxy-Ressourcen zu gewähren", "shareDescription": "Erstelle teilbare Links, um temporären oder permanenten Zugriff auf Proxy-Ressourcen zu gewähren",
"shareSearch": "Freigabe-Links suchen...", "shareSearch": "Freigabelinks suchen...",
"shareCreate": "Link erstellen", "shareCreate": "Freigabelink erstellen",
"shareErrorDelete": "Link konnte nicht gelöscht werden", "shareErrorDelete": "Link konnte nicht gelöscht werden",
"shareErrorDeleteMessage": "Fehler beim Löschen des Links", "shareErrorDeleteMessage": "Fehler beim Löschen des Links",
"shareDeleted": "Link gelöscht", "shareDeleted": "Link gelöscht",
@@ -161,7 +161,7 @@
"shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?", "shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?",
"shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.", "shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.",
"shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.", "shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.",
"accessToken": "Zugangs-Token", "accessToken": "Zugriffstoken",
"usageExamples": "Nutzungsbeispiele", "usageExamples": "Nutzungsbeispiele",
"tokenId": "Token-ID", "tokenId": "Token-ID",
"requestHeades": "Anfrage-Header", "requestHeades": "Anfrage-Header",
@@ -172,12 +172,12 @@
"shareTokenSecurety": "Bewahren Sie das Zugriffstoken sicher. Teilen Sie es nicht in öffentlich zugänglichen Bereichen oder Client-seitigem Code.", "shareTokenSecurety": "Bewahren Sie das Zugriffstoken sicher. Teilen Sie es nicht in öffentlich zugänglichen Bereichen oder Client-seitigem Code.",
"shareErrorFetchResource": "Fehler beim Abrufen der Ressourcen", "shareErrorFetchResource": "Fehler beim Abrufen der Ressourcen",
"shareErrorFetchResourceDescription": "Beim Abrufen der Ressourcen ist ein Fehler aufgetreten", "shareErrorFetchResourceDescription": "Beim Abrufen der Ressourcen ist ein Fehler aufgetreten",
"shareErrorCreate": "Fehler beim Erstellen des Teilen-Links", "shareErrorCreate": "Fehler beim Erstellen des Freigabelinks",
"shareErrorCreateDescription": "Beim Erstellen des Teilen-Links ist ein Fehler aufgetreten", "shareErrorCreateDescription": "Beim Erstellen des Freigabelinks ist ein Fehler aufgetreten",
"shareCreateDescription": "Jeder mit diesem Link kann auf die Ressource zugreifen", "shareCreateDescription": "Jeder mit diesem Link kann auf die Ressource zugreifen",
"shareTitleOptional": "Titel (optional)", "shareTitleOptional": "Titel (optional)",
"expireIn": "Verfällt in", "expireIn": "Läuft ab in",
"neverExpire": "Nie ablaufen", "neverExpire": "Läuft nie ab",
"shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.", "shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.",
"shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.", "shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.",
"shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.", "shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.",
@@ -186,7 +186,7 @@
"resourcesNotFound": "Keine Ressourcen gefunden", "resourcesNotFound": "Keine Ressourcen gefunden",
"resourceSearch": "Suche Ressourcen", "resourceSearch": "Suche Ressourcen",
"machineSearch": "Maschinen suchen", "machineSearch": "Maschinen suchen",
"machinesSearch": "Suche Maschinen-Klienten...", "machinesSearch": "Maschinen-Clients suchen",
"machineNotFound": "Keine Maschinen gefunden", "machineNotFound": "Keine Maschinen gefunden",
"userDeviceSearch": "Benutzergeräte durchsuchen", "userDeviceSearch": "Benutzergeräte durchsuchen",
"userDevicesSearch": "Benutzergeräte durchsuchen...", "userDevicesSearch": "Benutzergeräte durchsuchen...",
@@ -203,7 +203,7 @@
"proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.", "proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
"clientResourceTitle": "Private Ressourcen verwalten", "clientResourceTitle": "Private Ressourcen verwalten",
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind", "clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
"privateResourcesBannerTitle": "Zero-Trust Privater Zugang", "privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
"privateResourcesBannerDescription": "Private Ressourcen nutzen Zero-Trust und stellen sicher, dass Benutzer und Maschinen nur auf Ressourcen zugreifen können, die Sie explizit gewähren. Verbinden Sie Benutzergeräte oder Maschinen-Clients, um auf diese Ressourcen über ein sicheres virtuelles privates Netzwerk zuzugreifen.", "privateResourcesBannerDescription": "Private Ressourcen nutzen Zero-Trust und stellen sicher, dass Benutzer und Maschinen nur auf Ressourcen zugreifen können, die Sie explizit gewähren. Verbinden Sie Benutzergeräte oder Maschinen-Clients, um auf diese Ressourcen über ein sicheres virtuelles privates Netzwerk zuzugreifen.",
"resourcesSearch": "Suche Ressourcen...", "resourcesSearch": "Suche Ressourcen...",
"resourceAdd": "Ressource hinzufügen", "resourceAdd": "Ressource hinzufügen",
@@ -265,7 +265,7 @@
"rules": "Regeln", "rules": "Regeln",
"resourceSettingDescription": "Einstellungen für die Ressource konfigurieren", "resourceSettingDescription": "Einstellungen für die Ressource konfigurieren",
"resourceSetting": "{resourceName} Einstellungen", "resourceSetting": "{resourceName} Einstellungen",
"alwaysAllow": "Auth umgehen", "alwaysAllow": "Authentifizierung umgehen",
"alwaysDeny": "Zugriff blockieren", "alwaysDeny": "Zugriff blockieren",
"passToAuth": "Weiterleiten zur Authentifizierung", "passToAuth": "Weiterleiten zur Authentifizierung",
"orgSettingsDescription": "Organisationseinstellungen konfigurieren", "orgSettingsDescription": "Organisationseinstellungen konfigurieren",
@@ -274,7 +274,7 @@
"saveGeneralSettings": "Allgemeine Einstellungen speichern", "saveGeneralSettings": "Allgemeine Einstellungen speichern",
"saveSettings": "Einstellungen speichern", "saveSettings": "Einstellungen speichern",
"orgDangerZone": "Gefahrenzone", "orgDangerZone": "Gefahrenzone",
"orgDangerZoneDescription": "Sobald Sie diesen Org löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.", "orgDangerZoneDescription": "Sobald Sie diese Organisation löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.",
"orgDelete": "Organisation löschen", "orgDelete": "Organisation löschen",
"orgDeleteConfirm": "Organisation löschen bestätigen", "orgDeleteConfirm": "Organisation löschen bestätigen",
"orgMessageRemove": "Diese Aktion ist unwiderruflich und löscht alle zugehörigen Daten.", "orgMessageRemove": "Diese Aktion ist unwiderruflich und löscht alle zugehörigen Daten.",
@@ -323,7 +323,7 @@
"accessApprovalsManage": "Genehmigungen verwalten", "accessApprovalsManage": "Genehmigungen verwalten",
"accessApprovalsDescription": "Zeige und verwalte ausstehende Genehmigungen für den Zugriff auf diese Organisation", "accessApprovalsDescription": "Zeige und verwalte ausstehende Genehmigungen für den Zugriff auf diese Organisation",
"description": "Beschreibung", "description": "Beschreibung",
"inviteTitle": "Einladungen öffnen", "inviteTitle": "Offene Einladungen",
"inviteDescription": "Einladungen für andere Benutzer verwalten, der Organisation beizutreten", "inviteDescription": "Einladungen für andere Benutzer verwalten, der Organisation beizutreten",
"inviteSearch": "Einladungen suchen...", "inviteSearch": "Einladungen suchen...",
"minutes": "Minuten", "minutes": "Minuten",
@@ -370,12 +370,12 @@
"apiKeysDescription": "API-Schlüssel werden zur Authentifizierung mit der Integrations-API verwendet", "apiKeysDescription": "API-Schlüssel werden zur Authentifizierung mit der Integrations-API verwendet",
"provisioningKeysTitle": "Bereitstellungsschlüssel", "provisioningKeysTitle": "Bereitstellungsschlüssel",
"provisioningKeysManage": "Bereitstellungsschlüssel verwalten", "provisioningKeysManage": "Bereitstellungsschlüssel verwalten",
"provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Seiten für Ihr Unternehmen zu authentifizieren.", "provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Standorten für Ihr Unternehmen zu authentifizieren.",
"provisioningManage": "Bereitstellung", "provisioningManage": "Bereitstellung",
"provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Seiten prüfen, die noch auf Genehmigung warten.", "provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Standorte prüfen, die noch auf Genehmigung warten.",
"pendingSites": "Ausstehende Seiten", "pendingSites": "Ausstehende Standorte",
"siteApproveSuccess": "Site erfolgreich freigegeben", "siteApproveSuccess": "Standort erfolgreich freigegeben",
"siteApproveError": "Fehler beim Bestätigen der Seite", "siteApproveError": "Fehler beim Genehmigen des Standorts",
"provisioningKeys": "Bereitstellungsschlüssel", "provisioningKeys": "Bereitstellungsschlüssel",
"searchProvisioningKeys": "Bereitstellungsschlüssel suchen...", "searchProvisioningKeys": "Bereitstellungsschlüssel suchen...",
"provisioningKeysAdd": "Bereitstellungsschlüssel generieren", "provisioningKeysAdd": "Bereitstellungsschlüssel generieren",
@@ -405,7 +405,7 @@
"provisioningKeysNeverUsed": "Nie", "provisioningKeysNeverUsed": "Nie",
"provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten", "provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten",
"provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.", "provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.",
"provisioningKeysApproveNewSites": "Neue Seiten genehmigen", "provisioningKeysApproveNewSites": "Neuen Standort genehmigen",
"provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.", "provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.",
"provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels", "provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels",
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert", "provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
@@ -413,8 +413,8 @@
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel", "provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.", "provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.",
"provisioningKeysBannerButtonText": "Mehr erfahren", "provisioningKeysBannerButtonText": "Mehr erfahren",
"pendingSitesBannerTitle": "Ausstehende Seiten", "pendingSitesBannerTitle": "Ausstehende Standorte",
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.", "pendingSitesBannerDescription": "Standorte, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
"pendingSitesBannerButtonText": "Mehr erfahren", "pendingSitesBannerButtonText": "Mehr erfahren",
"apiKeysSettings": "{apiKeyName} Einstellungen", "apiKeysSettings": "{apiKeyName} Einstellungen",
"userTitle": "Alle Benutzer verwalten", "userTitle": "Alle Benutzer verwalten",
@@ -461,7 +461,7 @@
"licenseActivateKeyDescription": "Geben Sie einen Lizenzschlüssel ein, um ihn zu aktivieren.", "licenseActivateKeyDescription": "Geben Sie einen Lizenzschlüssel ein, um ihn zu aktivieren.",
"licenseActivate": "Lizenz aktivieren", "licenseActivate": "Lizenz aktivieren",
"licenseAgreement": "Durch Ankreuzung dieses Kästchens bestätigen Sie, dass Sie die Lizenzbedingungen gelesen und akzeptiert haben, die mit dem Lizenzschlüssel in Verbindung stehen.", "licenseAgreement": "Durch Ankreuzung dieses Kästchens bestätigen Sie, dass Sie die Lizenzbedingungen gelesen und akzeptiert haben, die mit dem Lizenzschlüssel in Verbindung stehen.",
"fossorialLicense": "Fossorial Gewerbelizenz & Abonnementbedingungen anzeigen", "fossorialLicense": "Kommerzielle Fossorial-Lizenz und Abonnementbedingungen anzeigen",
"licenseMessageRemove": "Dadurch werden der Lizenzschlüssel und alle zugehörigen Berechtigungen entfernt.", "licenseMessageRemove": "Dadurch werden der Lizenzschlüssel und alle zugehörigen Berechtigungen entfernt.",
"licenseMessageConfirm": "Um zu bestätigen, geben Sie bitte den Lizenzschlüssel unten ein.", "licenseMessageConfirm": "Um zu bestätigen, geben Sie bitte den Lizenzschlüssel unten ein.",
"licenseQuestionRemove": "Sind Sie sicher, dass Sie den Lizenzschlüssel löschen möchten?", "licenseQuestionRemove": "Sind Sie sicher, dass Sie den Lizenzschlüssel löschen möchten?",
@@ -481,7 +481,7 @@
"licensePurchaseSites": "Zusätzliche Standorte kaufen\n", "licensePurchaseSites": "Zusätzliche Standorte kaufen\n",
"licenseSitesUsedMax": "{usedSites} von {maxSites} Standorten verwendet", "licenseSitesUsedMax": "{usedSites} von {maxSites} Standorten verwendet",
"licenseSitesUsed": "{count, plural, =0 {# Standorte} one {# Standort} other {# Standorte}} im System.", "licenseSitesUsed": "{count, plural, =0 {# Standorte} one {# Standort} other {# Standorte}} im System.",
"licensePurchaseDescription": "Wähle aus, für wieviele Seiten du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Seiten hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}", "licensePurchaseDescription": "Wähle aus, für wie viele Standorte du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Standorte hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}",
"licenseFee": "Lizenzgebühr", "licenseFee": "Lizenzgebühr",
"licensePriceSite": "Preis pro Standort", "licensePriceSite": "Preis pro Standort",
"total": "Gesamt", "total": "Gesamt",
@@ -532,7 +532,7 @@
"userRemoveOrgConfirmSelf": "Entfernung bestätigen", "userRemoveOrgConfirmSelf": "Entfernung bestätigen",
"userRemoveOrgSelf": "Sich selbst aus der Organisation entfernen", "userRemoveOrgSelf": "Sich selbst aus der Organisation entfernen",
"userRemoveOrgSelfWarning": "Sie verlieren sofort den Zugriff auf diese Organisation.", "userRemoveOrgSelfWarning": "Sie verlieren sofort den Zugriff auf diese Organisation.",
"userRemoveOrgConfirmPhraseSelf": "ENTFERNUNG MICH SELBST AUS DER ORGANISATION", "userRemoveOrgConfirmPhraseSelf": "MICH SELBST AUS DER ORGANISATION ENTFERNEN",
"users": "Benutzer", "users": "Benutzer",
"accessRoleMember": "Mitglied", "accessRoleMember": "Mitglied",
"accessRoleOwner": "Eigentümer", "accessRoleOwner": "Eigentümer",
@@ -1711,11 +1711,11 @@
"regionSelectorComingSoon": "Kommt bald", "regionSelectorComingSoon": "Kommt bald",
"billingLoadingSubscription": "Abonnement wird geladen...", "billingLoadingSubscription": "Abonnement wird geladen...",
"billingFreeTier": "Kostenlose Stufe", "billingFreeTier": "Kostenlose Stufe",
"billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Webseiten werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.", "billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Standorte werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.",
"billingUsageLimitsOverview": "Übersicht über Nutzungsgrenzen", "billingUsageLimitsOverview": "Übersicht über Nutzungsgrenzen",
"billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@pangolin.net.", "billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@pangolin.net.",
"billingDataUsage": "Datenverbrauch", "billingDataUsage": "Datenverbrauch",
"billingSites": "Seiten", "billingSites": "Standorte",
"billingUsers": "Benutzergeräte", "billingUsers": "Benutzergeräte",
"billingDomains": "Domänen", "billingDomains": "Domänen",
"billingOrganizations": "Orden", "billingOrganizations": "Orden",
@@ -1743,7 +1743,7 @@
"billingCheckoutError": "Checkout-Fehler", "billingCheckoutError": "Checkout-Fehler",
"billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL", "billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL",
"billingPortalError": "Portalfehler", "billingPortalError": "Portalfehler",
"billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Websites ein. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.", "billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Standorte ein. Wenn Sie Ihr Limit erreichen, werden Ihre Standorte die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.",
"billingSInfo": "Anzahl der Sites die Sie verwenden können", "billingSInfo": "Anzahl der Sites die Sie verwenden können",
"billingUsersInfo": "Wie viele Benutzer Sie verwenden können", "billingUsersInfo": "Wie viele Benutzer Sie verwenden können",
"billingDomainInfo": "Wie viele Domains Sie verwenden können", "billingDomainInfo": "Wie viele Domains Sie verwenden können",
@@ -1927,7 +1927,7 @@
"configureHealthCheck": "Gesundheits-Check konfigurieren", "configureHealthCheck": "Gesundheits-Check konfigurieren",
"configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein", "configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein",
"enableHealthChecks": "Gesundheits-Checks aktivieren", "enableHealthChecks": "Gesundheits-Checks aktivieren",
"healthCheckDisabledStateDescription": "Wenn deaktiviert, führt die Seite keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.", "healthCheckDisabledStateDescription": "Wenn deaktiviert, führt der Standort keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.",
"enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.", "enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.",
"healthScheme": "Methode", "healthScheme": "Methode",
"healthSelectScheme": "Methode auswählen", "healthSelectScheme": "Methode auswählen",
@@ -2187,8 +2187,8 @@
} }
}, },
"remoteExitNodeSelection": "Knotenauswahl", "remoteExitNodeSelection": "Knotenauswahl",
"remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diese lokale Seite geleitet werden soll", "remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diesen lokalen Standort geleitet werden soll",
"remoteExitNodeRequired": "Ein Knoten muss für lokale Seiten ausgewählt sein", "remoteExitNodeRequired": "Ein Knoten muss für lokale Standorte ausgewählt sein",
"noRemoteExitNodesAvailable": "Keine Knoten verfügbar", "noRemoteExitNodesAvailable": "Keine Knoten verfügbar",
"noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Standorte zu verwenden.", "noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Standorte zu verwenden.",
"exitNode": "Exit-Node", "exitNode": "Exit-Node",
@@ -3235,7 +3235,7 @@
"uptimeAddAlert": "Warnmeldung hinzufügen", "uptimeAddAlert": "Warnmeldung hinzufügen",
"uptimeViewAlerts": "Warnungen anzeigen", "uptimeViewAlerts": "Warnungen anzeigen",
"uptimeCreateEmailAlert": "E-Mail Alarm erstellen", "uptimeCreateEmailAlert": "E-Mail Alarm erstellen",
"uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn diese Seite offline oder wieder online ist.", "uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn dieser Standort offline oder wieder online ist.",
"uptimeAlertDescriptionResource": "Werde per E-Mail benachrichtigt, wenn diese Ressource offline oder wieder online ist.", "uptimeAlertDescriptionResource": "Werde per E-Mail benachrichtigt, wenn diese Ressource offline oder wieder online ist.",
"uptimeAlertNamePlaceholder": "Alarmname", "uptimeAlertNamePlaceholder": "Alarmname",
"uptimeAdditionalEmails": "Zusätzliche E-Mails", "uptimeAdditionalEmails": "Zusätzliche E-Mails",

810
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -131,22 +131,22 @@
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.54.1", "@dotenvx/dotenvx": "1.66.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.10", "@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-devtools": "5.100.11",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2", "@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3", "@types/d3": "7.4.3",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/express-session": "1.18.2", "@types/express-session": "1.19.0",
"@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.3.5", "@types/node": "25.9.1",
"@types/nodemailer": "7.0.11", "@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.18.0", "@types/pg": "8.18.0",
@@ -160,21 +160,21 @@
"@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.10", "drizzle-kit": "0.31.10",
"esbuild": "0.27.4", "esbuild": "0.28.0",
"esbuild-node-externals": "1.20.1", "esbuild-node-externals": "1.22.0",
"eslint": "10.0.3", "eslint": "10.4.0",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.2.6",
"postcss": "8.5.8", "postcss": "8.5.8",
"prettier": "3.8.1", "prettier": "3.8.1",
"react-email": "5.2.10", "react-email": "5.2.10",
"tailwindcss": "4.2.2", "tailwindcss": "4.3.0",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.21.0", "tsx": "4.22.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.56.1" "typescript-eslint": "8.59.4"
}, },
"overrides": { "overrides": {
"esbuild": "0.27.4", "esbuild": "0.28.0",
"dompurify": "3.3.2" "dompurify": "3.3.2"
} }
} }

View File

@@ -221,10 +221,18 @@ async function handleResource(
) )
.where(eq(targets.resourceId, resource.resourceId)); .where(eq(targets.resourceId, resource.resourceId));
const monitoredTargets = otherTargets.filter(
(t) => t.hcHealth !== "unknown"
);
let health = "healthy"; let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); const allUnknown = monitoredTargets.length === 0;
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); const allHealthy = monitoredTargets.every(
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); (t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
if (allUnknown) { if (allUnknown) {
logger.debug( logger.debug(

View File

@@ -82,7 +82,7 @@ export const RuleSchema = z
.object({ .object({
action: z.enum(["allow", "deny", "pass"]), action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(), value: z.coerce.string(),
priority: z.int().optional() priority: z.int().optional()
}) })
.refine( .refine(
@@ -340,7 +340,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label)); return parts.slice(1).every((label) => labelRegex.test(label));
}, },
{ {

View File

@@ -18,11 +18,9 @@ import {
userOrgRoles, userOrgRoles,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, count, eq, inArray, ne } from "drizzle-orm";
import { import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers";
import { import {
initPeerAddHandshake, initPeerAddHandshake,
deletePeer as olmDeletePeer deletePeer as olmDeletePeer
@@ -33,7 +31,7 @@ import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
generateSubnetProxyTargetV2, generateSubnetProxyTargetV2,
parseEndpoint, parseEndpoint
} from "@server/lib/ip"; } from "@server/lib/ip";
import { import {
addPeerData, addPeerData,
@@ -41,6 +39,11 @@ import {
removePeerData, removePeerData,
removeTargets as removeSubnetProxyTargets removeTargets as removeSubnetProxyTargets
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
// TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
export async function getClientSiteResourceAccess( export async function getClientSiteResourceAccess(
siteResource: SiteResource, siteResource: SiteResource,
@@ -51,10 +54,7 @@ export async function getClientSiteResourceAccess(
? await trx ? await trx
.select() .select()
.from(sites) .from(sites)
.innerJoin( .innerJoin(siteNetworks, eq(siteNetworks.siteId, sites.siteId))
siteNetworks,
eq(siteNetworks.siteId, sites.siteId)
)
.where(eq(siteNetworks.networkId, siteResource.networkId)) .where(eq(siteNetworks.networkId, siteResource.networkId))
.then((rows) => rows.map((row) => row.sites)) .then((rows) => rows.map((row) => row.sites))
: []; : [];
@@ -166,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
pubKey: string | null; pubKey: string | null;
subnet: string | null; subnet: string | null;
}[]; }[];
}> {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromSiteResourceImpl(
siteResource: SiteResource,
trx: Transaction | typeof db = db
): Promise<{
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> { }> {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
@@ -362,7 +379,8 @@ export async function rebuildClientAssociationsFromSiteResource(
.where(inArray(clients.clientId, existingClientSiteIds)) .where(inArray(clients.clientId, existingClientSiteIds))
: []; : [];
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>(); const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
@@ -543,6 +561,29 @@ async function handleMessagesForSiteClients(
} }
} }
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
const clientSiteCounts: Record<number, number> = {};
if (clientsToProcess.size > 0) {
const clientIdsToProcess = Array.from(clientsToProcess.keys());
const siteCounts = await trx
.select({
clientId: clientSitesAssociationsCache.clientId,
siteCount: count(clientSitesAssociationsCache.siteId)
})
.from(clientSitesAssociationsCache)
.where(
inArray(
clientSitesAssociationsCache.clientId,
clientIdsToProcess
)
)
.groupBy(clientSitesAssociationsCache.clientId);
for (const row of siteCounts) {
clientSiteCounts[row.clientId] = Number(row.siteCount);
}
}
for (const client of clientsToProcess.values()) { for (const client of clientsToProcess.values()) {
// UPDATE THE NEWT // UPDATE THE NEWT
if (!client.subnet || !client.pubKey) { if (!client.subnet || !client.pubKey) {
@@ -586,7 +627,14 @@ async function handleMessagesForSiteClients(
} }
if (isAdd) { if (isAdd) {
// TODO: if we are in jit mode here should we really be sending this? if (clientSiteCounts[client.clientId] > 250) {
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
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,
@@ -604,9 +652,24 @@ async function handleMessagesForSiteClients(
exitNodeJobs.push(updateClientSiteDestinations(client, trx)); exitNodeJobs.push(updateClientSiteDestinations(client, trx));
} }
await Promise.all(exitNodeJobs); Promise.all(exitNodeJobs).catch((error) => {
await Promise.all(newtJobs); // do the servers first to make sure they are ready? logger.error(
await Promise.all(olmJobs); `rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
error
);
});
} }
interface PeerDestination { interface PeerDestination {
@@ -709,7 +772,7 @@ export async function updateClientSiteDestinations(
sourcePort: destination.sourcePort, sourcePort: destination.sourcePort,
destinations: destination.destinations destinations: destination.destinations
}; };
logger.info( logger.debug(
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
); );
@@ -889,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
export async function rebuildClientAssociationsFromClient( export async function rebuildClientAssociationsFromClient(
client: Client, client: Client,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> {
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromClientImpl(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
let newSiteResourceIds: number[] = []; let newSiteResourceIds: number[] = [];
@@ -1161,6 +1235,12 @@ async function handleMessagesForClientSites(
const olmJobs: Promise<any>[] = []; const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = []; const exitNodeJobs: Promise<any>[] = [];
const totalSitesOnClient = await trx
.select({ count: count(clientSitesAssociationsCache.siteId) })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
.then((rows) => Number(rows[0].count));
for (const siteData of sitesData) { for (const siteData of sitesData) {
const site = siteData.sites; const site = siteData.sites;
const exitNode = siteData.exitNodes; const exitNode = siteData.exitNodes;
@@ -1221,7 +1301,14 @@ async function handleMessagesForClientSites(
continue; continue;
} }
// TODO: if we are in jit mode here should we really be sending this? if (totalSitesOnClient > 250) {
// skip adding the site if we have more than 250 because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
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,
@@ -1249,9 +1336,24 @@ async function handleMessagesForClientSites(
); );
} }
await Promise.all(exitNodeJobs); Promise.all(exitNodeJobs).catch((error) => {
await Promise.all(newtJobs); logger.error(
await Promise.all(olmJobs); `rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
error
);
});
} }
async function handleMessagesForClientResources( async function handleMessagesForClientResources(
@@ -1532,3 +1634,195 @@ async function handleMessagesForClientResources(
await Promise.all([...proxyJobs, ...olmJobs]); await Promise.all([...proxyJobs, ...olmJobs]);
} }
export type ClientAssociationsCacheVerification = {
clientId: number;
consistent: boolean;
// What permissions say the cache should contain
expectedSiteResourceIds: number[];
expectedSiteIds: number[];
// What the cache currently contains
actualSiteResourceIds: number[];
actualSiteIds: number[];
// Diff
missingSiteResourceIds: number[]; // present in expected, missing from cache
extraSiteResourceIds: number[]; // present in cache, not in expected
missingSiteIds: number[];
extraSiteIds: number[];
};
// verifyClientAssociationsCache walks the same permission-derivation logic as
// rebuildClientAssociationsFromClient but does NOT modify the database. It
// returns the expected vs actual cache contents and a boolean indicating
// whether the cache is in sync with what permissions imply.
export async function verifyClientAssociationsCache(
client: Client,
trx: Transaction | typeof db = db
): Promise<ClientAssociationsCacheVerification> {
let newSiteResourceIds: number[] = [];
// 1. Direct client associations
const directSiteResources = await trx
.select({ siteResourceId: clientSiteResources.siteResourceId })
.from(clientSiteResources)
.innerJoin(
siteResources,
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
)
.where(
and(
eq(clientSiteResources.clientId, client.clientId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...directSiteResources.map((r) => r.siteResourceId)
);
// 2. User-based and role-based access (if client has a userId)
if (client.userId) {
const userSiteResourceIds = await trx
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
userSiteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, client.userId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...userSiteResourceIds.map((r) => r.siteResourceId)
);
const roleIds = await trx
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, client.userId),
eq(userOrgRoles.orgId, client.orgId)
)
)
.then((rows) => rows.map((row) => row.roleId));
if (roleIds.length > 0) {
const roleSiteResourceIds = await trx
.select({ siteResourceId: roleSiteResources.siteResourceId })
.from(roleSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
roleSiteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, roleIds),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...roleSiteResourceIds.map((r) => r.siteResourceId)
);
}
}
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
const newSiteResources =
newSiteResourceIds.length > 0
? await trx
.select()
.from(siteResources)
.where(
inArray(siteResources.siteResourceId, newSiteResourceIds)
)
: [];
const networkIds = Array.from(
new Set(
newSiteResources
.map((sr) => sr.networkId)
.filter((id): id is number => id !== null)
)
);
const newSiteIds =
networkIds.length > 0
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, networkIds))
.then((rows) =>
Array.from(new Set(rows.map((r) => r.siteId)))
)
: [];
// Read the existing cache state
const existingResourceAssociations = await trx
.select({
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const existingSiteResourceIds = existingResourceAssociations.map(
(r) => r.siteResourceId
);
const existingSiteAssociations = await trx
.select({ siteId: clientSitesAssociationsCache.siteId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
const expectedSiteResourceSet = new Set(newSiteResourceIds);
const actualSiteResourceSet = new Set(existingSiteResourceIds);
const expectedSiteSet = new Set(newSiteIds);
const actualSiteSet = new Set(existingSiteIds);
const missingSiteResourceIds = newSiteResourceIds.filter(
(id) => !actualSiteResourceSet.has(id)
);
const extraSiteResourceIds = existingSiteResourceIds.filter(
(id) => !expectedSiteResourceSet.has(id)
);
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
const extraSiteIds = existingSiteIds.filter(
(id) => !expectedSiteSet.has(id)
);
const consistent =
missingSiteResourceIds.length === 0 &&
extraSiteResourceIds.length === 0 &&
missingSiteIds.length === 0 &&
extraSiteIds.length === 0;
return {
clientId: client.clientId,
consistent,
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
(a, b) => a - b
),
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
(a, b) => a - b
),
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
};
}

View File

@@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as client from "@server/routers/client";
import { import {
verifyOrgAccess, verifyOrgAccess,
@@ -775,3 +776,15 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getTarget), verifyUserHasAction(ActionsEnum.getTarget),
healthChecks.getHealthCheckStatusHistory healthChecks.getHealthCheckStatusHistory
); );
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);

View File

@@ -26,7 +26,6 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm"; import { eq, InferInsertModel } from "drizzle-orm";
import { build } from "@server/build"; import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import config from "#private/lib/config"; import config from "#private/lib/config";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
logoUrl: z logoUrl: z
.union([ .string()
z.literal(""), .optional()
z .transform((val) => (val === "" ? null : val)),
.string()
.superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
])
.transform((val) => (val === "" ? null : val))
.nullish(),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(), resourceTitle: z.string(),

View File

@@ -19,6 +19,7 @@ import {
logsDb, logsDb,
newts, newts,
roles, roles,
roleSiteResources,
roundTripMessageTracker, roundTripMessageTracker,
siteResources, siteResources,
siteNetworks, siteNetworks,
@@ -361,9 +362,26 @@ export async function signSshKey(
} }
const roleRows = await db const roleRows = await db
.select() .select({
sshSudoCommands: roles.sshSudoCommands,
sshUnixGroups: roles.sshUnixGroups,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshSudoMode: roles.sshSudoMode
})
.from(roles) .from(roles)
.where(inArray(roles.roleId, roleIds)); .innerJoin(
roleSiteResources,
eq(roleSiteResources.roleId, roles.roleId)
)
.where(
and(
inArray(roles.roleId, roleIds),
eq(
roleSiteResources.siteResourceId,
resource.siteResourceId
)
)
);
const parsedSudoCommands: string[] = []; const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>(); const parsedGroupsSet = new Set<string>();
@@ -379,13 +397,17 @@ export async function signSshKey(
} }
try { try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g)); if (Array.isArray(grps))
grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch { } catch {
// skip // skip
} }
if (roleRow?.sshCreateHomeDir === true) homedir = true; if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none"; const m = roleRow?.sshSudoMode ?? "none";
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { if (
sudoModeOrder[m as keyof typeof sudoModeOrder] >
sudoModeOrder[sudoMode]
) {
sudoMode = m as "none" | "commands" | "full"; sudoMode = m as "none" | "commands" | "full";
} }
} }

View File

@@ -10,3 +10,5 @@ export * from "./listUserDevices";
export * from "./updateClient"; export * from "./updateClient";
export * from "./getClient"; export * from "./getClient";
export * from "./createUserClient"; export * from "./createUserClient";
export * from "./verifyClientAssociationsCache";
export * from "./rebuildClientAssociationsCacheRoute";

View File

@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients } 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";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "post",
path: "/client/{clientId}/rebuild-associations-cache",
description:
"Rebuild the client's site/site-resource association cache based on current permissions.",
tags: [OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function rebuildClientAssociationsCacheRoute(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { clientId } = parsedParams.data;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found`
)
);
}
await rebuildClientAssociationsFromClient(client);
return response(res, {
data: null,
success: true,
error: false,
message: "Client association cache rebuilt successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to rebuild client association cache"
)
);
}
}

View File

@@ -0,0 +1,83 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients } 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";
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "get",
path: "/client/{clientId}/verify-associations-cache",
description:
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
tags: [OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function verifyClientAssociationsCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { clientId } = parsedParams.data;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found`
)
);
}
const report = await verifyClientAssociationsCacheLib(client);
return response(res, {
data: report,
success: true,
error: false,
message: report.consistent
? "Client association cache is consistent"
: "Client association cache is INCONSISTENT",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify client association cache"
)
);
}
}

View File

@@ -11,7 +11,7 @@ import {
ExitNode ExitNode
} from "@server/db"; } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; 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";
@@ -97,86 +97,119 @@ export async function generateRelayMappings(exitNode: ExitNode) {
return {}; return {};
} }
// Filter to sites with the required fields up front so the rest of the
// function can safely treat endpoint/subnet/listenPort as defined.
const validSites = sitesRes.filter(
(s) => s.endpoint && s.subnet && s.listenPort
);
if (validSites.length === 0) {
return {};
}
const siteIds = validSites.map((s) => s.siteId);
const orgIds = Array.from(
new Set(
validSites
.map((s) => s.orgId)
.filter((id): id is NonNullable<typeof id> => id != null)
)
);
// Batch fetch all client-site associations for these sites in one query.
const clientSitesRes = siteIds.length
? await db
.select()
.from(clientSitesAssociationsCache)
.where(inArray(clientSitesAssociationsCache.siteId, siteIds))
: [];
// Batch fetch all sites in the relevant orgs in one query (covers
// site-to-site communication for every site processed below).
const orgSitesRes = orgIds.length
? await db.select().from(sites).where(inArray(sites.orgId, orgIds))
: [];
// Index org sites by orgId for O(1) lookup per site.
const sitesByOrg = new Map<string, typeof orgSitesRes>();
for (const peer of orgSitesRes) {
if (
peer.orgId == null ||
!peer.endpoint ||
!peer.subnet ||
!peer.listenPort
) {
continue;
}
let arr = sitesByOrg.get(peer.orgId);
if (!arr) {
arr = [];
sitesByOrg.set(peer.orgId, arr);
}
arr.push(peer);
}
// Index client-site associations by siteId for O(1) lookup per site.
const clientSitesBySite = new Map<number, typeof clientSitesRes>();
for (const cs of clientSitesRes) {
let arr = clientSitesBySite.get(cs.siteId);
if (!arr) {
arr = [];
clientSitesBySite.set(cs.siteId, arr);
}
arr.push(cs);
}
// Initialize mappings object for multi-peer support // Initialize mappings object for multi-peer support
const mappings: { [key: string]: ProxyMapping } = {}; const mappings: { [key: string]: ProxyMapping } = {};
// Process each site // Track destinations per endpoint to deduplicate in O(1).
for (const site of sitesRes) { const seen = new Map<string, Set<string>>();
if (!site.endpoint || !site.subnet || !site.listenPort) {
continue; const addDestination = (endpoint: string, dest: PeerDestination) => {
let destSet = seen.get(endpoint);
if (!destSet) {
destSet = new Set();
seen.set(endpoint, destSet);
mappings[endpoint] = { destinations: [] };
} }
const key = `${dest.destinationIP}:${dest.destinationPort}`;
// Find all clients associated with this site through clientSites if (!destSet.has(key)) {
const clientSitesRes = await db destSet.add(key);
.select() mappings[endpoint].destinations.push(dest);
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, site.siteId));
for (const clientSite of clientSitesRes) {
if (!clientSite.endpoint) {
continue;
}
// Add this site as a destination for the client
if (!mappings[clientSite.endpoint]) {
mappings[clientSite.endpoint] = { destinations: [] };
}
// Add site as a destination for this client
const destination: PeerDestination = {
destinationIP: site.subnet.split("/")[0],
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
const isDuplicate = mappings[clientSite.endpoint].destinations.some(
(dest) =>
dest.destinationIP === destination.destinationIP &&
dest.destinationPort === destination.destinationPort
);
if (!isDuplicate) {
mappings[clientSite.endpoint].destinations.push(destination);
}
} }
};
// Also handle site-to-site communication (all sites in the same org) // Process each site using the pre-fetched data.
if (site.orgId) { for (const site of validSites) {
const orgSites = await db const siteDestination: PeerDestination = {
.select() destinationIP: site.subnet!.split("/")[0],
.from(sites) destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
.where(eq(sites.orgId, site.orgId)); };
for (const peer of orgSites) { // Add this site as a destination for each associated client.
// Skip self const clientSites = clientSitesBySite.get(site.siteId);
if ( if (clientSites) {
peer.siteId === site.siteId || for (const clientSite of clientSites) {
!peer.endpoint || if (!clientSite.endpoint) {
!peer.subnet ||
!peer.listenPort
) {
continue; continue;
} }
addDestination(clientSite.endpoint, siteDestination);
}
}
// Add peer site as a destination for this site // Site-to-site communication (all sites in the same org).
if (!mappings[site.endpoint]) { if (site.orgId != null) {
mappings[site.endpoint] = { destinations: [] }; const peers = sitesByOrg.get(site.orgId);
} if (peers) {
for (const peer of peers) {
const destination: PeerDestination = { if (peer.siteId === site.siteId) {
destinationIP: peer.subnet.split("/")[0], continue;
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }
}; addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0],
// Check for duplicates destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
const isDuplicate = mappings[site.endpoint].destinations.some( });
(dest) =>
dest.destinationIP === destination.destinationIP &&
dest.destinationPort === destination.destinationPort
);
if (!isDuplicate) {
mappings[site.endpoint].destinations.push(destination);
} }
} }
} }

View File

@@ -11,7 +11,7 @@ import {
ExitNode ExitNode
} from "@server/db"; } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; 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";
@@ -185,16 +185,20 @@ export async function updateAndGenerateEndpointDestinations(
const sitesOnExitNode = await db const sitesOnExitNode = await db
.select({ .select({
siteId: sites.siteId, siteId: sites.siteId,
newtId: newts.newtId,
subnet: sites.subnet, subnet: sites.subnet,
listenPort: sites.listenPort, listenPort: sites.listenPort,
publicKey: sites.publicKey, publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint endpoint: clientSitesAssociationsCache.endpoint,
isRelayed: clientSitesAssociationsCache.isRelayed,
isJitMode: clientSitesAssociationsCache.isJitMode
}) })
.from(sites) .from(sites)
.innerJoin( .innerJoin(
clientSitesAssociationsCache, clientSitesAssociationsCache,
eq(sites.siteId, clientSitesAssociationsCache.siteId) eq(sites.siteId, clientSitesAssociationsCache.siteId)
) )
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where( .where(
and( and(
eq(sites.exitNodeId, exitNode.exitNodeId), eq(sites.exitNodeId, exitNode.exitNodeId),
@@ -202,24 +206,36 @@ export async function updateAndGenerateEndpointDestinations(
) )
); );
// Update clientSites for each site on this exit node // Format the endpoint properly for both IPv4 and IPv6
const formattedEndpoint = formatEndpoint(ip, port);
// Determine which rows actually need updating and whether the endpoint
// (as opposed to only the publicKey) changed for any of them.
const siteIdsToUpdate: number[] = [];
const sitesWithNewtsToUpdate: { siteId: number; newtId: string }[] = [];
let endpointChanged = false;
for (const site of sitesOnExitNode) { for (const site of sitesOnExitNode) {
// logger.debug(
// `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}`
// );
// Format the endpoint properly for both IPv4 and IPv6
const formattedEndpoint = formatEndpoint(ip, port);
// if the public key or endpoint has changed, update it otherwise continue
if ( if (
site.endpoint === formattedEndpoint && site.endpoint === formattedEndpoint &&
site.publicKey === publicKey site.publicKey === publicKey
) { ) {
continue; continue;
} }
siteIdsToUpdate.push(site.siteId);
if (!site.isRelayed && !site.isJitMode) {
sitesWithNewtsToUpdate.push({
siteId: site.siteId,
newtId: site.newtId
});
}
if (site.endpoint !== formattedEndpoint) {
endpointChanged = true;
}
}
const [updatedClientSitesAssociationsCache] = await db if (siteIdsToUpdate.length > 0) {
// Single bulk update for all affected rows for this client on this exit node
await db
.update(clientSitesAssociationsCache) .update(clientSitesAssociationsCache)
.set({ .set({
endpoint: formattedEndpoint, endpoint: formattedEndpoint,
@@ -228,24 +244,30 @@ export async function updateAndGenerateEndpointDestinations(
.where( .where(
and( and(
eq(clientSitesAssociationsCache.clientId, olm.clientId), eq(clientSitesAssociationsCache.clientId, olm.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId) inArray(
clientSitesAssociationsCache.siteId,
siteIdsToUpdate
)
) )
) );
.returning();
if ( // Only trigger downstream peer updates once per hole punch: the
updatedClientSitesAssociationsCache.endpoint !== // endpoint is the same for every site on this exit node, and
site.endpoint && // this is the endpoint from the join table not the site // handleClientEndpointChange already fans out to all connected
updatedClient.pubKey === publicKey // only trigger if the client's public key matches the current public key which means it has registered so we dont prematurely send the update // sites for this client.
) { if (endpointChanged && updatedClient.pubKey === publicKey) {
logger.info( logger.info(
`ClientSitesAssociationsCache for client ${olm.clientId} and site ${site.siteId} endpoint changed from ${site.endpoint} to ${updatedClientSitesAssociationsCache.endpoint}` `ClientSitesAssociationsCache for client ${olm.clientId} endpoint changed to ${formattedEndpoint} for ${siteIdsToUpdate.length} site(s) on exit node ${exitNode.exitNodeId}`
); );
// Handle any additional logic for endpoint change
handleClientEndpointChange( handleClientEndpointChange(
sitesWithNewtsToUpdate,
olm.clientId, olm.clientId,
updatedClientSitesAssociationsCache.endpoint! formattedEndpoint
); ).catch((error) => {
logger.error(
`Failed to handle client endpoint change for client ${olm.clientId}: ${error}`
);
});
} }
} }
@@ -336,59 +358,14 @@ export async function updateAndGenerateEndpointDestinations(
`Site ${newt.siteId} endpoint changed from ${site.endpoint} to ${updatedSite.endpoint}` `Site ${newt.siteId} endpoint changed from ${site.endpoint} to ${updatedSite.endpoint}`
); );
// Handle any additional logic for endpoint change // Handle any additional logic for endpoint change
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!); handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!).catch(
(error) => {
logger.error(
`Failed to handle site endpoint change for site ${newt.siteId}: ${error}`
);
}
);
} }
// if (!updatedSite || !updatedSite.subnet) {
// logger.warn(`Site not found: ${newt.siteId}`);
// throw new Error("Site not found");
// }
// Find all clients that connect to this site
// const sitesClientPairs = await db
// .select()
// .from(clientSites)
// .where(eq(clientSites.siteId, newt.siteId));
// THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING
// Get client details for each client
// for (const pair of sitesClientPairs) {
// const [client] = await db
// .select()
// .from(clients)
// .where(eq(clients.clientId, pair.clientId));
// if (client && client.endpoint) {
// const [host, portStr] = client.endpoint.split(':');
// if (host && portStr) {
// destinations.push({
// destinationIP: host,
// destinationPort: parseInt(portStr, 10)
// });
// }
// }
// }
// If this is a newt/site, also add other sites in the same org
// if (updatedSite.orgId) {
// const orgSites = await db
// .select()
// .from(sites)
// .where(eq(sites.orgId, updatedSite.orgId));
// for (const site of orgSites) {
// // Don't add the current site to the destinations
// if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) {
// const [host, portStr] = site.endpoint.split(':');
// if (host && portStr) {
// destinations.push({
// destinationIP: host,
// destinationPort: site.listenPort
// });
// }
// }
// }
// }
} }
return destinations; return destinations;
} }
@@ -408,12 +385,14 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
return; return;
} }
// Get all non-relayed clients connected to this site // Get all non-relayed and not jit clients connected to this site
const connectedClients = await db const connectedClients = await db
.select({ .select({
online: clients.online,
clientId: clients.clientId, clientId: clients.clientId,
olmId: olms.olmId, olmId: olms.olmId,
isRelayed: clientSitesAssociationsCache.isRelayed isRelayed: clientSitesAssociationsCache.isRelayed,
isJitMode: clientSitesAssociationsCache.isJitMode
}) })
.from(clientSitesAssociationsCache) .from(clientSitesAssociationsCache)
.innerJoin( .innerJoin(
@@ -423,32 +402,36 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
.innerJoin(olms, eq(olms.clientId, clients.clientId)) .innerJoin(olms, eq(olms.clientId, clients.clientId))
.where( .where(
and( and(
eq(clients.online, true), // the client has to be online or it does not matter...
eq(clientSitesAssociationsCache.siteId, siteId), eq(clientSitesAssociationsCache.siteId, siteId),
eq(clientSitesAssociationsCache.isRelayed, false) eq(clientSitesAssociationsCache.isRelayed, false),
eq(clientSitesAssociationsCache.isJitMode, false)
) )
); );
// Update each non-relayed client with the new site endpoint // Update each non-relayed client with the new site endpoint (in parallel)
for (const client of connectedClients) { await Promise.allSettled(
try { connectedClients.map(async (client) => {
await updateOlmPeer( try {
client.clientId, await updateOlmPeer(
{ client.clientId,
siteId: siteId, {
publicKey: site.publicKey, siteId: siteId,
endpoint: newEndpoint publicKey: site.publicKey!,
}, endpoint: newEndpoint
client.olmId },
); client.olmId
logger.debug( );
`Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}` logger.debug(
); `Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}`
} catch (error) { );
logger.error( } catch (error) {
`Failed to update client ${client.clientId} with new site endpoint: ${error}` logger.error(
); `Failed to update client ${client.clientId} with new site endpoint: ${error}`
} );
} }
})
);
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error handling site endpoint change for site ${siteId}: ${error}` `Error handling site endpoint change for site ${siteId}: ${error}`
@@ -457,10 +440,11 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
} }
async function handleClientEndpointChange( async function handleClientEndpointChange(
sitesWithNewtsToUpdate: { siteId: number; newtId: string }[],
clientId: number, clientId: number,
newEndpoint: string newEndpoint: string
) { ) {
// Alert all sites connected to this client that the endpoint has changed (only if NOT relayed) // Alert all sites connected to this client that the endpoint has changed (only if NOT relayed and NOT JIT MODE)
try { try {
// Get client details // Get client details
const [client] = await db const [client] = await db
@@ -474,58 +458,42 @@ async function handleClientEndpointChange(
return; return;
} }
// Get all non-relayed sites connected to this client if (sitesWithNewtsToUpdate.length > 250) {
const connectedSites = await db logger.warn(
.select({ `Client ${clientId} has ${sitesWithNewtsToUpdate.length} connected sites so the client will be in jit mode anyway, skipping endpoint updates`
siteId: sites.siteId,
newtId: newts.newtId,
isRelayed: clientSitesAssociationsCache.isRelayed,
subnet: clients.subnet
})
.from(clientSitesAssociationsCache)
.innerJoin(
sites,
eq(clientSitesAssociationsCache.siteId, sites.siteId)
)
.innerJoin(newts, eq(newts.siteId, sites.siteId))
.innerJoin(
clients,
eq(clientSitesAssociationsCache.clientId, clients.clientId)
)
.where(
and(
eq(clientSitesAssociationsCache.clientId, clientId),
eq(clientSitesAssociationsCache.isRelayed, false)
)
); );
return;
}
// Update each non-relayed site with the new client endpoint // Update each non-relayed site with the new client endpoint (in parallel)
for (const siteData of connectedSites) { await Promise.allSettled(
try { sitesWithNewtsToUpdate.map(async ({ siteId, newtId }) => {
if (!siteData.subnet) { if (!client.pubKey) {
logger.warn( logger.warn(
`Client ${clientId} has no subnet, skipping update for site ${siteData.siteId}` `Client ${clientId} has no public key, skipping update for site ${siteId}`
); );
continue; return;
} }
await updateNewtPeer( try {
siteData.siteId, await updateNewtPeer(
client.pubKey, siteId,
{ client.pubKey,
endpoint: newEndpoint {
}, endpoint: newEndpoint
siteData.newtId },
); newtId
logger.debug( );
`Updated site ${siteData.siteId} with new client ${clientId} endpoint: ${newEndpoint}` logger.debug(
); `Updated site ${siteId} with new client ${clientId} endpoint: ${newEndpoint}`
} catch (error) { );
logger.error( } catch (error) {
`Failed to update site ${siteData.siteId} with new client endpoint: ${error}` logger.error(
); `Failed to update site ${siteId} with new client endpoint: ${error}`
} );
} }
})
);
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error handling client endpoint change for client ${clientId}: ${error}` `Error handling client endpoint change for client ${clientId}: ${error}`

View File

@@ -5,6 +5,7 @@ import {
db, db,
exitNodes, exitNodes,
networks, networks,
SiteResource,
siteNetworks, siteNetworks,
siteResources, siteResources,
sites sites
@@ -15,7 +16,7 @@ import {
generateRemoteSubnets generateRemoteSubnets
} from "@server/lib/ip"; } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -27,11 +28,11 @@ export async function buildSiteConfigurationForOlmClient(
) { ) {
const siteConfigurations: { const siteConfigurations: {
siteId: number; siteId: number;
name?: string name?: string;
endpoint?: string endpoint?: string;
publicKey?: string publicKey?: string;
serverIP?: string | null serverIP?: string | null;
serverPort?: number | null serverPort?: number | null;
remoteSubnets?: string[]; remoteSubnets?: string[];
aliases: Alias[]; aliases: Alias[];
}[] = []; }[] = [];
@@ -46,50 +47,79 @@ export async function buildSiteConfigurationForOlmClient(
) )
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
if (sitesData.length === 0) {
return siteConfigurations;
}
// Batch-fetch every site resource this client has access to across ALL sites
// in a single query, then group by siteId in memory. This avoids issuing one
// query per site (which would be N round-trips for N sites).
const allClientSiteResources = await db
.select({
siteResource: siteResources,
siteId: siteNetworks.siteId
})
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
for (const row of allClientSiteResources) {
const arr = siteResourcesBySiteId.get(row.siteId);
if (arr) {
arr.push(row.siteResource);
} else {
siteResourcesBySiteId.set(row.siteId, [row.siteResource]);
}
}
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
if (!jitMode && relay) {
const exitNodeIds = Array.from(
new Set(
sitesData
.map(({ sites: s }) => s.exitNodeId)
.filter((id): id is number => id != null)
)
);
if (exitNodeIds.length > 0) {
const nodes = await db
.select()
.from(exitNodes)
.where(inArray(exitNodes.exitNodeId, exitNodeIds));
for (const n of nodes) {
exitNodesById.set(n.exitNodeId, n);
}
}
}
const clientsStartPort = config.getRawConfig().gerbil.clients_start_port;
const peerOps: Promise<unknown>[] = [];
// Process each site // Process each site
for (const { for (const {
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 const allSiteResources = siteResourcesBySiteId.get(site.siteId) ?? [];
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
eq(networks.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
)
);
if (jitMode) { if (jitMode) {
// Add site configuration to the array // Add site configuration to the array
siteConfigurations.push({ siteConfigurations.push({
siteId: site.siteId, siteId: site.siteId,
// remoteSubnets: generateRemoteSubnets( // remoteSubnets: generateRemoteSubnets(allSiteResources),
// allSiteResources.map(({ siteResources }) => siteResources) aliases: generateAliasConfig(allSiteResources)
// ),
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
)
}); });
continue; continue;
} }
@@ -109,10 +139,9 @@ export async function buildSiteConfigurationForOlmClient(
continue; continue;
} }
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers if (!site.publicKey || site.publicKey == "") {
logger.warn( // the site is not ready to accept new peers
`Site ${site.siteId} has no public key, skipping` logger.warn(`Site ${site.siteId} has no public key, skipping`);
);
continue; continue;
} }
@@ -128,7 +157,7 @@ export async function buildSiteConfigurationForOlmClient(
logger.info( logger.info(
`Public key mismatch. Deleting old peer from site ${site.siteId}...` `Public key mismatch. Deleting old peer from site ${site.siteId}...`
); );
await deletePeer(site.siteId, client.pubKey!); peerOps.push(deletePeer(site.siteId, client.pubKey!));
} }
if (!site.subnet) { if (!site.subnet) {
@@ -136,27 +165,19 @@ export async function buildSiteConfigurationForOlmClient(
continue; continue;
} }
const [clientSite] = await db // Add the peer to the exit node for this site. The endpoint comes from
.select() // the already-joined association row above, so no extra query needed.
.from(clientSitesAssociationsCache) if (association.endpoint && publicKey) {
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
)
.limit(1);
// Add the peer to the exit node for this site
if (clientSite.endpoint && publicKey) {
logger.info( logger.info(
`Adding peer ${publicKey} to site ${site.siteId} with endpoint ${clientSite.endpoint}` `Adding peer ${publicKey} to site ${site.siteId} with endpoint ${association.endpoint}`
);
peerOps.push(
addPeer(site.siteId, {
publicKey: publicKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: relay ? "" : association.endpoint
})
); );
await addPeer(site.siteId, {
publicKey: publicKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: relay ? "" : clientSite.endpoint
});
} else { } else {
logger.warn( logger.warn(
`Client ${client.clientId} has no endpoint, skipping peer addition` `Client ${client.clientId} has no endpoint, skipping peer addition`
@@ -165,16 +186,12 @@ export async function buildSiteConfigurationForOlmClient(
let relayEndpoint: string | undefined = undefined; let relayEndpoint: string | undefined = undefined;
if (relay) { if (relay) {
const [exitNode] = await db const exitNode = exitNodesById.get(site.exitNodeId);
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
if (!exitNode) { if (!exitNode) {
logger.warn(`Exit node not found for site ${site.siteId}`); logger.warn(`Exit node not found for site ${site.siteId}`);
continue; continue;
} }
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`; relayEndpoint = `${exitNode.endpoint}:${clientsStartPort}`;
} }
// Add site configuration to the array // Add site configuration to the array
@@ -186,12 +203,16 @@ export async function buildSiteConfigurationForOlmClient(
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, serverIP: site.address,
serverPort: site.listenPort, serverPort: site.listenPort,
remoteSubnets: generateRemoteSubnets( remoteSubnets: generateRemoteSubnets(allSiteResources),
allSiteResources.map(({ siteResources }) => siteResources) aliases: generateAliasConfig(allSiteResources)
), });
aliases: generateAliasConfig( }
allSiteResources.map(({ siteResources }) => siteResources)
) // Run all peer add/delete operations concurrently rather than serially per
// site, so total time is bounded by the slowest call instead of the sum.
if (peerOps.length > 0) {
Promise.allSettled(peerOps).catch((err) => {
logger.error("Error processing peer operations: ", err);
}); });
} }

View File

@@ -8,7 +8,7 @@ import {
ExitNode, ExitNode,
exitNodes, exitNodes,
sites, sites,
clientSitesAssociationsCache, clientSitesAssociationsCache
} from "@server/db"; } from "@server/db";
import { olms } from "@server/db"; import { olms } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -28,6 +28,7 @@ import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { APP_VERSION } from "@server/lib/consts"; import { APP_VERSION } from "@server/lib/consts";
import { build } from "@server/build";
export const olmGetTokenBodySchema = z.object({ export const olmGetTokenBodySchema = z.object({
olmId: z.string(), olmId: z.string(),
@@ -220,6 +221,22 @@ export async function getOlmToken(
) )
.where(eq(clientSitesAssociationsCache.clientId, clientIdToUse!)); .where(eq(clientSitesAssociationsCache.clientId, clientIdToUse!));
if (clientSites.length > 250 && build == "saas") {
// set all of the cache rows isJitMode to true
await db
.update(clientSitesAssociationsCache)
.set({ isJitMode: true })
.where(
and(
eq(
clientSitesAssociationsCache.clientId,
clientIdToUse!
),
eq(clientSitesAssociationsCache.isJitMode, false)
)
);
}
// Extract unique exit node IDs // Extract unique exit node IDs
const exitNodeIds = Array.from( const exitNodeIds = Array.from(
new Set( new Set(

View File

@@ -1,4 +1,4 @@
import { db, orgs } from "@server/db"; import { db, orgs, primaryDb } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
@@ -7,7 +7,7 @@ import {
olms, olms,
sites sites
} from "@server/db"; } from "@server/db";
import { count, eq } from "drizzle-orm"; import { and, count, eq, ne, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
@@ -81,7 +81,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.where(eq(olms.olmId, olm.olmId)); .where(eq(olms.olmId, olm.olmId));
} }
const [client] = await db const [client] = await primaryDb // read from the primary here so there is no latency with the last update on the holepunch
.select() .select()
.from(clients) .from(clients)
.where(eq(clients.clientId, olm.clientId)) .where(eq(clients.clientId, olm.clientId))
@@ -98,7 +98,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.blocked) { if (client.blocked) {
logger.debug( logger.debug(
`[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`, `[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
return; return;
@@ -107,7 +107,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.approvalState == "pending") { if (client.approvalState == "pending") {
logger.debug( logger.debug(
`[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`, `[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
return; return;
@@ -136,7 +136,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!org) { if (!org) {
logger.warn("[handleOlmRegisterMessage] Org not found", { logger.warn("[handleOlmRegisterMessage] Org not found", {
orgId: client.orgId orgId: client.orgId,
clientId: client.clientId
}); });
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
return; return;
@@ -145,7 +146,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (orgId) { if (orgId) {
if (!olm.userId) { if (!olm.userId) {
logger.warn("[handleOlmRegisterMessage] Olm has no user ID", { logger.warn("[handleOlmRegisterMessage] Olm has no user ID", {
orgId: client.orgId orgId: client.orgId,
clientId: client.clientId
}); });
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
return; return;
@@ -156,7 +158,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!userSession || !user) { if (!userSession || !user) {
logger.warn( logger.warn(
"[handleOlmRegisterMessage] Invalid user session for olm register", "[handleOlmRegisterMessage] Invalid user session for olm register",
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId); sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
return; return;
@@ -164,7 +166,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (user.userId !== olm.userId) { if (user.userId !== olm.userId) {
logger.warn( logger.warn(
"[handleOlmRegisterMessage] User ID mismatch for olm register", "[handleOlmRegisterMessage] User ID mismatch for olm register",
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId); sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
return; return;
@@ -182,13 +184,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.debug("[handleOlmRegisterMessage] Policy check result", { logger.debug("[handleOlmRegisterMessage] Policy check result", {
orgId: client.orgId, orgId: client.orgId,
clientId: client.clientId,
policyCheck policyCheck
}); });
if (policyCheck?.error) { if (policyCheck?.error) {
logger.error( logger.error(
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`, `[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return; return;
@@ -197,7 +200,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (policyCheck.policies?.passwordAge?.compliant === false) { if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn( logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`, `[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError( sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED, OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
@@ -209,7 +212,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
) { ) {
logger.warn( logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`, `[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError( sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED, OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
@@ -219,7 +222,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} else if (policyCheck.policies?.requiredTwoFactor === false) { } else if (policyCheck.policies?.requiredTwoFactor === false) {
logger.warn( logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`, `[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError( sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED, OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
@@ -229,7 +232,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} else if (!policyCheck.allowed) { } else if (!policyCheck.allowed) {
logger.warn( logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`, `[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return; return;
@@ -253,7 +256,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// Prepare an array to store site configurations // Prepare an array to store site configurations
logger.debug( logger.debug(
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`, `[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
let jitMode = false; let jitMode = false;
@@ -263,19 +266,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites // If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
logger.info( logger.info(
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`, `[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
jitMode = true; jitMode = true;
} }
logger.debug( logger.debug(
`[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`, `[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
if (!publicKey) { if (!publicKey) {
logger.warn("[handleOlmRegisterMessage] Public key not provided", { logger.warn("[handleOlmRegisterMessage] Public key not provided", {
orgId: client.orgId orgId: client.orgId,
clientId: client.clientId
}); });
return; return;
} }
@@ -283,7 +287,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.pubKey !== publicKey || client.archived) { if (client.pubKey !== publicKey || client.archived) {
logger.info( logger.info(
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
// Update the client's public key // Update the client's public key
await db await db
@@ -301,7 +305,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
isRelayed: relay == true, isRelayed: relay == true,
isJitMode: jitMode isJitMode: jitMode
}) })
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
or(
ne(
clientSitesAssociationsCache.isRelayed,
relay == true
),
ne(clientSitesAssociationsCache.isJitMode, jitMode)
)
)
);
} }
// 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.
@@ -310,7 +325,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) { if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
logger.warn( logger.warn(
`[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`, `[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
{ orgId: client.orgId } { orgId: client.orgId, clientId: client.clientId }
); );
return; return;
} }

View File

@@ -17,7 +17,7 @@ import { initPeerAddHandshake } from "./peers";
export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
context context
) => { ) => {
logger.info("Handling register olm message!"); logger.info("Handle Olm Server Init Add Peer Handshake Message");
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
const olm = c as Olm; const olm = c as Olm;

View File

@@ -9,16 +9,50 @@ import {
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 { count, eq, inArray } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import { build } from "@server/build";
export async function sendOlmSyncMessage(olm: Olm, client: Client) { export async function sendOlmSyncMessage(olm: Olm, client: Client) {
// 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(
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
{ orgId: client.orgId }
);
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(
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
{ orgId: client.orgId }
);
jitMode = true;
}
// 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
const siteConfigurations = await buildSiteConfigurationForOlmClient( const siteConfigurations = await buildSiteConfigurationForOlmClient(
client, client,
client.pubKey, client.pubKey,
false false,
jitMode
); );
// Get all exit nodes from sites where the client has peers // Get all exit nodes from sites where the client has peers
@@ -82,7 +116,6 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
exitNodes: exitNodesData exitNodes: exitNodesData
} }
}, },
{ {
compress: canCompress(olm.version, "olm") compress: canCompress(olm.version, "olm")
} }

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db, DB_TYPE } from "@server/db";
import { and, eq, or, inArray } from "drizzle-orm"; import { and, eq, or, inArray, sql } from "drizzle-orm";
import { import {
resources, resources,
userResources, userResources,
@@ -12,7 +12,9 @@ import {
resourceWhitelist, resourceWhitelist,
siteResources, siteResources,
userSiteResources, userSiteResources,
roleSiteResources roleSiteResources,
siteNetworks,
sites
} from "@server/db"; } from "@server/db";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -156,9 +158,24 @@ export async function getUserResources(
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean | null;
siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
siteOnlines: boolean[];
}> = []; }> = [];
if (accessibleSiteResourceIds.length > 0) { if (accessibleSiteResourceIds.length > 0) {
siteResourcesData = await db const aggCol = <T>(column: any) => {
if (DB_TYPE === "sqlite") {
return sql<T>`json_group_array(${column})`;
}
return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
};
const siteResourcesRaw = await db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
name: siteResources.name, name: siteResources.name,
@@ -170,9 +187,22 @@ export async function getUserResources(
fullDomain: siteResources.fullDomain, fullDomain: siteResources.fullDomain,
enabled: siteResources.enabled, enabled: siteResources.enabled,
alias: siteResources.alias, alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress aliasAddress: siteResources.aliasAddress,
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
siteIds: aggCol<number[]>(sites.siteId),
siteNames: aggCol<string[]>(sites.name),
siteNiceIds: aggCol<string[]>(sites.niceId),
siteAddresses: aggCol<(string | null)[]>(sites.address),
siteOnlines: aggCol<boolean[]>(sites.online)
}) })
.from(siteResources) .from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where( .where(
and( and(
inArray( inArray(
@@ -182,7 +212,55 @@ export async function getUserResources(
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true)
) )
); )
.groupBy(siteResources.siteResourceId);
siteResourcesData = siteResourcesRaw.map((row: any) => {
if (DB_TYPE !== "sqlite") {
return row;
}
const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[];
const siteNamesRaw = JSON.parse(row.siteNames) as (
| string
| null
)[];
const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (
| string
| null
)[];
const siteAddressesRaw = JSON.parse(row.siteAddresses) as (
| string
| null
)[];
const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (
| 0
| 1
| null
)[];
const siteIds: number[] = [];
const siteNames: string[] = [];
const siteNiceIds: string[] = [];
const siteAddresses: (string | null)[] = [];
const siteOnlines: boolean[] = [];
for (let i = 0; i < siteIdsRaw.length; i++) {
if (siteIdsRaw[i] == null) continue;
siteIds.push(siteIdsRaw[i] as number);
siteNames.push((siteNamesRaw[i] ?? "") as string);
siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string);
siteAddresses.push(siteAddressesRaw[i] ?? null);
siteOnlines.push(siteOnlinesRaw[i] === 1);
}
return {
...row,
siteIds,
siteNames,
siteNiceIds,
siteAddresses,
siteOnlines
};
});
} }
// Check for password, pincode, and whitelist protection for each resource // Check for password, pincode, and whitelist protection for each resource
@@ -260,6 +338,14 @@ export async function getUserResources(
enabled: siteResource.enabled, enabled: siteResource.enabled,
alias: siteResource.alias, alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress, aliasAddress: siteResource.aliasAddress,
tcpPortRangeString: siteResource.tcpPortRangeString,
udpPortRangeString: siteResource.udpPortRangeString,
disableIcmp: siteResource.disableIcmp,
siteIds: siteResource.siteIds,
siteNames: siteResource.siteNames,
siteNiceIds: siteResource.siteNiceIds,
siteAddresses: siteResource.siteAddresses,
siteOnlines: siteResource.siteOnlines,
type: "site" as const type: "site" as const
}; };
}); });
@@ -302,11 +388,19 @@ export type GetUserResourcesResponse = {
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null; protocol: string | null;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean | null;
ssl: boolean; ssl: boolean;
fullDomain: string | null; fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
siteOnlines: boolean[];
type: "site"; type: "site";
}>; }>;
}; };

View File

@@ -153,6 +153,65 @@ export default function GeneralPage() {
const [approvalId, setApprovalId] = useState<number | null>(null); const [approvalId, setApprovalId] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [cacheCheck, setCacheCheck] = useState<null | {
consistent: boolean;
missingSiteResourceIds: number[];
extraSiteResourceIds: number[];
missingSiteIds: number[];
extraSiteIds: number[];
expectedSiteResourceIds: number[];
actualSiteResourceIds: number[];
expectedSiteIds: number[];
actualSiteIds: number[];
}>(null);
const [isCheckingCache, setIsCheckingCache] = useState(false);
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
const handleRebuildCache = async () => {
if (!client.clientId) return;
setIsRebuildingCache(true);
try {
await api.post(
`/client/${client.clientId}/rebuild-associations-cache`
);
// Re-verify after rebuild so the result refreshes
const res = await api.get(
`/client/${client.clientId}/verify-associations-cache`
);
setCacheCheck(res.data.data);
toast({
title: "Cache rebuilt",
description: "Association cache rebuilt successfully."
});
} catch (e) {
toast({
variant: "destructive",
title: "Rebuild failed",
description: formatAxiosError(e, "Failed to rebuild cache")
});
} finally {
setIsRebuildingCache(false);
}
};
const handleVerifyCache = async () => {
if (!client.clientId) return;
setIsCheckingCache(true);
try {
const res = await api.get(
`/client/${client.clientId}/verify-associations-cache`
);
setCacheCheck(res.data.data);
} catch (e) {
toast({
variant: "destructive",
title: "Cache check failed",
description: formatAxiosError(e, "Failed to verify cache")
});
} finally {
setIsCheckingCache(false);
}
};
const { env } = useEnvContext(); const { env } = useEnvContext();
const showApprovalFeatures = const showApprovalFeatures =
@@ -844,6 +903,75 @@ export default function GeneralPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
<button
type="button"
onClick={handleVerifyCache}
disabled={isCheckingCache}
className="text-xs text-muted-foreground underline disabled:opacity-50"
title="Verify the client's site association cache against current permissions (read-only)"
>
{isCheckingCache
? "Checking cache…"
: "Verify association cache"}
</button>
{cacheCheck && (
<div
className={
"text-xs rounded border px-2 py-1 " +
(cacheCheck.consistent
? "border-green-600 text-green-700"
: "border-red-600 text-red-700")
}
>
{cacheCheck.consistent ? (
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Cache is consistent
</span>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1 font-semibold">
<XCircle className="h-3 w-3" />
Cache is INCONSISTENT
</div>
<div>
Missing site resources: [
{cacheCheck.missingSiteResourceIds.join(
", "
)}
]
</div>
<div>
Extra site resources: [
{cacheCheck.extraSiteResourceIds.join(", ")}
]
</div>
<div>
Missing sites: [
{cacheCheck.missingSiteIds.join(", ")}]
</div>
<div>
Extra sites: [
{cacheCheck.extraSiteIds.join(", ")}]
</div>
<button
type="button"
onClick={handleRebuildCache}
disabled={isRebuildingCache}
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
>
{isRebuildingCache
? "Rebuilding…"
: "Rebuild cache now"}
</button>
</div>
)}
</div>
)}
</div>
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -44,77 +44,11 @@ export type AuthPageCustomizationProps = {
}; };
const AuthPageFormSchema = z.object({ const AuthPageFormSchema = z.object({
logoUrl: z.union([ logoUrl: z
z.literal(""), .string()
z.string().superRefine(async (urlOrPath, ctx) => { .optional()
const parseResult = z.url().safeParse(urlOrPath); .transform((val) => (val === "" ? undefined : val)),
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message:
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
]),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
orgTitle: z.string().optional(), orgTitle: z.string().optional(),

View File

@@ -61,14 +61,14 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
<InfoSectionTitle>{t("status")}</InfoSectionTitle> <InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{client.online ? ( {client.online ? (
<div className="text-green-500 flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span> <span>{t("connected")}</span>
</div> </div>
) : ( ) : (
<div className="text-neutral-500 flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span> <span>{t("disconnected")}</span>
</div> </div>
)} )}
</InfoSectionContent> </InfoSectionContent>

View File

@@ -26,12 +26,12 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
<InfoSectionTitle>{t("status")}</InfoSectionTitle> <InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{remoteExitNode.online ? ( {remoteExitNode.online ? (
<div className="text-green-500 flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span> <span>{t("online")}</span>
</div> </div>
) : ( ) : (
<div className="text-neutral-500 flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span> <span>{t("offline")}</span>
</div> </div>

View File

@@ -140,14 +140,14 @@ export default function ExitNodesTable({
const originalRow = row.original; const originalRow = row.original;
if (originalRow.online) { if (originalRow.online) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span> <span>{t("online")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span> <span>{t("offline")}</span>
</span> </span>

View File

@@ -519,21 +519,21 @@ export default function HealthChecksTable({
const health = row.original.hcHealth; const health = row.original.hcHealth;
if (health === "healthy") { if (health === "healthy") {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full" /> <div className="w-2 h-2 bg-green-500 rounded-full" />
<span>{t("standaloneHcHealthStateHealthy")}</span> <span>{t("standaloneHcHealthStateHealthy")}</span>
</span> </span>
); );
} else if (health === "unhealthy") { } else if (health === "unhealthy") {
return ( return (
<span className="text-red-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-red-500 rounded-full" /> <div className="w-2 h-2 bg-red-500 rounded-full" />
<span>{t("standaloneHcHealthStateUnhealthy")}</span> <span>{t("standaloneHcHealthStateUnhealthy")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full" /> <div className="w-2 h-2 bg-neutral-500 rounded-full" />
<span>{t("standaloneHcHealthStateUnknown")}</span> <span>{t("standaloneHcHealthStateUnknown")}</span>
</span> </span>

View File

@@ -285,14 +285,14 @@ export default function MachineClientsTable({
const originalRow = row.original; const originalRow = row.original;
if (originalRow.online) { if (originalRow.online) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("connected")}</span> <span>{t("connected")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("disconnected")}</span> <span>{t("disconnected")}</span>
</span> </span>

View File

@@ -228,14 +228,14 @@ export default function PendingSitesTable({
) { ) {
if (originalRow.online) { if (originalRow.online) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span> <span>{t("online")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span> <span>{t("offline")}</span>
</span> </span>

View File

@@ -89,12 +89,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSectionTitle>Socket</InfoSectionTitle> <InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{isAvailable ? ( {isAvailable ? (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span> <span>Online</span>
</span> </span>
) : ( ) : (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>Offline</span> <span>Offline</span>
</span> </span>

View File

@@ -34,12 +34,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSectionTitle>{t("status")}</InfoSectionTitle> <InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.online ? ( {site.online ? (
<div className="text-green-500 flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span> <span>{t("online")}</span>
</div> </div>
) : ( ) : (
<div className="text-neutral-500 flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span> <span>{t("offline")}</span>
</div> </div>

View File

@@ -226,14 +226,14 @@ export default function SitesTable({
) { ) {
if (originalRow.online) { if (originalRow.online) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span> <span>{t("online")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span> <span>{t("offline")}</span>
</span> </span>

View File

@@ -436,14 +436,14 @@ export default function UserDevicesTable({
const originalRow = row.original; const originalRow = row.original;
if (originalRow.online) { if (originalRow.online) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("connected")}</span> <span>{t("connected")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("disconnected")}</span> <span>{t("disconnected")}</span>
</span> </span>