mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-21 16:25:19 +00:00
Compare commits
47 Commits
1.18.4
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37ecb1ad5 | ||
|
|
b8180d848a | ||
|
|
fef7563e14 | ||
|
|
6337cf4359 | ||
|
|
b3cfe82dff | ||
|
|
d65128671c | ||
|
|
41fdd5de74 | ||
|
|
2704202ba9 | ||
|
|
72ef0ae020 | ||
|
|
1442faa740 | ||
|
|
6aa589e612 | ||
|
|
4b1a8e14c4 | ||
|
|
1a0db10b1a | ||
|
|
b7634086db | ||
|
|
a6469e67a8 | ||
|
|
1ba75092f9 | ||
|
|
08a08e73b3 | ||
|
|
82745c701a | ||
|
|
68e775659b | ||
|
|
1c5e3000b6 | ||
|
|
3b93fd99a1 | ||
|
|
159e91a07c | ||
|
|
530b5082bd | ||
|
|
3322f1ccb4 | ||
|
|
1b17fba19f | ||
|
|
dd1f7ba544 | ||
|
|
8c2e6965f1 | ||
|
|
b414f04cce | ||
|
|
9c71922dda | ||
|
|
6e4a28f227 | ||
|
|
64d8f035a2 | ||
|
|
0a5780a3b3 | ||
|
|
d58b96f4b1 | ||
|
|
f778f5c941 | ||
|
|
6422208f69 | ||
|
|
c3ebc423b5 | ||
|
|
92f992728f | ||
|
|
78ad2d17c7 | ||
|
|
b29bb7384d | ||
|
|
5a8de8210b | ||
|
|
d5181454f4 | ||
|
|
0e0666cacf | ||
|
|
02ba2393b9 | ||
|
|
daf260cf61 | ||
|
|
92a06e0ea3 | ||
|
|
c16d2ff2ed | ||
|
|
73a4d7d351 |
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [fosrl]
|
|
||||||
4
.github/workflows/cicd.yml
vendored
4
.github/workflows/cicd.yml
vendored
@@ -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.
|
||||||
|
|||||||
39
.github/workflows/restart-runners.yml
vendored
39
.github/workflows/restart-runners.yml
vendored
@@ -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"
|
|
||||||
160
.github/workflows/saas.yml
vendored
160
.github/workflows/saas.yml
vendored
@@ -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"
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
810
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
81
server/routers/client/rebuildClientAssociationsCacheRoute.ts
Normal file
81
server/routers/client/rebuildClientAssociationsCacheRoute.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
83
server/routers/client/verifyClientAssociationsCache.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user