mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-22 08:09:51 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db02f482ff |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [fosrl]
|
||||
4
.github/workflows/cicd.yml
vendored
4
.github/workflows/cicd.yml
vendored
@@ -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
39
.github/workflows/restart-runners.yml
vendored
Normal 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
160
.github/workflows/saas.yml
vendored
Normal 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
110
cloud
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "Изберете как заявленията ви се удостоверяват.",
|
||||
|
||||
@@ -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ů",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
||||
|
||||
@@ -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": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"
|
||||
|
||||
@@ -5,7 +5,6 @@ const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
transpilePackages: ["@novnc/novnc"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
|
||||
102
package-lock.json
generated
102
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (build !== "saas") {
|
||||
if (build != "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] || []
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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") }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -33,4 +33,3 @@ export * from "./removeUserFromResource";
|
||||
export * from "./listAllResourceNames";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getBrowserTarget";
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user