Compare commits

..

1 Commits

Author SHA1 Message Date
Owen
db02f482ff Add regional redis cache 2026-05-12 21:36:06 -07:00
116 changed files with 2221 additions and 7983 deletions

3
.github/FUNDING.yml vendored Normal file
View File

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

View File

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

39
.github/workflows/restart-runners.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
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 Normal file
View File

@@ -0,0 +1,160 @@
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"

110
cloud
View File

@@ -1,110 +0,0 @@
git push origin -d 1.11.0-s.0
git push origin -d 1.11.0-s.1
git push origin -d 1.11.0-s.2
git push origin -d 1.11.0-s.3
git push origin -d 1.11.0-s.4
git push origin -d 1.11.0-s.5
git push origin -d 1.11.1-s.0
git push origin -d 1.12.0-s.0
git push origin -d 1.12.2-s.0
git push origin -d 1.12.2-s.1
git push origin -d 1.12.2-s.2
git push origin -d 1.12.2-s.3
git push origin -d 1.12.2-s.4
git push origin -d 1.12.2-s.5
git push origin -d 1.13.0.s.0
git push origin -d 1.13.1-s.0
git push origin -d 1.14.0-s.2
git push origin -d 1.14.1-s.0
git push origin -d 1.14.1-s.1
git push origin -d 1.14.1-s.2
git push origin -d 1.14.1-s.3
git push origin -d 1.15.0-s.0
git push origin -d 1.15.0-s.1
git push origin -d 1.15.0-s.2
git push origin -d 1.15.0-s.3
git push origin -d 1.15.0-s.4
git push origin -d 1.15.0-s.5
git push origin -d 1.15.1-s.0
git push origin -d 1.15.1-s.1
git push origin -d 1.15.3-s.0
git push origin -d 1.15.3-s.1
git push origin -d 1.15.4-s.0
git push origin -d 1.15.4-s.1
git push origin -d 1.15.4-s.10
git push origin -d 1.15.4-s.2
git push origin -d 1.15.4-s.3
git push origin -d 1.15.4-s.4
git push origin -d 1.15.4-s.5
git push origin -d 1.15.4-s.6
git push origin -d 1.15.4-s.7
git push origin -d 1.15.4-s.8
git push origin -d 1.15.4-s.9
git push origin -d 1.16.0-s.0
git push origin -d 1.16.0-s.1
git push origin -d 1.16.1-s.0
git push origin -d 1.16.1-s.1
git push origin -d 1.16.2-s.0
git push origin -d 1.16.2-s.1
git push origin -d 1.16.2-s.10
git push origin -d 1.16.2-s.11
git push origin -d 1.16.2-s.12
git push origin -d 1.16.2-s.13
git push origin -d 1.16.2-s.14
git push origin -d 1.16.2-s.15
git push origin -d 1.16.2-s.16
git push origin -d 1.16.2-s.17
git push origin -d 1.16.2-s.18
git push origin -d 1.16.2-s.19
git push origin -d 1.16.2-s.2
git push origin -d 1.16.2-s.20
git push origin -d 1.16.2-s.21
git push origin -d 1.16.2-s.22
git push origin -d 1.16.2-s.3
git push origin -d 1.16.2-s.4
git push origin -d 1.16.2-s.5
git push origin -d 1.16.2-s.6
git push origin -d 1.16.2-s.7
git push origin -d 1.16.2-s.8
git push origin -d 1.16.2-s.9
git push origin -d 1.17.0-s.0
git push origin -d 1.17.0-s.1
git push origin -d 1.17.0-s.2
git push origin -d 1.17.0-s.3
git push origin -d 1.17.0-s.4
git push origin -d 1.17.1-s.0
git push origin -d 1.17.1-s.1
git push origin -d 1.17.1-s.2
git push origin -d 1.17.1-s.3
git push origin -d 1.17.1-s.4
git push origin -d 1.17.1-s.5
git push origin -d 1.17.1-s.6
git push origin -d 1.17.1-s.7
git push origin -d 1.18.0-s.0
git push origin -d 1.18.0-s.1
git push origin -d 1.18.0-s.2
git push origin -d 1.18.1-s.0
git push origin -d 1.18.1-s.1
git push origin -d 1.18.1-s.2
git push origin -d 1.18.1-s.3
git push origin -d 1.18.1-s.4
git push origin -d 1.18.1-s.5
git push origin -d 1.18.1-s.6
git push origin -d 1.18.1-s.7
git push origin -d 1.18.2-s.0
git push origin -d 1.18.2-s.1
git push origin -d 1.18.2-s.2
git push origin -d 1.18.2-s.3
git push origin -d 1.18.2-s.4
git push origin -d 1.18.2-s.5
git push origin -d 1.18.3-s.0
git push origin -d 1.18.3-s.1
git push origin -d 1.18.3-s.2
git push origin -d 1.18.3-s.3
git push origin -d 1.18.4-s.0
git push origin -d 1.18.4-s.1
git push origin -d 1.18.4-s.2
git push origin -d 1.18.4-s.3
git push origin -d 1.18.4-s.4
git push origin -d 1.18.4-s.5
git push origin -d 1.18.4-s.6

View File

@@ -4,7 +4,6 @@ import (
"crypto/rand"
"embed"
"encoding/base64"
"flag"
"fmt"
"io"
"io/fs"
@@ -69,9 +68,6 @@ const (
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
fmt.Println("Welcome to the Pangolin installer!")
@@ -210,7 +206,7 @@ func main() {
}
}
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) {

View File

@@ -630,7 +630,7 @@
"createdAt": "Създаден на",
"proxyErrorInvalidHeader": "Невалидна стойност за заглавие на хоста. Използвайте формат на име на домейн, или оставете празно поле за да премахнете персонализирано заглавие на хост.",
"proxyErrorTls": "Невалидно име на TLS сървър. Използвайте формат на име на домейн, или оставете празно за да премахнете името на TLS сървъра.",
"proxyEnableSSL": "Активиране на TLS",
"proxyEnableSSL": "Активиране на SSL",
"proxyEnableSSLDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целите.",
"target": "Цел",
"configureTarget": "Конфигуриране на цели",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Метод",
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
"editInternalResourceDialogEnableSsl": "Активирайте SSL",
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
"editInternalResourceDialogDestination": "Дестинация",
"editInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Метод",
"createInternalResourceDialogScheme": "Метод",
"createInternalResourceDialogEnableSsl": "Активирайте TLS",
"createInternalResourceDialogEnableSsl": "Активирайте SSL",
"createInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
"createInternalResourceDialogDestination": "Дестинация",
"createInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.",
@@ -2233,7 +2233,7 @@
"description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри",
"introTitle": "Управлявано Самостоятелно-хостван Панголиин",
"introDescription": "е опция за внедряване, предназначена за хора, които искат простота и допълнителна надеждност, като същевременно запазят данните си частни и самостоятелно-хоствани.",
"introDetail": "С тази опция все още управлявате свой собствен Панголиин възел - вашите тунели, TLS терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:",
"introDetail": "С тази опция все още управлявате свой собствен Панголиин възел - вашите тунели, SSL терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:",
"benefitSimplerOperations": {
"title": "По-прости операции",
"description": "Няма нужда да управлявате свой собствен имейл сървър или да настройвате сложни аларми. Ще получите проверки и предупреждения при прекъсване от самото начало."
@@ -3136,7 +3136,7 @@
"httpDestNamePlaceholder": "Моята HTTP дестинация",
"httpDestUrlLabel": "Дестинация URL",
"httpDestUrlErrorHttpRequired": "URL адресът трябва да използва http или https",
"httpDestUrlErrorHttpsRequired": "HTTPS е необходимо за облачни инсталации",
"httpDestUrlErrorHttpsRequired": "SSL е необходимо за облачни инсталации",
"httpDestUrlErrorInvalid": "Въведете валиден URL (напр. https://example.com/webhook)",
"httpDestAuthTitle": "Удостоверяване",
"httpDestAuthDescription": "Изберете как заявленията ви се удостоверяват.",

View File

@@ -630,7 +630,7 @@
"createdAt": "Vytvořeno v",
"proxyErrorInvalidHeader": "Neplatná hodnota hlavičky hostitele. Použijte formát názvu domény, nebo uložte prázdné pro zrušení vlastního hlavičky hostitele.",
"proxyErrorTls": "Neplatné jméno TLS serveru. Použijte formát doménového jména nebo uložte prázdné pro odstranění názvu TLS serveru.",
"proxyEnableSSL": "Povolit TLS",
"proxyEnableSSL": "Povolit SSL",
"proxyEnableSSLDescription": "Povolit šifrování SSL/TLS pro zabezpečená připojení HTTPS k cílům.",
"target": "Target",
"configureTarget": "Konfigurace cílů",

View File

@@ -22,11 +22,11 @@
"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.",
"dismiss": "Verwerfen",
"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.",
"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.",
"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.",
"billingTrialBannerTitle": "Kostenlose Testversion aktiv",
"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.",
"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.",
"billingTrialBannerUpgrade": "Jetzt upgraden",
"billingTrialBadge": "Kostenlose Testversion",
"trialActive": "Kostenlose Testversion aktiv",
@@ -34,8 +34,8 @@
"trialHasEnded": "Ihre Testversion ist beendet.",
"trialDaysRemaining": "{count, plural, one {# Tag übrig} other {# Tage übrig}}",
"trialDaysLeftShort": "Noch {days}d in der Testversion",
"trialGoToBilling": "Zur Abrechnung gehen",
"subscriptionViolationViewBilling": "Abrechnung anzeigen",
"trialGoToBilling": "Zur Rechnungsseite gehen",
"subscriptionViolationViewBilling": "Rechnung anzeigen",
"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}!",
"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",
"siteConfirmDelete": "Löschen des Standorts bestätigen",
"siteDelete": "Standort löschen",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird sie 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?",
"siteManageSites": "Standorte verwalten",
"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",
"siteSettingDescription": "Standorteinstellungen konfigurieren",
"siteResourcesTab": "Ressourcen",
"siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen",
"siteResourcesNoneOnSite": "Diese Seite hat noch keine öffentlichen oder privaten Ressourcen.",
"siteResourcesSectionPublic": "Öffentliche Ressourcen",
"siteResourcesSectionPrivate": "Private Ressourcen",
"siteResourcesSectionPublicDescription": "Ressourcen, die extern über Domains oder Ports bereitgestellt werden.",
"siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über den Standort verfügbar sind.",
"siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über die Seite verfügbar sind.",
"siteResourcesViewAllPublic": "Alle Ressourcen anzeigen",
"siteResourcesViewAllPrivate": "Alle Ressourcen anzeigen",
"siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit diesem Standort verbunden sind.",
"siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit dieser Seite verbunden sind.",
"siteResourcesShowMore": "Mehr anzeigen",
"siteResourcesPermissionDenied": "Sie haben keine Berechtigung, diese Ressourcen aufzulisten.",
"siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diesen Standort vorhanden.",
"siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit diesem Standort verbunden.",
"siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diese Seite vorhanden.",
"siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit dieser Seite verbunden.",
"siteResourcesHowToAccess": "Zugriffsmöglichkeiten",
"siteResourcesTargetsOnSite": "Ziele an diesem Standort",
"siteResourcesTargetsOnSite": "Ziele auf dieser Seite",
"siteSetting": "{siteName} Einstellungen",
"siteNewtTunnel": "Newt Standort (empfohlen)",
"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.",
"siteInfo": "Standortinformationen",
"status": "Status",
"shareTitle": "Freigabelinks verwalten",
"shareTitle": "Links zum Teilen verwalten",
"shareDescription": "Erstelle teilbare Links, um temporären oder permanenten Zugriff auf Proxy-Ressourcen zu gewähren",
"shareSearch": "Freigabelinks suchen...",
"shareCreate": "Freigabelink erstellen",
"shareSearch": "Freigabe-Links suchen...",
"shareCreate": "Link erstellen",
"shareErrorDelete": "Link konnte nicht gelöscht werden",
"shareErrorDeleteMessage": "Fehler beim Löschen des Links",
"shareDeleted": "Link gelöscht",
@@ -161,7 +161,7 @@
"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.",
"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": "Zugriffstoken",
"accessToken": "Zugangs-Token",
"usageExamples": "Nutzungsbeispiele",
"tokenId": "Token-ID",
"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.",
"shareErrorFetchResource": "Fehler beim Abrufen der Ressourcen",
"shareErrorFetchResourceDescription": "Beim Abrufen der Ressourcen ist ein Fehler aufgetreten",
"shareErrorCreate": "Fehler beim Erstellen des Freigabelinks",
"shareErrorCreateDescription": "Beim Erstellen des Freigabelinks ist ein Fehler aufgetreten",
"shareErrorCreate": "Fehler beim Erstellen des Teilen-Links",
"shareErrorCreateDescription": "Beim Erstellen des Teilen-Links ist ein Fehler aufgetreten",
"shareCreateDescription": "Jeder mit diesem Link kann auf die Ressource zugreifen",
"shareTitleOptional": "Titel (optional)",
"expireIn": "Läuft ab in",
"neverExpire": "Läuft nie ab",
"expireIn": "Verfällt in",
"neverExpire": "Nie ablaufen",
"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.",
"shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.",
@@ -186,7 +186,7 @@
"resourcesNotFound": "Keine Ressourcen gefunden",
"resourceSearch": "Suche Ressourcen",
"machineSearch": "Maschinen suchen",
"machinesSearch": "Maschinen-Clients suchen",
"machinesSearch": "Suche Maschinen-Klienten...",
"machineNotFound": "Keine Maschinen gefunden",
"userDeviceSearch": "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.",
"clientResourceTitle": "Private Ressourcen verwalten",
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
"privateResourcesBannerTitle": "Zero-Trust Privater Zugang",
"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...",
"resourceAdd": "Ressource hinzufügen",
@@ -265,7 +265,7 @@
"rules": "Regeln",
"resourceSettingDescription": "Einstellungen für die Ressource konfigurieren",
"resourceSetting": "{resourceName} Einstellungen",
"alwaysAllow": "Authentifizierung umgehen",
"alwaysAllow": "Auth umgehen",
"alwaysDeny": "Zugriff blockieren",
"passToAuth": "Weiterleiten zur Authentifizierung",
"orgSettingsDescription": "Organisationseinstellungen konfigurieren",
@@ -274,7 +274,7 @@
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
"saveSettings": "Einstellungen speichern",
"orgDangerZone": "Gefahrenzone",
"orgDangerZoneDescription": "Sobald Sie diese Organisation löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.",
"orgDangerZoneDescription": "Sobald Sie diesen Org löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.",
"orgDelete": "Organisation löschen",
"orgDeleteConfirm": "Organisation löschen bestätigen",
"orgMessageRemove": "Diese Aktion ist unwiderruflich und löscht alle zugehörigen Daten.",
@@ -323,7 +323,7 @@
"accessApprovalsManage": "Genehmigungen verwalten",
"accessApprovalsDescription": "Zeige und verwalte ausstehende Genehmigungen für den Zugriff auf diese Organisation",
"description": "Beschreibung",
"inviteTitle": "Offene Einladungen",
"inviteTitle": "Einladungen öffnen",
"inviteDescription": "Einladungen für andere Benutzer verwalten, der Organisation beizutreten",
"inviteSearch": "Einladungen suchen...",
"minutes": "Minuten",
@@ -370,12 +370,12 @@
"apiKeysDescription": "API-Schlüssel werden zur Authentifizierung mit der Integrations-API verwendet",
"provisioningKeysTitle": "Bereitstellungsschlüssel",
"provisioningKeysManage": "Bereitstellungsschlüssel verwalten",
"provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Standorten für Ihr Unternehmen zu authentifizieren.",
"provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Seiten für Ihr Unternehmen zu authentifizieren.",
"provisioningManage": "Bereitstellung",
"provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Standorte prüfen, die noch auf Genehmigung warten.",
"pendingSites": "Ausstehende Standorte",
"siteApproveSuccess": "Standort erfolgreich freigegeben",
"siteApproveError": "Fehler beim Genehmigen des Standorts",
"provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Seiten prüfen, die noch auf Genehmigung warten.",
"pendingSites": "Ausstehende Seiten",
"siteApproveSuccess": "Site erfolgreich freigegeben",
"siteApproveError": "Fehler beim Bestätigen der Seite",
"provisioningKeys": "Bereitstellungsschlüssel",
"searchProvisioningKeys": "Bereitstellungsschlüssel suchen...",
"provisioningKeysAdd": "Bereitstellungsschlüssel generieren",
@@ -405,7 +405,7 @@
"provisioningKeysNeverUsed": "Nie",
"provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten",
"provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.",
"provisioningKeysApproveNewSites": "Neuen Standort genehmigen",
"provisioningKeysApproveNewSites": "Neue Seiten genehmigen",
"provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.",
"provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels",
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
@@ -413,8 +413,8 @@
"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.",
"provisioningKeysBannerButtonText": "Mehr erfahren",
"pendingSitesBannerTitle": "Ausstehende Standorte",
"pendingSitesBannerDescription": "Standorte, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
"pendingSitesBannerTitle": "Ausstehende Seiten",
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
"pendingSitesBannerButtonText": "Mehr erfahren",
"apiKeysSettings": "{apiKeyName} Einstellungen",
"userTitle": "Alle Benutzer verwalten",
@@ -461,7 +461,7 @@
"licenseActivateKeyDescription": "Geben Sie einen Lizenzschlüssel ein, um ihn zu 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.",
"fossorialLicense": "Kommerzielle Fossorial-Lizenz und Abonnementbedingungen anzeigen",
"fossorialLicense": "Fossorial Gewerbelizenz & Abonnementbedingungen anzeigen",
"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.",
"licenseQuestionRemove": "Sind Sie sicher, dass Sie den Lizenzschlüssel löschen möchten?",
@@ -481,7 +481,7 @@
"licensePurchaseSites": "Zusätzliche Standorte kaufen\n",
"licenseSitesUsedMax": "{usedSites} von {maxSites} Standorten verwendet",
"licenseSitesUsed": "{count, plural, =0 {# Standorte} one {# Standort} other {# Standorte}} im System.",
"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.}}",
"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.}}",
"licenseFee": "Lizenzgebühr",
"licensePriceSite": "Preis pro Standort",
"total": "Gesamt",
@@ -532,7 +532,7 @@
"userRemoveOrgConfirmSelf": "Entfernung bestätigen",
"userRemoveOrgSelf": "Sich selbst aus der Organisation entfernen",
"userRemoveOrgSelfWarning": "Sie verlieren sofort den Zugriff auf diese Organisation.",
"userRemoveOrgConfirmPhraseSelf": "MICH SELBST AUS DER ORGANISATION ENTFERNEN",
"userRemoveOrgConfirmPhraseSelf": "ENTFERNUNG MICH SELBST AUS DER ORGANISATION",
"users": "Benutzer",
"accessRoleMember": "Mitglied",
"accessRoleOwner": "Eigentümer",
@@ -630,7 +630,7 @@
"createdAt": "Erstellt am",
"proxyErrorInvalidHeader": "Ungültiger benutzerdefinierter Host-Header-Wert. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den benutzerdefinierten Host-Header zu deaktivieren.",
"proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.",
"proxyEnableSSL": "TLS aktivieren",
"proxyEnableSSL": "SSL aktivieren",
"proxyEnableSSLDescription": "Aktiviere SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zu den Zielen.",
"target": "Ziel",
"configureTarget": "Ziele konfigurieren",
@@ -1711,11 +1711,11 @@
"regionSelectorComingSoon": "Kommt bald",
"billingLoadingSubscription": "Abonnement wird geladen...",
"billingFreeTier": "Kostenlose Stufe",
"billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Standorte werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.",
"billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Webseiten werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.",
"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.",
"billingDataUsage": "Datenverbrauch",
"billingSites": "Standorte",
"billingSites": "Seiten",
"billingUsers": "Benutzergeräte",
"billingDomains": "Domänen",
"billingOrganizations": "Orden",
@@ -1743,7 +1743,7 @@
"billingCheckoutError": "Checkout-Fehler",
"billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL",
"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 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.",
"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.",
"billingSInfo": "Anzahl der Sites die Sie verwenden können",
"billingUsersInfo": "Wie viele Benutzer Sie verwenden können",
"billingDomainInfo": "Wie viele Domains Sie verwenden können",
@@ -1927,7 +1927,7 @@
"configureHealthCheck": "Gesundheits-Check konfigurieren",
"configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein",
"enableHealthChecks": "Gesundheits-Checks aktivieren",
"healthCheckDisabledStateDescription": "Wenn deaktiviert, führt der Standort keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.",
"healthCheckDisabledStateDescription": "Wenn deaktiviert, führt die Seite 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.",
"healthScheme": "Methode",
"healthSelectScheme": "Methode auswählen",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Schema",
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
"editInternalResourceDialogEnableSsl": "SSL aktivieren",
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
"editInternalResourceDialogDestination": "Ziel",
"editInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Schema",
"createInternalResourceDialogScheme": "Schema",
"createInternalResourceDialogEnableSsl": "TLS aktivieren",
"createInternalResourceDialogEnableSsl": "SSL aktivieren",
"createInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
"createInternalResourceDialogDestination": "Ziel",
"createInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.",
@@ -2187,8 +2187,8 @@
}
},
"remoteExitNodeSelection": "Knotenauswahl",
"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 Standorte ausgewählt sein",
"remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diese lokale Seite geleitet werden soll",
"remoteExitNodeRequired": "Ein Knoten muss für lokale Seiten ausgewählt sein",
"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.",
"exitNode": "Exit-Node",
@@ -2233,7 +2233,7 @@
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
"introTitle": "Verwalteter selbstgehosteter Pangolin",
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten Ihre Tunnel, TLS-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten Ihre Tunnel, SSL-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
"benefitSimplerOperations": {
"title": "Einfachere Operationen",
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
@@ -3235,7 +3235,7 @@
"uptimeAddAlert": "Warnmeldung hinzufügen",
"uptimeViewAlerts": "Warnungen anzeigen",
"uptimeCreateEmailAlert": "E-Mail Alarm erstellen",
"uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn dieser Standort offline oder wieder online ist.",
"uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn diese Seite offline oder wieder online ist.",
"uptimeAlertDescriptionResource": "Werde per E-Mail benachrichtigt, wenn diese Ressource offline oder wieder online ist.",
"uptimeAlertNamePlaceholder": "Alarmname",
"uptimeAdditionalEmails": "Zusätzliche E-Mails",

View File

@@ -255,23 +255,6 @@
"resourceGoTo": "Go to Resource",
"resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource",
"labelDelete": "Delete Label",
"labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully",
"labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name",
"labelColorField": "Label Color",
"labelPlaceholder": "Ex: homelab",
"labelCreate": "Create Label",
"createLabelDialogTitle": "Create Label",
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
"labelEdit": "Edit Label",
"editLabelDialogTitle": "Update Label",
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
"labelDeleteConfirm": "Confirm Delete Label",
"labelErrorDelete": "Failed to delete label",
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
"visibility": "Visibility",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -647,7 +630,7 @@
"createdAt": "Created At",
"proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.",
"proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.",
"proxyEnableSSL": "Enable TLS",
"proxyEnableSSL": "Enable SSL",
"proxyEnableSSLDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the targets.",
"target": "Target",
"configureTarget": "Configure Targets",
@@ -1157,15 +1140,6 @@
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found",
"inviteInvalid": "Invalid Invite",
"labels": "Labels",
"orgLabelsDescription": "Manage labels in this organization.",
"addLabels": "Add labels",
"siteLabelsTab": "Labels",
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelSearch": "Search labels",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user",
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
@@ -2076,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS",
"editInternalResourceDialogEnableSsl": "Enable SSL",
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"editInternalResourceDialogDestination": "Destination",
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
@@ -2126,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable TLS",
"createInternalResourceDialogEnableSsl": "Enable SSL",
"createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
@@ -2259,7 +2233,7 @@
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."

View File

@@ -630,7 +630,7 @@
"createdAt": "Creado el",
"proxyErrorInvalidHeader": "Valor de cabecera de host personalizado no válido. Utilice el formato de nombre de dominio, o guarde en blanco para desestablecer cabecera de host personalizada.",
"proxyErrorTls": "Nombre de servidor TLS inválido. Utilice el formato de nombre de dominio o guarde en blanco para eliminar el nombre de servidor TLS.",
"proxyEnableSSL": "Activar TLS",
"proxyEnableSSL": "Activar SSL",
"proxyEnableSSLDescription": "Habilita el cifrado SSL/TLS para conexiones seguras HTTPS a los objetivos.",
"target": "Target",
"configureTarget": "Configurar objetivos",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Esquema",
"editInternalResourceDialogEnableSsl": "Activar TLS",
"editInternalResourceDialogEnableSsl": "Activar SSL",
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
"editInternalResourceDialogDestination": "Destino",
"editInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Esquema",
"createInternalResourceDialogScheme": "Esquema",
"createInternalResourceDialogEnableSsl": "Activar TLS",
"createInternalResourceDialogEnableSsl": "Activar SSL",
"createInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
"createInternalResourceDialogDestination": "Destino",
"createInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
@@ -2233,7 +2233,7 @@
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
"introTitle": "Pangolin autogestionado",
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación TLS y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación SSL y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
"benefitSimplerOperations": {
"title": "Operaciones simples",
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."

View File

@@ -630,7 +630,7 @@
"createdAt": "Créé le",
"proxyErrorInvalidHeader": "Valeur d'en-tête Host personnalisée invalide. Utilisez le format de nom de domaine, ou laissez vide pour désactiver l'en-tête Host personnalisé.",
"proxyErrorTls": "Nom de serveur TLS invalide. Utilisez le format de nom de domaine, ou laissez vide pour supprimer le nom de serveur TLS.",
"proxyEnableSSL": "Activer TLS",
"proxyEnableSSL": "Activer SSL",
"proxyEnableSSLDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers les cibles.",
"target": "Cible",
"configureTarget": "Configurer les cibles",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Méthode HTTP",
"editInternalResourceDialogEnableSsl": "Activer TLS",
"editInternalResourceDialogEnableSsl": "Activer SSL",
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
"editInternalResourceDialogDestination": "Destination",
"editInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Méthode HTTP",
"createInternalResourceDialogScheme": "Méthode HTTP",
"createInternalResourceDialogEnableSsl": "Activer TLS",
"createInternalResourceDialogEnableSsl": "Activer SSL",
"createInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
"createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
@@ -2233,7 +2233,7 @@
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
"introTitle": "Pangolin auto-hébergé géré",
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin - vos tunnels, la terminaison TLS et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin - vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
"benefitSimplerOperations": {
"title": "Opérations plus simples",
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."

View File

@@ -630,7 +630,7 @@
"createdAt": "Creato Il",
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
"proxyEnableSSL": "Abilita TLS",
"proxyEnableSSL": "Abilita SSL",
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
"target": "Target",
"configureTarget": "Configura Risorse Interne",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Metodo HTTP",
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
"editInternalResourceDialogEnableSsl": "Abilitare SSL",
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
"editInternalResourceDialogDestination": "Destinazione",
"editInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Metodo HTTP",
"createInternalResourceDialogScheme": "Metodo HTTP",
"createInternalResourceDialogEnableSsl": "Abilitare TLS",
"createInternalResourceDialogEnableSsl": "Abilitare SSL",
"createInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
"createInternalResourceDialogDestination": "Destinazione",
"createInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.",
@@ -2233,7 +2233,7 @@
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin - i tunnel, la terminazione TLS e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin - i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
"benefitSimplerOperations": {
"title": "Operazioni più semplici",
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."

View File

@@ -630,7 +630,7 @@
"createdAt": "생성일",
"proxyErrorInvalidHeader": "잘못된 사용자 정의 호스트 헤더 값입니다. 도메인 이름 형식을 사용하거나 사용자 정의 호스트 헤더를 해제하려면 비워 두십시오.",
"proxyErrorTls": "유효하지 않은 TLS 서버 이름입니다. 도메인 이름 형식을 사용하거나 비워 두어 TLS 서버 이름을 제거하십시오.",
"proxyEnableSSL": "TLS 활성화",
"proxyEnableSSL": "SSL 활성화",
"proxyEnableSSLDescription": "타겟과의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화를 활성화하세요.",
"target": "대상",
"configureTarget": "대상 구성",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "스킴",
"editInternalResourceDialogEnableSsl": "TLS 활성화",
"editInternalResourceDialogEnableSsl": "SSL 활성화",
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
"editInternalResourceDialogDestination": "대상지",
"editInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "스킴",
"createInternalResourceDialogScheme": "스킴",
"createInternalResourceDialogEnableSsl": "TLS 활성화",
"createInternalResourceDialogEnableSsl": "SSL 활성화",
"createInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
"createInternalResourceDialogDestination": "대상지",
"createInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.",
@@ -2233,7 +2233,7 @@
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
"introTitle": "관리 자체 호스팅 팡골린",
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, TLS 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, SSL 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
"benefitSimplerOperations": {
"title": "더 간단한 운영",
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."

View File

@@ -630,7 +630,7 @@
"createdAt": "Opprettet",
"proxyErrorInvalidHeader": "Ugyldig verdi for egendefinert vertsoverskrift. Bruk domenenavnformat, eller lagre tomt for å fjerne den egendefinerte vertsoverskriften.",
"proxyErrorTls": "Ugyldig TLS-servernavn. Bruk domenenavnformat, eller la stå tomt for å fjerne TLS-servernavnet.",
"proxyEnableSSL": "Aktiver TLS",
"proxyEnableSSL": "Aktiver SSL",
"proxyEnableSSLDescription": "Aktivere SSL/TLS-kryptering for sikker HTTPS tilkobling til målene.",
"target": "Target",
"configureTarget": "Konfigurer mål",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Skjema",
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
"editInternalResourceDialogEnableSsl": "Aktiver SSL",
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
"editInternalResourceDialogDestination": "Destinasjon",
"editInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Skjema",
"createInternalResourceDialogScheme": "Skjema",
"createInternalResourceDialogEnableSsl": "Aktiver TLS",
"createInternalResourceDialogEnableSsl": "Aktiver SSL",
"createInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
"createInternalResourceDialogDestination": "Destinasjon",
"createInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.",
@@ -2233,7 +2233,7 @@
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
"introTitle": "Administrert Self-Hosted Pangolin",
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, TLS-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, SSL-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
"benefitSimplerOperations": {
"title": "Enklere operasjoner",
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."

View File

@@ -630,7 +630,7 @@
"createdAt": "Aangemaakt op",
"proxyErrorInvalidHeader": "Ongeldige aangepaste Header waarde. Gebruik het domeinnaam formaat, of sla leeg op om de aangepaste Host header ongedaan te maken.",
"proxyErrorTls": "Ongeldige TLS servernaam. Gebruik de domeinnaam of sla leeg op om de TLS servernaam te verwijderen.",
"proxyEnableSSL": "TLS inschakelen",
"proxyEnableSSL": "SSL inschakelen",
"proxyEnableSSLDescription": "SSL/TLS-versleuteling inschakelen voor beveiligde HTTPS-verbindingen naar de doelen.",
"target": "Target",
"configureTarget": "Doelstellingen configureren",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Schema",
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
"editInternalResourceDialogEnableSsl": "SSL inschakelen",
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
"editInternalResourceDialogDestination": "Bestemming",
"editInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Schema",
"createInternalResourceDialogScheme": "Schema",
"createInternalResourceDialogEnableSsl": "TLS inschakelen",
"createInternalResourceDialogEnableSsl": "SSL inschakelen",
"createInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
"createInternalResourceDialogDestination": "Bestemming",
"createInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.",
@@ -2233,7 +2233,7 @@
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
"introTitle": "Beheerde zelfgehoste pangolin",
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, TLS-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, SSL-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
"benefitSimplerOperations": {
"title": "Simpler operaties",
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."

View File

@@ -630,7 +630,7 @@
"createdAt": "Utworzono",
"proxyErrorInvalidHeader": "Nieprawidłowa wartość niestandardowego nagłówka hosta. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć niestandardowy nagłówek hosta.",
"proxyErrorTls": "Nieprawidłowa nazwa serwera TLS. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć nazwę serwera TLS.",
"proxyEnableSSL": "Włącz TLS",
"proxyEnableSSL": "Włącz SSL",
"proxyEnableSSLDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z celami.",
"target": "Target",
"configureTarget": "Konfiguruj Targety",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Schemat",
"editInternalResourceDialogEnableSsl": "Włącz TLS",
"editInternalResourceDialogEnableSsl": "Włącz SSL",
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
"editInternalResourceDialogDestination": "Miejsce docelowe",
"editInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Schemat",
"createInternalResourceDialogScheme": "Schemat",
"createInternalResourceDialogEnableSsl": "Włącz TLS",
"createInternalResourceDialogEnableSsl": "Włącz SSL",
"createInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
"createInternalResourceDialogDestination": "Miejsce docelowe",
"createInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
@@ -2233,7 +2233,7 @@
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
"introTitle": "Zarządzany samowystarczalny Pangolin",
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin - tunele, zakończenie TLS i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin - tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
"benefitSimplerOperations": {
"title": "Uproszczone operacje",
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."

View File

@@ -630,7 +630,7 @@
"createdAt": "Criado Em",
"proxyErrorInvalidHeader": "Valor do cabeçalho Host personalizado inválido. Use o formato de nome de domínio ou salve vazio para remover o cabeçalho Host personalizado.",
"proxyErrorTls": "Nome do Servidor TLS inválido. Use o formato de nome de domínio ou salve vazio para remover o Nome do Servidor TLS.",
"proxyEnableSSL": "Habilitar TLS",
"proxyEnableSSL": "Habilitar SSL",
"proxyEnableSSLDescription": "Habilitar criptografia SSL/TLS para conexões HTTPS seguras aos alvos.",
"target": "Target",
"configureTarget": "Configurar Alvos",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Esquema",
"editInternalResourceDialogEnableSsl": "Ativar TLS",
"editInternalResourceDialogEnableSsl": "Ativar SSL",
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
"editInternalResourceDialogDestination": "Destino",
"editInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Esquema",
"createInternalResourceDialogScheme": "Esquema",
"createInternalResourceDialogEnableSsl": "Ativar TLS",
"createInternalResourceDialogEnableSsl": "Ativar SSL",
"createInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
"createInternalResourceDialogDestination": "Destino",
"createInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.",
@@ -2233,7 +2233,7 @@
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin - seus túneis, terminação TLS e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin - seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
"benefitSimplerOperations": {
"title": "Operações simples",
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."

View File

@@ -630,7 +630,7 @@
"createdAt": "Создано в",
"proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.",
"proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.",
"proxyEnableSSL": "Включить TLS",
"proxyEnableSSL": "Включить SSL",
"proxyEnableSSLDescription": "Включить шифрование SSL/TLS для безопасных HTTPS соединений с целями.",
"target": "Target",
"configureTarget": "Настроить адресаты",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Схема",
"editInternalResourceDialogEnableSsl": "Включить TLS",
"editInternalResourceDialogEnableSsl": "Включить SSL",
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
"editInternalResourceDialogDestination": "Пункт назначения",
"editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Схема",
"createInternalResourceDialogScheme": "Схема",
"createInternalResourceDialogEnableSsl": "Включить TLS",
"createInternalResourceDialogEnableSsl": "Включить SSL",
"createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.",
"createInternalResourceDialogDestination": "Пункт назначения",
"createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
@@ -2233,7 +2233,7 @@
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
"introTitle": "Управляемый Само-Хост Панголина",
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, TLS, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
"benefitSimplerOperations": {
"title": "Более простые операции",
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."

View File

@@ -630,7 +630,7 @@
"createdAt": "Oluşturulma Tarihi",
"proxyErrorInvalidHeader": "Geçersiz özel Ana Bilgisayar Başlığı değeri. Alan adı formatını kullanın veya özel Ana Bilgisayar Başlığını ayarlamak için boş bırakın.",
"proxyErrorTls": "Geçersiz TLS Sunucu Adı. Alan adı formatını kullanın veya TLS Sunucu Adını kaldırmak için boş bırakılsın.",
"proxyEnableSSL": "TLS Etkinleştir",
"proxyEnableSSL": "SSL Etkinleştir",
"proxyEnableSSLDescription": "Hedeflere güvenli HTTPS bağlantıları için SSL/TLS şifrelemesini etkinleştirin.",
"target": "Hedef",
"configureTarget": "Hedefleri Yapılandır",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Şema",
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
"editInternalResourceDialogEnableSsl": "SSL'i Etkinleştir",
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
"editInternalResourceDialogDestination": "Hedef",
"editInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Şema",
"createInternalResourceDialogScheme": "Şema",
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
"createInternalResourceDialogEnableSsl": "SSL'i Etkinleştir",
"createInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
"createInternalResourceDialogDestination": "Hedef",
"createInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.",
@@ -2233,7 +2233,7 @@
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz - tünelleriniz, TLS bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz - tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
"benefitSimplerOperations": {
"title": "Daha basit işlemler",
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."

View File

@@ -630,7 +630,7 @@
"createdAt": "创建于",
"proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。",
"proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。",
"proxyEnableSSL": "启用 TLS",
"proxyEnableSSL": "启用 SSL",
"proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。",
"target": "Target",
"configureTarget": "配置目标",
@@ -2050,7 +2050,7 @@
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "方案",
"editInternalResourceDialogEnableSsl": "启用 TLS",
"editInternalResourceDialogEnableSsl": "启用 SSL",
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
"editInternalResourceDialogDestination": "目标",
"editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
@@ -2100,7 +2100,7 @@
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "方案",
"createInternalResourceDialogScheme": "方案",
"createInternalResourceDialogEnableSsl": "启用 TLS",
"createInternalResourceDialogEnableSsl": "启用 SSL",
"createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
"createInternalResourceDialogDestination": "目标",
"createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
@@ -2233,7 +2233,7 @@
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
"introTitle": "托管自托管的潘戈林公司",
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、TLS 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
"benefitSimplerOperations": {
"title": "简单的操作",
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"

View File

@@ -489,7 +489,7 @@
"createdAt": "創建於",
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式或將空保存為取消自訂 Header。",
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
"proxyEnableSSL": "啟用 TLS",
"proxyEnableSSL": "啟用 SSL",
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
"target": "目標",
"configureTarget": "配置目標",
@@ -1763,7 +1763,7 @@
"description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能",
"introTitle": "託管式自架 Pangolin",
"introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"benefitSimplerOperations": {
"title": "簡單的操作",
"description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"

View File

@@ -5,7 +5,6 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
reactStrictMode: false,
transpilePackages: ["@novnc/novnc"],
eslint: {
ignoreDuringBuilds: true
},

102
package-lock.json generated
View File

@@ -11,14 +11,11 @@
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -47,9 +44,6 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",
@@ -1064,6 +1058,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1465,16 +1460,6 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@devolutions/iron-remote-desktop": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
},
"node_modules/@devolutions/iron-remote-desktop-rdp": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
@@ -2369,6 +2354,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2391,6 +2377,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2413,6 +2400,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2429,6 +2417,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2445,6 +2434,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2461,6 +2451,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2477,6 +2468,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2493,6 +2485,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2509,6 +2502,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2525,6 +2519,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2541,6 +2536,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2557,6 +2553,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2579,6 +2576,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2601,6 +2599,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2623,6 +2622,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2645,6 +2645,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2667,6 +2668,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2689,6 +2691,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2711,6 +2714,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -2730,6 +2734,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2749,6 +2754,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2768,6 +2774,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3027,6 +3034,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -3646,12 +3654,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oslojs/asn1": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
@@ -6979,6 +6981,7 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -8439,6 +8442,7 @@
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -8554,6 +8558,7 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -8901,6 +8906,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -8996,6 +9002,7 @@
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -9023,6 +9030,7 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -9048,6 +9056,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9058,6 +9067,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -9144,8 +9154,7 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -9219,6 +9228,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -9673,27 +9683,6 @@
"win32"
]
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -9713,6 +9702,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10162,6 +10152,7 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -10233,6 +10224,7 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -10361,6 +10353,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -11267,6 +11260,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -11707,7 +11701,6 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -12342,6 +12335,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -12427,6 +12421,7 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -12563,6 +12558,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -12956,6 +12952,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -15373,7 +15370,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -15384,7 +15380,6 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -15473,6 +15468,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
@@ -16432,6 +16428,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -16939,6 +16936,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16970,6 +16968,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -17262,6 +17261,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -18723,7 +18723,8 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -19198,6 +19199,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19625,6 +19627,7 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -19831,6 +19834,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -34,14 +34,11 @@
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -70,9 +67,6 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",

View File

@@ -148,22 +148,11 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets"
listHealthChecks = "listHealthChecks"
}
export async function checkUserActionPermission(

View File

@@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: varchar("authToken").notNull(),
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
destination: varchar("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -159,93 +159,9 @@ export const resources = pgTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false),
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
wildcard: boolean("wildcard").notNull().default(false)
});
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
color: varchar("color").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = pgTable(
"siteLabels",
{
siteLabelId: serial("siteLabelId").primaryKey(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = pgTable(
"resourceLabels",
{
resourceLabelId: serial("resourceLabelId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = pgTable(
"siteResourceLabels",
{
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = pgTable(
"clientLabels",
{
clientLabelId: serial("clientLabelId").primaryKey(),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = pgTable("targets", {
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
@@ -280,11 +196,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
@@ -1183,30 +1097,19 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false)
});
export const statusHistory = pgTable(
"statusHistory",
{
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull()
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1276,4 +1179,3 @@ export type RoundTripMessageTracker = InferSelectModel<
>;
export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: text("authToken").notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -180,99 +180,9 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
});
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
@@ -309,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -1288,30 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1381,4 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -24,12 +24,10 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels"
WildcardSubdomain = "wildcardSubdomain"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],

View File

@@ -20,7 +20,9 @@ import {
} from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
import {
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers";
import {
initPeerAddHandshake,
deletePeer as olmDeletePeer
@@ -31,7 +33,7 @@ import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargetV2,
parseEndpoint
parseEndpoint,
} from "@server/lib/ip";
import {
addPeerData,
@@ -49,7 +51,10 @@ export async function getClientSiteResourceAccess(
? await trx
.select()
.from(sites)
.innerJoin(siteNetworks, eq(siteNetworks.siteId, sites.siteId))
.innerJoin(
siteNetworks,
eq(siteNetworks.siteId, sites.siteId)
)
.where(eq(siteNetworks.networkId, siteResource.networkId))
.then((rows) => rows.map((row) => row.sites))
: [];
@@ -357,8 +362,7 @@ export async function rebuildClientAssociationsFromSiteResource(
.where(inArray(clients.clientId, existingClientSiteIds))
: [];
const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
@@ -705,7 +709,7 @@ export async function updateClientSiteDestinations(
sourcePort: destination.sourcePort,
destinations: destination.destinations
};
logger.debug(
logger.info(
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
);

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache";
import { regionalCache as cache } from "@server/private/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
entityId: number
): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = cache.keys().filter((k) => k.startsWith(prefix));
const keys = await cache.keysWithPrefix(prefix);
if (keys.length > 0) {
await cache.del(keys);
}

View File

@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
}
}
// logger.debug(
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
// );
logger.debug(
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
);
for (const domain of allDomains) {
try {

View File

@@ -13,7 +13,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { redisManager } from "@server/private/lib/redis";
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
@@ -298,3 +298,147 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
export default cache;
/**
* Regional adaptive cache backed by the in-cluster Redis instance.
* Falls back to a local NodeCache when the regional Redis is unavailable.
* Use this for data that is regional in nature (e.g. status history) so
* reads are served from the same cluster the user is hitting.
*/
const regionalLocalCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
class RegionalAdaptiveCache {
private useRedis(): boolean {
return (
regionalRedisManager.isRedisEnabled() &&
regionalRedisManager.getHealthStatus().isHealthy
);
}
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
if (this.useRedis()) {
try {
const serialized = JSON.stringify(value);
const success = await regionalRedisManager.set(
key,
serialized,
redisTtl
);
if (success) {
logger.debug(`[regional] Set key in Redis: ${key}`);
return true;
}
} catch (error) {
logger.error(
`[regional] Redis set error for key ${key}:`,
error
);
}
}
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
return success;
}
async get<T = any>(key: string): Promise<T | undefined> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
if (value !== null) {
logger.debug(`[regional] Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`[regional] Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(
`[regional] Redis get error for key ${key}:`,
error
);
}
}
const value = regionalLocalCache.get<T>(key);
if (value !== undefined) {
logger.debug(`[regional] Cache hit in local cache: ${key}`);
} else {
logger.debug(`[regional] Cache miss in local cache: ${key}`);
}
return value;
}
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
if (this.useRedis()) {
try {
for (const k of keys) {
const success = await regionalRedisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`[regional] Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) return deletedCount;
deletedCount = 0;
} catch (error) {
logger.error(`[regional] Redis del error:`, error);
deletedCount = 0;
}
}
for (const k of keys) {
const count = regionalLocalCache.del(k);
if (count > 0) {
deletedCount++;
logger.debug(`[regional] Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
async has(key: string): Promise<boolean> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
return value !== null;
} catch (error) {
logger.error(
`[regional] Redis has error for key ${key}:`,
error
);
}
}
return regionalLocalCache.has(key);
}
/**
* Returns keys matching the given prefix from whichever backend is active.
* Redis uses a KEYS scan; local cache filters in-memory keys.
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
if (this.useRedis()) {
try {
return await regionalRedisManager.keys(`${prefix}*`);
} catch (error) {
logger.error(`[regional] Redis keys error:`, error);
}
}
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
}
getCurrentBackend(): "redis" | "local" {
return this.useRedis() ? "redis" : "local";
}
}
export const regionalCache = new RegionalAdaptiveCache();

View File

@@ -24,8 +24,7 @@ import { LogStreamingManager } from "./LogStreamingManager";
*/
export const logStreamingManager = new LogStreamingManager();
if (build !== "saas") {
// this is handled separately in the saas build, so we don't want to start it here
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
logStreamingManager.start();
}

View File

@@ -73,6 +73,25 @@ export const privateConfigSchema = z
.object({
rejectUnauthorized: z.boolean().optional().default(true)
})
.optional(),
regional_redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
.optional()
})
.optional(),

View File

@@ -109,14 +109,14 @@ class RedisManager {
password: redisConfig.password,
db: redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -135,14 +135,14 @@ class RedisManager {
password: replica.password,
db: replica.db || redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -855,3 +855,163 @@ class RedisManager {
export const redisManager = new RedisManager();
export const redis = redisManager.getClient();
export default redisManager;
/**
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
* Connects only when `redis.regional_redis` is present in the private config
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
* caching of regionally-scoped data.
*/
class RegionalRedisManager {
private writeClient: Redis | null = null;
private readClient: Redis | null = null;
private isEnabled: boolean = false;
private isHealthy: boolean = false;
private connectionTimeout: number = 5000;
private commandTimeout: number = 5000;
constructor() {
if (build === "oss") return;
const cfg = privateConfig.getRawPrivateConfig();
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
this.isEnabled = true;
this.initializeClients();
}
private getConfig(): RedisOptions {
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
const opts: RedisOptions = {
host: r.host,
port: r.port,
password: r.password,
db: r.db
};
if (r.tls) {
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
}
return opts;
}
private initializeClients(): void {
const cfg = this.getConfig();
const baseOpts = {
...cfg,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 10000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
};
try {
this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
// For simplicity use same host for both; callers can always read from primary
// The real replica routing is handled by the StatefulSet headless service
this.readClient = this.writeClient;
this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready");
this.isHealthy = true;
});
this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err);
this.isHealthy = false;
});
this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting...");
this.isHealthy = false;
});
logger.info("Regional Redis client initialized");
} catch (error) {
logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false;
}
}
public isRedisEnabled(): boolean {
return this.isEnabled && this.writeClient !== null && this.isHealthy;
}
public getHealthStatus() {
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
}
public async set(
key: string,
value: string,
ttl?: number
): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
if (ttl) {
await this.writeClient.setex(key, ttl, value);
} else {
await this.writeClient.set(key, value);
}
return true;
} catch (error) {
logger.error("Regional Redis SET error:", error);
return false;
}
}
public async get(key: string): Promise<string | null> {
if (!this.isRedisEnabled() || !this.readClient) return null;
try {
return await this.readClient.get(key);
} catch (error) {
logger.error("Regional Redis GET error:", error);
return null;
}
}
public async del(key: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
await this.writeClient.del(key);
return true;
} catch (error) {
logger.error("Regional Redis DEL error:", error);
return false;
}
}
public async keys(pattern: string): Promise<string[]> {
if (!this.isRedisEnabled() || !this.readClient) return [];
try {
return await this.readClient.keys(pattern);
} catch (error) {
logger.error("Regional Redis KEYS error:", error);
return [];
}
}
public async disconnect(): Promise<void> {
try {
if (this.writeClient) {
await this.writeClient.quit();
this.writeClient = null;
}
this.readClient = null;
logger.info("Regional Redis client disconnected");
} catch (error) {
logger.error("Error disconnecting regional Redis client:", error);
}
}
}
export const regionalRedisManager = new RegionalRedisManager();

View File

@@ -12,7 +12,6 @@
*/
import {
browserGatewayTarget,
certificates,
db,
domainNamespaces,
@@ -278,115 +277,6 @@ export async function getTraefikConfig(
});
});
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Browser gateway target fields
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
// Group browser gateway targets by resource
type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
targets: {
browserGatewayTargetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
siteExitNodeId: number | null;
}[];
};
const browserGatewayResourcesMap = new Map<
number,
BrowserGatewayResourceEntry
>();
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
targets: []
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
@@ -434,12 +324,6 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain);
}
}
// Include browser gateway resource domains
for (const bgResource of browserGatewayResourcesMap.values()) {
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
domains.add(bgResource.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -705,7 +589,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
@@ -1041,185 +925,6 @@ export async function getTraefikConfig(
}
}
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
);
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
@@ -1335,7 +1040,7 @@ export async function getTraefikConfig(
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 101,
tls
};
@@ -1438,7 +1143,7 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.https_entrypoint
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 203,
tls: tls
};

View File

@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
next: NextFunction
): Promise<any> {
try {
if (build !== "saas") {
if (build != "saas") {
return next();
}

View File

@@ -1,187 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
resources,
sites
} from "@server/db";
import { eq, and } 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 { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive(),
type: z.enum(["ssh", "rdp", "vnc"]),
destination: z.string().nonempty(),
destinationPort: z.number().int().min(1).max(65535)
});
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "put",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
description: "Create a browser gateway target for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createBrowserGatewayTarget(
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 { orgId, resourceId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found in organization ${orgId}`
)
);
}
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
const [record] = await db
.insert(browserGatewayTarget)
.values({
resourceId,
siteId,
type,
destination,
destinationPort,
authToken: encryptedToken
})
.returning();
if (site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[record],
newt.version
);
}
}
logger.info(
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
);
return response<CreateBrowserGatewayTargetResponse>(res, {
data: record,
success: true,
error: false,
message: "Browser gateway target created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create browser gateway target"
)
);
}
}

View File

@@ -1,130 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db, newts, sites } from "@server/db";
import { eq, and } 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 { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Delete a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteBrowserGatewayTarget(
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 { orgId, browserGatewayTargetId } = parsedParams.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
await db
.delete(browserGatewayTarget)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
);
if (existing.site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.bgt.siteId))
.limit(1);
if (newt) {
await removeBrowserGatewayTarget(
newt.newtId,
browserGatewayTargetId,
newt.version
);
}
}
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
return response(res, {
data: null,
success: true,
error: false,
message: "Browser gateway target deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete browser gateway target"
)
);
}
}

View File

@@ -1,109 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "get",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Get a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function getBrowserGatewayTarget(
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 { orgId, browserGatewayTargetId } = parsedParams.data;
const [result] = await db
.select({ bgt: browserGatewayTarget })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!result) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
return response<GetBrowserGatewayTargetResponse>(res, {
data: result.bgt,
success: true,
error: false,
message: "Browser gateway target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve browser gateway target"
)
);
}
}

View File

@@ -1,18 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createBrowserGatewayTarget";
export * from "./updateBrowserGatewayTarget";
export * from "./deleteBrowserGatewayTarget";
export * from "./getBrowserGatewayTarget";
export * from "./listBrowserGatewayTargets";

View File

@@ -1,148 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const querySchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export type ListBrowserGatewayTargetsResponse = {
targets: BrowserGatewayTarget[];
total: number;
limit: number;
offset: number;
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
description: "List browser gateway targets for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function listBrowserGatewayTargets(
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 { orgId, resourceId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const targets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.resourceId, resourceId))
.limit(limit)
.offset(offset);
return response<ListBrowserGatewayTargetsResponse>(res, {
data: {
targets: targets,
total: targets.length,
limit,
offset
},
success: true,
error: false,
message: "Browser gateway targets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to list browser gateway targets"
)
);
}
}

View File

@@ -1,180 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
sites
} from "@server/db";
import { eq, and } 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 { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive().optional(),
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
destination: z.string().nonempty().optional(),
destinationPort: z.number().int().min(1).max(65535).optional()
});
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "post",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Update a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateBrowserGatewayTarget(
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 { orgId, browserGatewayTargetId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
const updateValues: Partial<BrowserGatewayTarget> = {};
if (siteId !== undefined) updateValues.siteId = siteId;
if (type !== undefined) updateValues.type = type;
if (destination !== undefined) updateValues.destination = destination;
if (destinationPort !== undefined)
updateValues.destinationPort = destinationPort;
const [updated] = await db
.update(browserGatewayTarget)
.set(updateValues)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
)
.returning();
const targetSiteId = siteId ?? existing.bgt.siteId;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, targetSiteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, targetSiteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[updated],
newt.version
);
}
}
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
return response<UpdateBrowserGatewayTargetResponse>(res, {
data: updated,
success: true,
error: false,
message: "Browser gateway target updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update browser gateway target"
)
);
}
}

View File

@@ -31,8 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels";
import {
verifyOrgAccess,
@@ -734,59 +732,6 @@ authenticated.get(
alertRule.getAlertRule
);
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get(
"/org/:orgId/health-checks",
verifyValidLicense,
@@ -830,48 +775,3 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getTarget),
healthChecks.getHealthCheckStatusHistory
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import {
verifyApiKeyHasAction,
@@ -216,43 +215,3 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -1,224 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const attachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function attachLabelToItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = attachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteLabels)
.values({
labelId,
siteId
})
.onConflictDoNothing();
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(resourceLabels)
.values({
labelId,
resourceId
})
.onConflictDoNothing();
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteResourceLabels)
.values({
labelId,
siteResourceId
})
.onConflictDoNothing();
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(clientLabels)
.values({
labelId,
clientId
})
.onConflictDoNothing();
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label attached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,149 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
db,
labels,
resourceLabels,
resources,
siteLabels,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty(),
siteId: z.number().int().optional(),
resourceId: z.number().int().optional()
});
export async function createOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, color, siteId, resourceId } = parsedBody.data;
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Site with Id ${siteId} doesn't exist.`
)
);
}
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
}
const label = await db.transaction(async (tx) => {
const [label] = await tx
.insert(labels)
.values({
name,
color,
orgId
})
.returning();
if (siteId) {
await tx.insert(siteLabels).values({
siteId,
labelId: label.labelId
});
}
if (resourceId) {
await tx.insert(resourceLabels).values({
resourceId,
labelId: label.labelId
});
}
return label;
});
return response<CreateOrEditLabelResponse>(res, {
data: { label },
success: true,
error: false,
message: "Org Label created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,72 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
export async function deleteOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
await db
.delete(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
return response(res, {
data: null,
success: true,
error: false,
message: "Label deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,224 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const detachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function detachLabelFromItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = detachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
await db
.delete(siteLabels)
.where(
and(
eq(siteLabels.labelId, labelId),
eq(siteLabels.siteId, siteId)
)
);
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
await db
.delete(resourceLabels)
.where(
and(
eq(resourceLabels.labelId, labelId),
eq(resourceLabels.resourceId, resourceId)
)
);
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
await db
.delete(siteResourceLabels)
.where(
and(
eq(siteResourceLabels.labelId, labelId),
eq(siteResourceLabels.siteResourceId, siteResourceId)
)
);
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
await db
.delete(clientLabels)
.where(
and(
eq(clientLabels.labelId, labelId),
eq(clientLabels.clientId, clientId)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label detached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,19 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";

View File

@@ -1,155 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, like, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listLabelsSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
function queryLabelsBase() {
return db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(labels);
}
export async function listOrgLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listLabelsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [and(eq(labels.orgId, orgId))];
if (query) {
conditions.push(
like(
sql`LOWER(${labels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = queryLabelsBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
queryLabelsBase()
.where(and(...conditions))
.as("filtered_labels")
);
const labelListQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(labels.name));
const [totalCount, rows] = await Promise.all([
countQuery,
labelListQuery
]);
return response<ListOrgLabelsResponse>(res, {
data: {
labels: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,101 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const updateLabelBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export async function updateOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = updateLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
const { name, color } = parsedBody.data;
const [label] = await db
.update(labels)
.set({
name,
color
})
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
.returning();
return response<CreateOrEditLabelResponse>(res, {
data: {
label
},
success: true,
error: false,
message: "Label updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -19,7 +19,6 @@ import {
logsDb,
newts,
roles,
roleSiteResources,
roundTripMessageTracker,
siteResources,
siteNetworks,
@@ -362,26 +361,9 @@ export async function signSshKey(
}
const roleRows = await db
.select({
sshSudoCommands: roles.sshSudoCommands,
sshUnixGroups: roles.sshUnixGroups,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshSudoMode: roles.sshSudoMode
})
.select()
.from(roles)
.innerJoin(
roleSiteResources,
eq(roleSiteResources.roleId, roles.roleId)
)
.where(
and(
inArray(roles.roleId, roleIds),
eq(
roleSiteResources.siteResourceId,
resource.siteResourceId
)
)
);
.where(inArray(roles.roleId, roleIds));
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
@@ -397,17 +379,13 @@ export async function signSshKey(
}
try {
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 {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
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";
}
}

View File

@@ -1,20 +1,15 @@
import {
clientLabels,
clients,
clientSitesAssociationsCache,
currentFingerprint,
db,
labels,
olms,
orgs,
roleClients,
sites,
userClients,
users,
type Label
users
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -174,7 +169,6 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
siteNiceId: string | null;
}>;
olmUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
};
type OlmWithUpdateAvailable = ClientWithSites;
@@ -261,11 +255,6 @@ export async function listClients(
(client) => client.clientId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter
const conditions = [
and(
@@ -299,29 +288,18 @@ export async function listClients(
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${clients.name})`, q),
like(sql`LOWER(${clients.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
conditions.push(
or(
like(
sql`LOWER(${clients.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${clients.niceId})`,
"%" + query.toLowerCase() + "%"
)
);
}
conditions.push(or(...queryList));
)
);
}
const baseQuery = queryClientsBase().where(and(...conditions));
@@ -348,30 +326,6 @@ export async function listClients(
const clientIds = clientsList.map((client) => client.clientId);
const siteAssociations = await getSiteAssociations(clientIds);
let labelsForClients: Array<{
labelId: number;
name: string;
color: string;
clientId: number;
}> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) {
labelsForClients = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
clientId: clientLabels.clientId
})
.from(labels)
.innerJoin(
clientLabels,
eq(clientLabels.labelId, labels.labelId)
)
.where(inArray(clientLabels.clientId, clientIds))
.orderBy(asc(clientLabels.clientLabelId));
}
// Group site associations by client ID
const sitesByClient = siteAssociations.reduce(
(acc, association) => {
@@ -399,10 +353,7 @@ export async function listClients(
const clientsWithSites = clientsList.map((client) => {
return {
...client,
sites: sitesByClient[client.clientId] || [],
labels: labelsForClients.filter(
(l) => l.clientId === client.clientId
)
sites: sitesByClient[client.clientId] || []
};
});

View File

@@ -11,7 +11,7 @@ import {
ExitNode
} from "@server/db";
import { db } from "@server/db";
import { eq, inArray } from "drizzle-orm";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
@@ -97,119 +97,86 @@ export async function generateRelayMappings(exitNode: ExitNode) {
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
const mappings: { [key: string]: ProxyMapping } = {};
// Track destinations per endpoint to deduplicate in O(1).
const seen = new Map<string, Set<string>>();
const addDestination = (endpoint: string, dest: PeerDestination) => {
let destSet = seen.get(endpoint);
if (!destSet) {
destSet = new Set();
seen.set(endpoint, destSet);
mappings[endpoint] = { destinations: [] };
// Process each site
for (const site of sitesRes) {
if (!site.endpoint || !site.subnet || !site.listenPort) {
continue;
}
const key = `${dest.destinationIP}:${dest.destinationPort}`;
if (!destSet.has(key)) {
destSet.add(key);
mappings[endpoint].destinations.push(dest);
}
};
// Process each site using the pre-fetched data.
for (const site of validSites) {
const siteDestination: PeerDestination = {
destinationIP: site.subnet!.split("/")[0],
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
};
// Find all clients associated with this site through clientSites
const clientSitesRes = await db
.select()
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, site.siteId));
// Add this site as a destination for each associated client.
const clientSites = clientSitesBySite.get(site.siteId);
if (clientSites) {
for (const clientSite of clientSites) {
if (!clientSite.endpoint) {
continue;
}
addDestination(clientSite.endpoint, siteDestination);
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);
}
}
// Site-to-site communication (all sites in the same org).
if (site.orgId != null) {
const peers = sitesByOrg.get(site.orgId);
if (peers) {
for (const peer of peers) {
if (peer.siteId === site.siteId) {
continue;
}
addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0],
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
});
// Also handle site-to-site communication (all sites in the same org)
if (site.orgId) {
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, site.orgId));
for (const peer of orgSites) {
// Skip self
if (
peer.siteId === site.siteId ||
!peer.endpoint ||
!peer.subnet ||
!peer.listenPort
) {
continue;
}
// Add peer site as a destination for this site
if (!mappings[site.endpoint]) {
mappings[site.endpoint] = { destinations: [] };
}
const destination: PeerDestination = {
destinationIP: peer.subnet.split("/")[0],
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
};
// Check for duplicates
const isDuplicate = mappings[site.endpoint].destinations.some(
(dest) =>
dest.destinationIP === destination.destinationIP &&
dest.destinationPort === destination.destinationPort
);
if (!isDuplicate) {
mappings[site.endpoint].destinations.push(destination);
}
}
}

View File

@@ -11,7 +11,7 @@ import {
ExitNode
} from "@server/db";
import { db } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
@@ -185,20 +185,16 @@ export async function updateAndGenerateEndpointDestinations(
const sitesOnExitNode = await db
.select({
siteId: sites.siteId,
newtId: newts.newtId,
subnet: sites.subnet,
listenPort: sites.listenPort,
publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint,
isRelayed: clientSitesAssociationsCache.isRelayed,
isJitMode: clientSitesAssociationsCache.isJitMode
endpoint: clientSitesAssociationsCache.endpoint
})
.from(sites)
.innerJoin(
clientSitesAssociationsCache,
eq(sites.siteId, clientSitesAssociationsCache.siteId)
)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.exitNodeId, exitNode.exitNodeId),
@@ -206,36 +202,24 @@ export async function updateAndGenerateEndpointDestinations(
)
);
// 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;
// Update clientSites for each site on this exit node
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 (
site.endpoint === formattedEndpoint &&
site.publicKey === publicKey
) {
continue;
}
siteIdsToUpdate.push(site.siteId);
if (!site.isRelayed && !site.isJitMode) {
sitesWithNewtsToUpdate.push({
siteId: site.siteId,
newtId: site.newtId
});
}
if (site.endpoint !== formattedEndpoint) {
endpointChanged = true;
}
}
if (siteIdsToUpdate.length > 0) {
// Single bulk update for all affected rows for this client on this exit node
await db
const [updatedClientSitesAssociationsCache] = await db
.update(clientSitesAssociationsCache)
.set({
endpoint: formattedEndpoint,
@@ -244,30 +228,24 @@ export async function updateAndGenerateEndpointDestinations(
.where(
and(
eq(clientSitesAssociationsCache.clientId, olm.clientId),
inArray(
clientSitesAssociationsCache.siteId,
siteIdsToUpdate
)
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
)
.returning();
// Only trigger downstream peer updates once per hole punch: the
// endpoint is the same for every site on this exit node, and
// handleClientEndpointChange already fans out to all connected
// sites for this client.
if (endpointChanged && updatedClient.pubKey === publicKey) {
if (
updatedClientSitesAssociationsCache.endpoint !==
site.endpoint && // this is the endpoint from the join table not the site
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
) {
logger.info(
`ClientSitesAssociationsCache for client ${olm.clientId} endpoint changed to ${formattedEndpoint} for ${siteIdsToUpdate.length} site(s) on exit node ${exitNode.exitNodeId}`
`ClientSitesAssociationsCache for client ${olm.clientId} and site ${site.siteId} endpoint changed from ${site.endpoint} to ${updatedClientSitesAssociationsCache.endpoint}`
);
// Handle any additional logic for endpoint change
handleClientEndpointChange(
sitesWithNewtsToUpdate,
olm.clientId,
formattedEndpoint
).catch((error) => {
logger.error(
`Failed to handle client endpoint change for client ${olm.clientId}: ${error}`
);
});
updatedClientSitesAssociationsCache.endpoint!
);
}
}
@@ -358,14 +336,59 @@ export async function updateAndGenerateEndpointDestinations(
`Site ${newt.siteId} endpoint changed from ${site.endpoint} to ${updatedSite.endpoint}`
);
// Handle any additional logic for endpoint change
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!).catch(
(error) => {
logger.error(
`Failed to handle site endpoint change for site ${newt.siteId}: ${error}`
);
}
);
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
}
// 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;
}
@@ -385,14 +408,12 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
return;
}
// Get all non-relayed and not jit clients connected to this site
// Get all non-relayed clients connected to this site
const connectedClients = await db
.select({
online: clients.online,
clientId: clients.clientId,
olmId: olms.olmId,
isRelayed: clientSitesAssociationsCache.isRelayed,
isJitMode: clientSitesAssociationsCache.isJitMode
isRelayed: clientSitesAssociationsCache.isRelayed
})
.from(clientSitesAssociationsCache)
.innerJoin(
@@ -402,36 +423,32 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
.innerJoin(olms, eq(olms.clientId, clients.clientId))
.where(
and(
eq(clients.online, true), // the client has to be online or it does not matter...
eq(clientSitesAssociationsCache.siteId, siteId),
eq(clientSitesAssociationsCache.isRelayed, false),
eq(clientSitesAssociationsCache.isJitMode, false)
eq(clientSitesAssociationsCache.isRelayed, false)
)
);
// Update each non-relayed client with the new site endpoint (in parallel)
await Promise.allSettled(
connectedClients.map(async (client) => {
try {
await updateOlmPeer(
client.clientId,
{
siteId: siteId,
publicKey: site.publicKey!,
endpoint: newEndpoint
},
client.olmId
);
logger.debug(
`Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update client ${client.clientId} with new site endpoint: ${error}`
);
}
})
);
// Update each non-relayed client with the new site endpoint
for (const client of connectedClients) {
try {
await updateOlmPeer(
client.clientId,
{
siteId: siteId,
publicKey: site.publicKey,
endpoint: newEndpoint
},
client.olmId
);
logger.debug(
`Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update client ${client.clientId} with new site endpoint: ${error}`
);
}
}
} catch (error) {
logger.error(
`Error handling site endpoint change for site ${siteId}: ${error}`
@@ -440,11 +457,10 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
}
async function handleClientEndpointChange(
sitesWithNewtsToUpdate: { siteId: number; newtId: string }[],
clientId: number,
newEndpoint: string
) {
// Alert all sites connected to this client that the endpoint has changed (only if NOT relayed and NOT JIT MODE)
// Alert all sites connected to this client that the endpoint has changed (only if NOT relayed)
try {
// Get client details
const [client] = await db
@@ -458,42 +474,58 @@ async function handleClientEndpointChange(
return;
}
if (sitesWithNewtsToUpdate.length > 250) {
logger.warn(
`Client ${clientId} has ${sitesWithNewtsToUpdate.length} connected sites so the client will be in jit mode anyway, skipping endpoint updates`
);
return;
}
// Update each non-relayed site with the new client endpoint (in parallel)
await Promise.allSettled(
sitesWithNewtsToUpdate.map(async ({ siteId, newtId }) => {
if (!client.pubKey) {
logger.warn(
`Client ${clientId} has no public key, skipping update for site ${siteId}`
);
return;
}
try {
await updateNewtPeer(
siteId,
client.pubKey,
{
endpoint: newEndpoint
},
newtId
);
logger.debug(
`Updated site ${siteId} with new client ${clientId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update site ${siteId} with new client endpoint: ${error}`
);
}
// Get all non-relayed sites connected to this client
const connectedSites = await db
.select({
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)
)
);
// Update each non-relayed site with the new client endpoint
for (const siteData of connectedSites) {
try {
if (!siteData.subnet) {
logger.warn(
`Client ${clientId} has no subnet, skipping update for site ${siteData.siteId}`
);
continue;
}
await updateNewtPeer(
siteData.siteId,
client.pubKey,
{
endpoint: newEndpoint
},
siteData.newtId
);
logger.debug(
`Updated site ${siteData.siteId} with new client ${clientId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update site ${siteData.siteId} with new client endpoint: ${error}`
);
}
}
} catch (error) {
logger.error(
`Error handling client endpoint change for client ${clientId}: ${error}`

View File

@@ -42,8 +42,6 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
// Gerbil routes
const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter);

View File

@@ -1,10 +0,0 @@
import type { Label } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListOrgLabelsResponse = PaginatedResponse<{
labels: Omit<Label, "orgId">[];
}>;
export type CreateOrEditLabelResponse = {
label: Label;
};

View File

@@ -1,6 +1,4 @@
import {
browserGatewayTarget,
BrowserGatewayTarget,
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
@@ -18,7 +16,6 @@ import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
formatEndpoint,
generateSubnetProxyTargetV2,
@@ -236,11 +233,6 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
const allBrowserGatewayTargets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
// Filter out invalid targets
@@ -312,22 +304,9 @@ export async function buildTargetConfigurationForNewtClient(
(target) => target !== null
);
const serverSecret = config.getRawConfig().server.secret!;
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
const decryptAuthToken = decrypt(t.authToken, serverSecret);
return {
id: t.browserGatewayTargetId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
authToken: decryptAuthToken
};
});
return {
validHealthCheckTargets,
tcpTargets,
udpTargets,
browserGatewayTargets
udpTargets
};
}

View File

@@ -43,13 +43,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const siteId = newt.siteId;
const {
publicKey,
pingResults,
newtVersion,
backwardsCompatible,
chainId
} = message.data;
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
message.data;
if (!publicKey) {
logger.warn("Public key not provided");
return;
@@ -196,12 +191,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId));
}
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -221,7 +212,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
tcp: tcpTargets
},
healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets,
chainId: chainId
}
},

View File

@@ -9,12 +9,8 @@ import {
import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(site.siteId);
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
@@ -40,8 +36,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
},
healthCheckTargets: validHealthCheckTargets,
peers: peers,
clientTargets: targets,
browserGatewayTargets: browserGatewayTargets
clientTargets: targets
}
},
{

View File

@@ -1,9 +1,7 @@
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
import { Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export async function addTargets(
newtId: string,
@@ -241,55 +239,3 @@ export async function removeTargets(
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function sendBrowserGatewayTargets(
newtId: string,
targets: BrowserGatewayTarget[],
version?: string | null
) {
if (targets.length === 0) return;
const payload = targets.map((t) => {
const decryptAuthToken = decrypt(
t.authToken,
config.getRawConfig().server.secret!
);
return {
id: t.browserGatewayTargetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
authToken: decryptAuthToken
};
});
await sendToClient(
newtId,
{
type: "newt/browsergateway/add",
data: {
targets: payload
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeBrowserGatewayTarget(
newtId: string,
browserGatewayTargetId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: "newt/browsergateway/remove",
data: {
ids: [browserGatewayTargetId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}

View File

@@ -5,7 +5,6 @@ import {
db,
exitNodes,
networks,
SiteResource,
siteNetworks,
siteResources,
sites
@@ -16,7 +15,7 @@ import {
generateRemoteSubnets
} from "@server/lib/ip";
import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config";
@@ -28,11 +27,11 @@ export async function buildSiteConfigurationForOlmClient(
) {
const siteConfigurations: {
siteId: number;
name?: string;
endpoint?: string;
publicKey?: string;
serverIP?: string | null;
serverPort?: number | null;
name?: string
endpoint?: string
publicKey?: string
serverIP?: string | null
serverPort?: number | null
remoteSubnets?: string[];
aliases: Alias[];
}[] = [];
@@ -47,79 +46,50 @@ export async function buildSiteConfigurationForOlmClient(
)
.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
for (const {
sites: site,
clientSitesAssociationsCache: association
} of sitesData) {
const allSiteResources = siteResourcesBySiteId.get(site.siteId) ?? [];
const allSiteResources = await db // only get the site resources that this client has access to
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.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) {
// Add site configuration to the array
siteConfigurations.push({
siteId: site.siteId,
// remoteSubnets: generateRemoteSubnets(allSiteResources),
aliases: generateAliasConfig(allSiteResources)
// remoteSubnets: generateRemoteSubnets(
// allSiteResources.map(({ siteResources }) => siteResources)
// ),
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
)
});
continue;
}
@@ -139,9 +109,10 @@ export async function buildSiteConfigurationForOlmClient(
continue;
}
if (!site.publicKey || site.publicKey == "") {
// the site is not ready to accept new peers
logger.warn(`Site ${site.siteId} has no public key, skipping`);
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
logger.warn(
`Site ${site.siteId} has no public key, skipping`
);
continue;
}
@@ -157,7 +128,7 @@ export async function buildSiteConfigurationForOlmClient(
logger.info(
`Public key mismatch. Deleting old peer from site ${site.siteId}...`
);
peerOps.push(deletePeer(site.siteId, client.pubKey!));
await deletePeer(site.siteId, client.pubKey!);
}
if (!site.subnet) {
@@ -165,19 +136,27 @@ export async function buildSiteConfigurationForOlmClient(
continue;
}
// Add the peer to the exit node for this site. The endpoint comes from
// the already-joined association row above, so no extra query needed.
if (association.endpoint && publicKey) {
const [clientSite] = await db
.select()
.from(clientSitesAssociationsCache)
.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(
`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
})
`Adding peer ${publicKey} to site ${site.siteId} with endpoint ${clientSite.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 {
logger.warn(
`Client ${client.clientId} has no endpoint, skipping peer addition`
@@ -186,12 +165,16 @@ export async function buildSiteConfigurationForOlmClient(
let relayEndpoint: string | undefined = undefined;
if (relay) {
const exitNode = exitNodesById.get(site.exitNodeId);
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
if (!exitNode) {
logger.warn(`Exit node not found for site ${site.siteId}`);
continue;
}
relayEndpoint = `${exitNode.endpoint}:${clientsStartPort}`;
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
}
// Add site configuration to the array
@@ -203,16 +186,12 @@ export async function buildSiteConfigurationForOlmClient(
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort,
remoteSubnets: generateRemoteSubnets(allSiteResources),
aliases: generateAliasConfig(allSiteResources)
});
}
// 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);
remoteSubnets: generateRemoteSubnets(
allSiteResources.map(({ siteResources }) => siteResources)
),
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
)
});
}

View File

@@ -8,7 +8,7 @@ import {
ExitNode,
exitNodes,
sites,
clientSitesAssociationsCache
clientSitesAssociationsCache,
} from "@server/db";
import { olms } from "@server/db";
import HttpCode from "@server/types/HttpCode";
@@ -28,7 +28,6 @@ import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
import { APP_VERSION } from "@server/lib/consts";
import { build } from "@server/build";
export const olmGetTokenBodySchema = z.object({
olmId: z.string(),
@@ -221,22 +220,6 @@ export async function getOlmToken(
)
.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
const exitNodeIds = Array.from(
new Set(

View File

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

View File

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

View File

@@ -9,50 +9,16 @@ import {
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { count, eq, inArray } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import { canCompress } from "@server/lib/clientVersionChecks";
import { build } from "@server/build";
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
const siteConfigurations = await buildSiteConfigurationForOlmClient(
client,
client.pubKey,
false,
jitMode
false
);
// Get all exit nodes from sites where the client has peers
@@ -116,6 +82,7 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
exitNodes: exitNodesData
}
},
{
compress: canCompress(olm.version, "olm")
}

View File

@@ -1,94 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } 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 { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export type GetBrowserTargetResponse = {
ip: string;
port: number;
authToken: string;
};
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
authToken: decryptAuthToken
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db, DB_TYPE } from "@server/db";
import { and, eq, or, inArray, sql } from "drizzle-orm";
import { db } from "@server/db";
import { and, eq, or, inArray } from "drizzle-orm";
import {
resources,
userResources,
@@ -12,9 +12,7 @@ import {
resourceWhitelist,
siteResources,
userSiteResources,
roleSiteResources,
siteNetworks,
sites
roleSiteResources
} from "@server/db";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -158,24 +156,9 @@ export async function getUserResources(
enabled: boolean;
alias: 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) {
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
siteResourcesData = await db
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
@@ -187,22 +170,9 @@ export async function getUserResources(
fullDomain: siteResources.fullDomain,
enabled: siteResources.enabled,
alias: siteResources.alias,
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)
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(
@@ -212,55 +182,7 @@ export async function getUserResources(
eq(siteResources.orgId, orgId),
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
@@ -338,14 +260,6 @@ export async function getUserResources(
enabled: siteResource.enabled,
alias: siteResource.alias,
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
};
});
@@ -388,19 +302,11 @@ export type GetUserResourcesResponse = {
destination: string;
mode: string;
protocol: string | null;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
siteOnlines: boolean[];
type: "site";
}>;
};

View File

@@ -33,4 +33,3 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getBrowserTarget";

View File

@@ -1,10 +1,7 @@
import {
browserGatewayTarget,
db,
labels,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourceLabels,
resourcePassword,
resourcePincode,
resources,
@@ -12,11 +9,8 @@ import {
sites,
targetHealthCheck,
targets,
userResources,
type Label
userResources
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -146,7 +140,6 @@ export type ResourceWithTargets = {
headerAuthId: number | null;
wildcard: boolean;
health: string | null;
browserAccessType: string | null;
targets: Array<{
targetId: number;
ip: string;
@@ -161,7 +154,6 @@ export type ResourceWithTargets = {
siteNiceId: string;
online?: boolean; // undefined for local sites
}>;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
};
function queryResourcesBase() {
@@ -185,8 +177,7 @@ function queryResourcesBase() {
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health,
browserAccessType: resources.browserAccessType
health: resources.health
})
.from(resources)
.leftJoin(
@@ -297,11 +288,6 @@ export async function listResources(
);
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>;
if (req.user) {
accessibleResources = await db
@@ -339,6 +325,24 @@ export async function listResources(
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${resources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.fullDomain})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof enabled !== "undefined") {
conditions.push(eq(resources.enabled, enabled));
}
@@ -382,32 +386,6 @@ export async function listResources(
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite));
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -429,36 +407,6 @@ export async function listResources(
]);
const resourceIdList = rows.map((row) => row.resourceId);
let labelsForResources: Array<{
labelId: number;
name: string;
color: string;
resourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForResources =
resourceIdList.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId));
}
const allResourceTargets =
resourceIdList.length === 0
? []
@@ -485,30 +433,6 @@ export async function listResources(
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -529,16 +453,12 @@ export async function listResources(
protocol: row.protocol,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
browserAccessType: row.browserAccessType,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
health: row.health ?? null,
targets: [],
sites: [],
labels: labelsForResources.filter(
(l) => l.resourceId === row.resourceId
)
sites: []
};
map.set(row.resourceId, entry);
}
@@ -573,21 +493,6 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}

View File

@@ -24,10 +24,7 @@ import {
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -71,8 +68,7 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional(),
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional()
postAuthPath: z.string().nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"

View File

@@ -9,10 +9,7 @@ import {
siteResources,
targets,
sites,
userSites,
labels,
siteLabels,
type Label
userSites
} from "@server/db";
import cache from "#dynamic/lib/cache";
import response from "@server/lib/response";
@@ -26,8 +23,6 @@ import createHttpError from "http-errors";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to
@@ -192,7 +187,7 @@ const listSitesSchema = z.object({
function querySitesBase() {
return db
.selectDistinct({
.select({
siteId: sites.siteId,
niceId: sites.niceId,
name: sites.name,
@@ -238,7 +233,6 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
};
export type ListSitesResponse = PaginatedResponse<{
@@ -314,11 +308,6 @@ export async function listSites(
.where(eq(sites.orgId, orgId));
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const { pageSize, page, query, sort_by, order, online, status } =
parsedQuery.data;
@@ -330,43 +319,33 @@ export async function listSites(
eq(sites.orgId, orgId)
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online));
}
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${sites.name})`, q),
like(sql`LOWER(${sites.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites")
querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
);
const siteListQuery = baseQuery
@@ -388,46 +367,11 @@ export async function listSites(
// Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion();
const siteIds = rows.map((site) => site.siteId);
let labelsForSites: Array<{
labelId: number;
name: string;
color: string;
siteId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForSites =
siteIds.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteId: siteLabels.siteId
})
.from(labels)
.innerJoin(
siteLabels,
eq(siteLabels.labelId, labels.labelId)
)
.where(inArray(siteLabels.siteId, siteIds))
.orderBy(asc(siteLabels.siteLabelId));
}
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false;
// associate labels
const labelsForSite = labelsForSites.filter(
(label) => label.siteId === site.siteId
);
return { ...siteWithUpdate, labels: labelsForSite };
return siteWithUpdate;
});
// Try to get the latest version, but don't block if it fails

View File

@@ -1,14 +1,4 @@
import {
db,
DB_TYPE,
Label,
SiteResource,
siteNetworks,
siteResourceLabels,
siteResources,
sites,
labels
} from "@server/db";
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -19,8 +9,6 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
orgId: z.string()
@@ -81,11 +69,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
default: "asc",
description: "Sort order"
}),
siteId: z.coerce.number<string>().int().positive().optional().openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
});
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
@@ -95,7 +88,6 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
})[];
}>;
@@ -242,11 +234,6 @@ export async function listAllSiteResourcesByOrg(
const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
@@ -271,41 +258,41 @@ export async function listAllSiteResourcesByOrg(
inArray(siteResources.siteResourceId, resourcesForSite)
);
}
if (query) {
conditions.push(
or(
like(
sql`LOWER(${siteResources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.destination})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.alias})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.aliasAddress})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (mode) {
conditions.push(eq(siteResources.mode, mode));
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${siteResources.name})`, q),
like(sql`LOWER(${siteResources.niceId})`, q),
like(sql`LOWER(${siteResources.destination})`, q),
like(sql`LOWER(${siteResources.alias})`, q),
like(sql`LOWER(${siteResources.aliasAddress})`, q),
like(sql`LOWER(${sites.name})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
@@ -328,51 +315,11 @@ export async function listAllSiteResourcesByOrg(
countQuery
]);
const siteResourcesList = siteResourcesRaw.map(
transformSiteResourceRow
);
const siteResourceIdList = siteResourcesList.map(
(r) => r.siteResourceId
);
let labelsForSiteResources: Array<{
labelId: number;
name: string;
color: string;
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
labelsForSiteResources = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
}
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
return response<ListAllSiteResourcesByOrgResponse>(res, {
data: {
siteResources: siteResourcesList.map((r) => ({
...r,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === r.siteResourceId
)
})),
siteResources: siteResourcesList,
pagination: {
total: totalCount,
pageSize,
@@ -393,4 +340,4 @@ export async function listAllSiteResourcesByOrg(
)
);
}
}
}

View File

@@ -1,63 +0,0 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Labels"
};
type Props = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function LabelsPage({ params, searchParams }: Props) {
const { orgId } = await params;
const searchParamsObj = new URLSearchParams(await searchParams);
let labels: ListOrgLabelsResponse["labels"] = [];
let pagination: ListOrgLabelsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
labels = responseData.labels;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("labels")}
description={t("orgLabelsDescription")}
/>
<OrgLabelsTable
labels={labels}
orgId={orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -76,8 +76,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent,
archived: client.archived || false,
blocked: client.blocked || false,
approvalState: client.approvalState ?? "approved",
labels: client.labels ?? []
approvalState: client.approvalState ?? "approved"
};
};

View File

@@ -9,7 +9,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -294,7 +294,7 @@ export default function ConnectionLogsPage() {
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error),
description: t("Failed to filter logs"),
variant: "destructive"
});
} finally {

View File

@@ -127,8 +127,7 @@ export default async function ClientResourcesPage(
authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null,
labels: siteResource.labels ?? []
fullDomain: siteResource.fullDomain ?? null
};
}
);

View File

@@ -507,9 +507,7 @@ export default function GeneralForm() {
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain
? toASCII(
finalizeSubdomainSanitize(data.subdomain, true)
)
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
@@ -557,15 +555,13 @@ export default function GeneralForm() {
return (
<>
<SettingsContainer>
{resource?.resourceId &&
resource?.orgId &&
resource.browserAccessType == "http" && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
{resource?.resourceId && resource?.orgId && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -121,10 +121,6 @@ export default function ReverseProxyTargetsPage(props: {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const [targetMode, setTargetMode] = useState<
"http" | "ssh" | "rdp" | "vnc"
>((resource.browserAccessType as "http" | "ssh" | "rdp" | "vnc") || "http");
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
@@ -141,12 +137,9 @@ export default function ReverseProxyTargetsPage(props: {
orgId={params.orgId}
initialTargets={remoteTargets}
resource={resource}
targetMode={targetMode}
setTargetMode={setTargetMode}
updateResource={updateResource}
/>
{resource.http && targetMode === "http" && (
{resource.http && (
<ProxyResourceHttpForm
resource={resource}
updateResource={updateResource}
@@ -166,17 +159,11 @@ export default function ReverseProxyTargetsPage(props: {
function ProxyResourceTargetsForm({
orgId,
initialTargets,
resource,
targetMode,
setTargetMode,
updateResource
resource
}: {
initialTargets: LocalTarget[];
orgId: string;
resource: GetResourceResponse;
targetMode: "http" | "ssh" | "rdp" | "vnc";
setTargetMode: (mode: "http" | "ssh" | "rdp" | "vnc") => void;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -214,11 +201,6 @@ function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) {
return; // Already initialized
@@ -288,41 +270,6 @@ function ProxyResourceTargetsForm({
})
);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setTargetMode(bgt.type as "ssh" | "rdp" | "vnc");
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -409,7 +356,7 @@ function ProxyResourceTargetsForm({
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -428,6 +375,7 @@ function ProxyResourceTargetsForm({
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -456,15 +404,9 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
@@ -490,15 +432,9 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
@@ -781,55 +717,6 @@ function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
async function saveTargets() {
if (targetMode !== "http") {
try {
if (!bgDestination || !bgDestinationPort) {
if (bgTargetId) {
await api.delete(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`
);
setBgTargetId(null);
}
} else if (bgTargetId) {
await api.post(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`,
{
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: bgSiteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: bgSiteId ?? sites[0]?.siteId,
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
);
setBgTargetId(res.data.data.browserGatewayTargetId);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
return;
}
// Validate that no targets have blank IPs or invalid ports
const targetsWithInvalidFields = targets.filter(
(target) =>
@@ -904,14 +791,12 @@ function ProxyResourceTargetsForm({
}
toast({
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
title: targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description: targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
setTargetsToRemove([]);
@@ -944,187 +829,102 @@ function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex items-center gap-3 mb-4">
<span className="text-sm font-medium">Target Type</span>
<Select
value={targetMode}
onValueChange={async (v) => {
const mode = v as
| "http"
| "ssh"
| "rdp"
| "vnc";
setTargetMode(mode);
try {
await api.post(
`/resource/${resource.resourceId}`,
{ browserAccessType: mode }
);
updateResource({ browserAccessType: mode });
} catch (err) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
<SelectItem value="vnc">VNC</SelectItem>
</SelectContent>
</Select>
</div>
{targetMode === "http" ? (
{targets.length > 0 ? (
<>
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow
key={headerGroup.id}
>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header
.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows
?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
>
{row
.getVisibleCells()
.map(
(
cell
) => {
const isActionsColumn =
cell
.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("targetNoOne")}
</TableCell>
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={
setIsAdvancedMode
}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
@@ -1132,91 +932,50 @@ function ProxyResourceTargetsForm({
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size >
1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Destination
</label>
<Input
placeholder="192.168.1.1"
value={bgDestination}
onChange={(e) =>
setBgDestination(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Port
</label>
<Input
type="number"
placeholder={
targetMode === "rdp"
? "3389"
: targetMode === "ssh"
? "22"
: "5900"
}
value={bgDestinationPort}
onChange={(e) =>
setBgDestinationPort(e.target.value)
}
/>
{t("advancedMode")}
</label>
</div>
</div>
</div>
{sites.length > 1 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Site
</label>
<Select
value={bgSiteId ? String(bgSiteId) : ""}
onValueChange={(v) =>
setBgSiteId(Number(v))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a site" />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem
key={site.siteId}
value={String(site.siteId)}
>
{site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>
<form className="self-end mt-4" action={formAction}>

View File

@@ -111,7 +111,6 @@ export default async function ProxyResourcesPage(
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
labels: resource.labels,
authState: !resource.http
? "none"
: resource.sso ||
@@ -126,7 +125,6 @@ export default async function ProxyResourcesPage(
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl,
wildcard: resource.wildcard,
browserAccessType: resource.browserAccessType,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
ip: target.ip,

View File

@@ -60,7 +60,6 @@ export default async function SitesPage(props: SitesPageProps) {
return {
name: site.name,
id: site.siteId,
labels: site.labels,
nice: site.niceId.toString(),
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -23,7 +23,6 @@ import {
Server,
Settings,
SquareMousePointer,
TagIcon,
TicketCheck,
Unplug,
User,
@@ -100,7 +99,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
...(build === "saas"
...(build == "saas"
? [
{
title: "sidebarRemoteExitNodes",
@@ -238,19 +237,10 @@ export const orgNavSections = (
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
...(build !== "oss"
? [
{
title: "labels",
href: "/{orgId}/settings/labels",
icon: <TagIcon className="size-4 flex-none" />
}
]
: [])
}
]
},
...(build === "saas" && options?.isPrimaryOrg
...(build == "saas" && options?.isPrimaryOrg
? [
{
title: "sidebarBillingAndLicenses",

View File

@@ -1,527 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
IronError,
FileTransferProvider
} from "@devolutions/iron-remote-desktop/dist";
import type {
RdpFileTransferProvider,
FileInfo
} from "@devolutions/iron-remote-desktop-rdp/dist";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"iron-remote-desktop": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
scale?: string;
verbose?: string;
flexcenter?: string;
module?: unknown;
},
HTMLElement
>;
}
}
}
type Target = {
ip: string;
port: number;
authToken: string;
};
type FormState = {
username: string;
password: string;
domain: string;
kdcProxyUrl: string;
pcb: string;
enableClipboard: boolean;
};
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === "object" &&
error !== null &&
typeof (error as IronError).backtrace === "function" &&
typeof (error as IronError).kind === "function"
);
};
export default function RdpClient({
target,
error
}: {
target: Target | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_rdp_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
const userInteractionRef = useRef<UserInteraction | null>(null);
const backendRef = useRef<unknown>(null);
// Holds the RdpFileTransferProvider constructor so we can create a fresh
// instance per session (avoids stale upload state across reconnects).
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
null
);
// Active session's provider instance; replaced on each connect.
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
const extensionsRef = useRef<{
displayControl: (enable: boolean) => unknown;
preConnectionBlob: (pcb: string) => unknown;
kdcProxyUrl: (url: string) => unknown;
} | null>(null);
// Load the iron-remote-desktop modules client-side and register the
// `<iron-remote-desktop>` custom element.
useEffect(() => {
let cancelled = false;
(async () => {
const [coreMod, rdpMod] = await Promise.all([
import("@devolutions/iron-remote-desktop/dist"),
import("@devolutions/iron-remote-desktop-rdp/dist")
]);
if (cancelled) return;
await rdpMod.init("INFO");
backendRef.current = rdpMod.Backend;
extensionsRef.current = {
displayControl: rdpMod.displayControl,
preConnectionBlob: rdpMod.preConnectionBlob,
kdcProxyUrl: rdpMod.kdcProxyUrl
};
// Store the class; a fresh instance is created per session.
fileTransferClassRef.current =
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
// Importing the package registers the custom element as a side
// effect. Touch the default export to avoid tree-shaking.
void coreMod;
setModuleReady(true);
})().catch((err) => {
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
description: `${err}`
});
});
return () => {
cancelled = true;
};
}, []);
// Attach the "ready" listener synchronously the moment the custom
// element mounts. The custom element dispatches `ready` from its own
// `onMount`, so a deferred useEffect can race and miss it.
const remoteElementRef = (el: HTMLElement | null) => {
if (!el) return;
const onReady = (e: Event) => {
const event = e as CustomEvent;
userInteractionRef.current = event.detail.irgUserInteraction;
};
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
return;
}
userInteraction.setEnableClipboard(form.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
fileTransferRef.current?.dispose();
const ProviderClass = fileTransferClassRef.current;
const fileTransfer = ProviderClass ? new ProviderClass() : null;
fileTransferRef.current = fileTransfer;
if (fileTransfer) {
// Auto-download files when the remote copies them to clipboard.
fileTransfer.on("files-available", (files: FileInfo[]) => {
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.isDirectory) continue;
const { completion } = fileTransfer.downloadFile(file, i);
completion
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
})
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
description: `${err}`
});
});
}
});
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
});
// Register with the web component so CLIPRDR extensions are
// wired up before connect() builds the session.
userInteraction.enableFileTransfer(
fileTransfer as unknown as FileTransferProvider
);
}
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
return;
}
const destination = `${target.ip}:${target.port}`;
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withAuthToken(target.authToken)
.withDesktopSize({
width: window.innerWidth,
height: window.innerHeight
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
const termInfo = await sessionInfo.run();
fileTransferRef.current?.dispose();
fileTransferRef.current = null;
setShowLogin(true);
} catch (err) {
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
}
}
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
const u = ui();
if (!u) return;
if (cursorOverrideActive) {
u.setCursorStyleOverride(null);
} else {
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
}
setCursorOverrideActive((v) => !v);
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ft = fileTransferRef.current;
if (!ft) return;
const files = await ft.showFilePicker({
multiple: true
});
if (files.length === 0) return;
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
description: `${err}`
});
}
}}
>
Upload files
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
Terminate
</Button>
<label className="ml-2 flex items-center gap-2">
<input
type="checkbox"
checked={unicodeMode}
onChange={(e) => {
setUnicodeMode(e.target.checked);
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
</label>
</div>
{moduleReady && (
<iron-remote-desktop
ref={remoteElementRef}
verbose="true"
scale="fit"
flexcenter="true"
module={backendRef.current}
/>
)}
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number; authToken: string } | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
console.log("Fetched browser target:", target);
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <RdpClient target={target} error={error} />;
}

View File

@@ -1,389 +0,0 @@
"use client";
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
type Target = {
ip: string;
port: number;
authToken: string;
};
type FormState = {
username: string;
password: string;
privateKey: string;
};
export default function SshClient({
target,
error
}: {
target: Target | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
});
const fileInputRef = useRef<HTMLInputElement>(null);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
null
);
const wsRef = useRef<WebSocket | null>(null);
// Mount the terminal div once connected.
useEffect(() => {
if (!connected || !terminalRef.current) return;
let cancelled = false;
(async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links")
]);
if (cancelled || !terminalRef.current) return;
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: {
background: "#0d0d0d",
foreground: "#f0f0f0"
},
scrollback: 5000
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
xtermRef.current?.dispose();
};
}, []);
function connect() {
setConnectError(null);
setConnecting(true);
if (!target) {
setConnectError("No target specified");
setConnecting(false);
return;
}
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
const url = new URL(proxyAddress);
url.searchParams.set("host", target.ip ?? "");
url.searchParams.set("port", String(target.port ?? 22));
url.searchParams.set("username", form.username);
url.searchParams.set("authToken", target.authToken ?? "");
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
ws.onopen = () => {
// Send credentials as the first frame so the proxy can complete
// SSH authentication before piping pty data.
ws.send(
JSON.stringify({
type: "auth",
password: form.password,
privateKey: form.privateKey
})
);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setConnected(true);
};
ws.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
data?: string;
error?: string;
};
if (msg.type === "data" && msg.data) {
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
);
}
} catch {
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => xtermRef.current?.write(t));
}
};
ws.onerror = () => {
setConnecting(false);
setConnected(false);
setConnectError("WebSocket connection failed");
};
ws.onclose = (evt) => {
setConnecting(false);
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
);
};
}
function disconnect() {
wsRef.current?.close();
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
<div className="space-y-4">
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field label="Private Key (optional)" id="privateKey">
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
fileInputRef.current?.click()
}
>
Upload key file
</Button>
{form.privateKey && (
<button
type="button"
className="text-xs text-muted-foreground underline"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={connect}
loading={connecting}
disabled={
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

View File

@@ -1,32 +0,0 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export default async function SshPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number; authToken: string } | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <SshClient target={target} error={error} />;
}

View File

@@ -1,250 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
type Target = {
ip: string;
port: number;
authToken: string;
};
type FormState = {
password: string;
};
export default function VncClient({
target,
error
}: {
target: Target | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_vnc_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const connect = async () => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new (
target: HTMLElement,
url: string,
options?: Record<string, unknown>
) => unknown;
try {
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod;
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
description: `${err}`
});
return;
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
host: target.ip,
port: String(target.port),
authToken: target.authToken
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnected(true);
});
rfb.addEventListener(
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnected(false);
}
);
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
}
);
rfbRef.current = rfb;
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
<div className="space-y-4">
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (rfbRef.current) {
rfbRef.current.sendCtrlAltDel();
}
}}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard
?.readText()
.then((text) => {
rfbRef.current?.clipboardPasteFrom(text);
})
.catch(() => {});
}}
>
Paste clipboard
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
style={{ background: "#000" }}
/>
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

View File

@@ -1,32 +0,0 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number; authToken: string } | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <VncClient target={target} error={error} />;
}

View File

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

View File

@@ -2,6 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
@@ -29,21 +30,13 @@ import {
ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal,
PlusIcon
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import {
startTransition,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import type { PaginationState } from "@tanstack/react-table";
@@ -60,10 +53,6 @@ import {
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -95,11 +84,6 @@ export type InternalResourceRow = {
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
function formatDestinationDisplay(row: InternalResourceRow): string {
@@ -157,10 +141,7 @@ export default function ClientResourcesTable({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const [isRefreshing, startTransition] = useTransition();
useEffect(() => {
const interval = setInterval(() => {
@@ -186,7 +167,7 @@ export default function ClientResourcesTable({
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => {
startRefreshTransition(() => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
@@ -204,8 +185,8 @@ export default function ClientResourcesTable({
siteId: number
) => {
try {
startTransition(async () => {
await api.delete(`/site-resource/${resourceId}`).then(() => {
await api.delete(`/site-resource/${resourceId}`).then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
@@ -273,333 +254,296 @@ export default function ClientResourcesTable({
);
}
const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[]
>(() => {
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover
open={siteFilterOpen}
onOpenChange={setSiteFilterOpen}
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
<PopoverTrigger asChild>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
{t("standaloneHcFilterAnySite")}
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) =>
handleFilterChange("mode", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">
{t("resourcesTableDestination")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={display}
isLink={false}
displayText={display}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
<CopyToClipboard
text={display}
text={resourceRow.alias}
isLink={false}
displayText={display}
displayText={resourceRow.alias}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels",
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
)
});
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
];
function handleFilterChange(
column: string,
@@ -694,8 +638,7 @@ export default function ClientResourcesTable({
enableColumnVisibility
columnVisibility={{
niceId: false,
aliasAddress: false,
labels: false
aliasAddress: false
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
@@ -731,101 +674,3 @@ export default function ClientResourcesTable({
</>
);
}
type ClientResourceLabelCellProps = {
resource: InternalResourceRow;
orgId: string;
};
function ClientResourceLabelCell({
resource,
orgId
}: ClientResourceLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = resource.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleResourceLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteResourceId: resource.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteResourceId: resource.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleResourceLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import { useState } from "react";
import {
cleanForFQDN,
InternalResourceForm,
@@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
const [isSubmitting, startTransition] = useTransition();
function handleSubmit(values: InternalResourceFormValues) {
startTransition(async () => {
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true);
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
}
await api.put<
AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, {
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
`/org/${orgId}/site-resource`,
{
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
@@ -106,30 +106,32 @@ export default function CreateInternalResourceDialog({
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
});
}
);
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
}
return (

View File

@@ -1,102 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
};
export function CreateOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess
}: CreateOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, data);
if (res.status === 201) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelCreateSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("createLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
onSubmit={(data) => {
startTransition(async () => createOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelCreate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -1,109 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
label: {
name: string;
color: string;
labelId: number;
};
};
export function EditOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess,
label
}: EditOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.patch<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/label/${label.labelId}`, data);
if (res.status === 200) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelEditSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("editLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
defaultValue={label}
onSubmit={(data) => {
startTransition(async () => editOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelEdit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

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

View File

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

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