mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-17 13:49:51 +00:00
Compare commits
114 Commits
dev
...
resource-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9df995e76 | ||
|
|
c4b3656fad | ||
|
|
54c1dd3bae | ||
|
|
a8f4d2b7d1 | ||
|
|
51f1693dbd | ||
|
|
b33a6e6fac | ||
|
|
fc2c13a686 | ||
|
|
f4602a120e | ||
|
|
7ccceeea0d | ||
|
|
f81f78f294 | ||
|
|
6cab223f12 | ||
|
|
7b05c02508 | ||
|
|
5922bfb1a0 | ||
|
|
43f2e32231 | ||
|
|
20ebdc6289 | ||
|
|
a80ae49a33 | ||
|
|
660197eef1 | ||
|
|
f3eb823bc3 | ||
|
|
61c13db090 | ||
|
|
ccbd793f52 | ||
|
|
d13e6896a8 | ||
|
|
83a36ead10 | ||
|
|
b61b74b0b5 | ||
|
|
01b068c50f | ||
|
|
fee44ce960 | ||
|
|
1906504a86 | ||
|
|
36bcba332c | ||
|
|
304ab1964c | ||
|
|
b286096c7b | ||
|
|
a22a4b6e74 | ||
|
|
9a680d2374 | ||
|
|
f80e212b07 | ||
|
|
8a39b3fd45 | ||
|
|
61ec938b00 | ||
|
|
6686de6788 | ||
|
|
79636cbb30 | ||
|
|
2fa1bc6cdc | ||
|
|
c5f6d822ca | ||
|
|
4de4bf9625 | ||
|
|
5d956080f2 | ||
|
|
f8e18de2fc | ||
|
|
884482ec35 | ||
|
|
9b43948fa4 | ||
|
|
bcd6cd99cc | ||
|
|
37ceba6b81 | ||
|
|
dfe42e9016 | ||
|
|
38aa2dace8 | ||
|
|
136c3eff0c | ||
|
|
642999c8b1 | ||
|
|
c5fc49b4fa | ||
|
|
cd5a38b1eb | ||
|
|
595842c2c9 | ||
|
|
82d5276ade | ||
|
|
51eb782831 | ||
|
|
de2980e1bc | ||
|
|
8a3c0d9a08 | ||
|
|
1a5e9f1005 | ||
|
|
f42c013f33 | ||
|
|
42c9bda939 | ||
|
|
cbce9fae3a | ||
|
|
e44b15ecd5 | ||
|
|
7f6ca31757 | ||
|
|
a1eb248474 | ||
|
|
be2b1fd1ce | ||
|
|
20b65f549e | ||
|
|
1dc8be373c | ||
|
|
22b2e6b3d4 | ||
|
|
89e7107a47 | ||
|
|
0a69131c38 | ||
|
|
590f2c29b3 | ||
|
|
0ddcce6fe1 | ||
|
|
8a54fb7f23 | ||
|
|
5c280b024e | ||
|
|
033cc62ce7 | ||
|
|
4c69b7a64e | ||
|
|
e7ab9b3f37 | ||
|
|
3143662f82 | ||
|
|
18964ba2a3 | ||
|
|
f862404c5c | ||
|
|
c292578f80 | ||
|
|
7b02d4104d | ||
|
|
2ef5d90e13 | ||
|
|
d6a8021613 | ||
|
|
c5231d37f6 | ||
|
|
4d803a40c9 | ||
|
|
1d709b551a | ||
|
|
335411de4c | ||
|
|
0e4abdf4b6 | ||
|
|
267b40b73c | ||
|
|
ba9a0c5e3c | ||
|
|
9e0b7ff0d7 | ||
|
|
003bf7fdf3 | ||
|
|
c3fdda026b | ||
|
|
a53363d064 | ||
|
|
ee21e1faa7 | ||
|
|
e409a34a09 | ||
|
|
7177ab7f77 | ||
|
|
801f6fb661 | ||
|
|
805d82b8d9 | ||
|
|
bd6d790495 | ||
|
|
2305163474 | ||
|
|
dda53dcb16 | ||
|
|
2c3e768867 | ||
|
|
8d682ed9ad | ||
|
|
47fe497ca1 | ||
|
|
4d5f364663 | ||
|
|
c3db8b972f | ||
|
|
cfced63ba1 | ||
|
|
51aa55f963 | ||
|
|
e7df24841e | ||
|
|
e6fd4c32c4 | ||
|
|
f6590aedbd | ||
|
|
3cb9e02533 | ||
|
|
4d792350ef |
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"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,9 +17,9 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite*
|
||||
!Dockerfile.sqlite
|
||||
*.sqlite3
|
||||
*.sqlite3*
|
||||
*.log
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
@@ -54,3 +54,4 @@ hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
drizzle.config.ts
|
||||
server/setup/migrations.ts
|
||||
solo.yml
|
||||
@@ -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",
|
||||
|
||||
@@ -208,11 +208,33 @@
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesTitle": "Manage Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
|
||||
"resourcePoliciesSearch": "Search policies...",
|
||||
"resourcePoliciesAdd": "Add Policy",
|
||||
"resourcePoliciesDefaultBadgeText": "Default policy",
|
||||
"resourcePoliciesCreate": "Create Resource Policy",
|
||||
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
||||
"resourcePolicyName": "Policy Name",
|
||||
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
||||
"resourcePolicyNamePlaceholder": "e.g. Internal Access Policy",
|
||||
"resourcePoliciesSeeAll": "See All Policies",
|
||||
"resourcePolicyAuthMethodAdd": "Add Authentication Method",
|
||||
"resourcePolicyOtpEmailAdd": "Add OTP emails",
|
||||
"resourcePolicyRulesAdd": "Add Rules",
|
||||
"resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods",
|
||||
"resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources",
|
||||
"rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy",
|
||||
"authentication": "Authentication",
|
||||
"protected": "Protected",
|
||||
"notProtected": "Not Protected",
|
||||
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
||||
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
|
||||
"resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.",
|
||||
"resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?",
|
||||
"resourceHTTP": "HTTPS Resource",
|
||||
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||
"resourceRaw": "Raw TCP/UDP Resource",
|
||||
@@ -253,6 +275,8 @@
|
||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||
"resourceBack": "Back to Resources",
|
||||
"resourceGoTo": "Go to Resource",
|
||||
"resourcePolicyDelete": "Delete Resource Policy",
|
||||
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
|
||||
"resourceDelete": "Delete Resource",
|
||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||
"visibility": "Visibility",
|
||||
@@ -265,6 +289,8 @@
|
||||
"rules": "Rules",
|
||||
"resourceSettingDescription": "Configure the settings on the resource",
|
||||
"resourceSetting": "{resourceName} Settings",
|
||||
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
||||
"resourcePolicySetting": "{policyName} Settings",
|
||||
"alwaysAllow": "Bypass Auth",
|
||||
"alwaysDeny": "Block Access",
|
||||
"passToAuth": "Pass to Auth",
|
||||
@@ -630,7 +656,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",
|
||||
@@ -747,6 +773,16 @@
|
||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||
"rulesSubmit": "Save Rules",
|
||||
"policyErrorCreate": "Error creating policy",
|
||||
"policyErrorCreateDescription": "An error occurred when creating the policy",
|
||||
"policyErrorCreateMessageDescription": "An unexpected error occurred",
|
||||
"policyErrorUpdate": "Error updating policy",
|
||||
"policyErrorUpdateDescription": "An error occurred when updating the policy",
|
||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||
"authMethodsSave": "Save auth methods",
|
||||
"rulesSave": "Save Rules",
|
||||
"resourceErrorCreate": "Error creating resource",
|
||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||
"resourceErrorCreateMessage": "Error creating resource:",
|
||||
@@ -810,6 +846,16 @@
|
||||
"pincodeAdd": "Add PIN Code",
|
||||
"pincodeRemove": "Remove PIN Code",
|
||||
"resourceAuthMethods": "Authentication Methods",
|
||||
"resourcePolicyAuthMethodsEmpty": "No authentication method",
|
||||
"resourcePolicyOtpEmpty": "No one time password",
|
||||
"resourcePolicyReadOnly": "This policy is Read only",
|
||||
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
|
||||
"resourcePolicyTypeSave": "Save Resource type",
|
||||
"resourcePolicySelect": "Select resource policy",
|
||||
"resourcePolicySelectError": "Select a resource policy",
|
||||
"resourcePolicyNotFound": "Policy not found",
|
||||
"resourcePolicySearch": "Search policies",
|
||||
"resourcePolicyRulesEmpty": "No authentication rules",
|
||||
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
|
||||
"resourceAuthSettingsSave": "Saved successfully",
|
||||
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
|
||||
@@ -845,6 +891,12 @@
|
||||
"resourcePincodeSetupTitle": "Set Pincode",
|
||||
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
|
||||
"resourceRoleDescription": "Admins can always access this resource.",
|
||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||
"resourcePolicyInline": "Inline Resource Policy",
|
||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||
"resourcePolicyShared": "Shared Resource Policy",
|
||||
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||
@@ -1374,6 +1426,8 @@
|
||||
"sidebarResources": "Resources",
|
||||
"sidebarProxyResources": "Public",
|
||||
"sidebarClientResources": "Private",
|
||||
"sidebarPolicies": "Policies",
|
||||
"sidebarResourcePolicies": "Resources",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
"sidebarTeam": "Team",
|
||||
@@ -2050,7 +2104,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.",
|
||||
@@ -2100,7 +2154,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.",
|
||||
@@ -2233,7 +2287,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,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
@@ -152,7 +153,21 @@ export enum ActionsEnum {
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks"
|
||||
listHealthChecks = "listHealthChecks",
|
||||
listResourcePolicies = "listResourcePolicies",
|
||||
getResourcePolicy = "getResourcePolicy",
|
||||
createResourcePolicy = "createResourcePolicy",
|
||||
updateResourcePolicy = "updateResourcePolicy",
|
||||
deleteResourcePolicy = "deleteResourcePolicy",
|
||||
listResourcePolicyRoles = "listResourcePolicyRoles",
|
||||
setResourcePolicyRoles = "setResourcePolicyRoles",
|
||||
listResourcePolicyUsers = "listResourcePolicyUsers",
|
||||
setResourcePolicyUsers = "setResourcePolicyUsers",
|
||||
setResourcePolicyPassword = "setResourcePolicyPassword",
|
||||
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||
setResourcePolicyRules = "setResourcePolicyRules"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
@@ -185,6 +200,23 @@ export async function checkUserActionPermission(
|
||||
}
|
||||
}
|
||||
|
||||
// If no direct permission, check role-based permission (any of user's roles)
|
||||
const roleActionPermission = await db
|
||||
.select()
|
||||
.from(roleActions)
|
||||
.where(
|
||||
and(
|
||||
eq(roleActions.actionId, actionId),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (roleActionPermission.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the user has direct permission for the action in the current org
|
||||
const userActionPermission = await db
|
||||
.select()
|
||||
@@ -202,20 +234,7 @@ export async function checkUserActionPermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no direct permission, check role-based permission (any of user's roles)
|
||||
const roleActionPermission = await db
|
||||
.select()
|
||||
.from(roleActions)
|
||||
.where(
|
||||
and(
|
||||
eq(roleActions.actionId, actionId),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return roleActionPermission.length > 0;
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error checking user action permission:", error);
|
||||
throw createHttpError(
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { clients, db, resources, siteResources } from "@server/db";
|
||||
import {
|
||||
clients,
|
||||
db,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { randomInt } from "crypto";
|
||||
import { exitNodes, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueResourcePolicyName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
const policyCount = await db
|
||||
.select({
|
||||
niceId: resourcePolicies.niceId,
|
||||
orgId: resourcePolicies.orgId
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, name),
|
||||
eq(resourcePolicies.orgId, orgId)
|
||||
)
|
||||
);
|
||||
if (policyCount.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueSiteResourceName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
|
||||
@@ -110,6 +110,16 @@ export const sites = pgTable("sites", {
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
@@ -196,9 +206,11 @@ 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"),
|
||||
@@ -521,6 +533,38 @@ export const userResources = pgTable("userResources", {
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const rolePolicies = pgTable("rolePolicies", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userPolicies = pgTable("userPolicies", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", {
|
||||
whitelistId: serial("id").primaryKey(),
|
||||
email: varchar("email").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userInvites = pgTable("userInvites", {
|
||||
inviteId: varchar("inviteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
@@ -586,6 +630,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
|
||||
}
|
||||
);
|
||||
|
||||
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
|
||||
pincodeId: serial("pincodeId").primaryKey(),
|
||||
pincodeHash: varchar("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
|
||||
passwordId: serial("passwordId").primaryKey(),
|
||||
passwordHash: varchar("passwordHash").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
|
||||
headerAuthId: serial("headerAuthId").primaryKey(),
|
||||
headerAuthHash: varchar("headerAuthHash").notNull(),
|
||||
extendedCompatibility: boolean("extendedCompatibility")
|
||||
.notNull()
|
||||
.default(true),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
accessTokenId: varchar("accessTokenId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
@@ -679,6 +757,43 @@ export const resourceRules = pgTable("resourceRules", {
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
||||
ruleId: serial("ruleId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicies = pgTable("resourcePolicies", {
|
||||
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
scope: varchar("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = pgTable("supporterKey", {
|
||||
keyId: serial("keyId").primaryKey(),
|
||||
key: varchar("key").notNull(),
|
||||
@@ -1097,19 +1212,30 @@ 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>;
|
||||
@@ -1179,3 +1305,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
resourceHeaderAuth,
|
||||
ResourceHeaderAuth,
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
resources,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
sessions,
|
||||
userResources,
|
||||
userPolicies,
|
||||
users,
|
||||
ResourceHeaderAuthExtendedCompatibility,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
@@ -154,58 +157,126 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if role has access to resource
|
||||
* Check if role has access to resource (direct or via resource policy)
|
||||
*/
|
||||
export async function getRoleResourceAccess(
|
||||
resourceId: number,
|
||||
roleIds: number[]
|
||||
) {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourcePolicyId, rolePolicies.resourcePolicyId)
|
||||
)
|
||||
);
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
inArray(rolePolicies.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
const combined = [...direct, ...viaPolicies];
|
||||
return combined.length > 0 ? combined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has direct access to resource
|
||||
* Check if user has access to resource (direct or via resource policy)
|
||||
*/
|
||||
export async function getUserResourceAccess(
|
||||
userId: string,
|
||||
resourceId: number
|
||||
) {
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
resourcePolicyId: userPolicies.resourcePolicyId
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourcePolicyId, userPolicies.resourcePolicyId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(userPolicies.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
||||
return direct[0] ?? viaPolicies[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource rules for a given resource
|
||||
* Get resource rules for a given resource (direct and via resource policy)
|
||||
*/
|
||||
export async function getResourceRules(
|
||||
resourceId: number
|
||||
): Promise<ResourceRule[]> {
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
const [directRules, policyRules] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
resourcePolicyRules.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
]);
|
||||
|
||||
return rules;
|
||||
const maxDirectPriority = directRules.reduce(
|
||||
(max, r) => Math.max(max, r.priority),
|
||||
0
|
||||
);
|
||||
const offsetPolicyRules = policyRules.map((r) => ({
|
||||
...r,
|
||||
priority: maxDirectPriority + r.priority
|
||||
}));
|
||||
|
||||
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,6 +121,16 @@ export const sites = sqliteTable("sites", {
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: text("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
@@ -219,9 +229,11 @@ 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()
|
||||
@@ -909,6 +921,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
||||
headerAuthHash: text("headerAuthHash").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
|
||||
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
|
||||
pincodeHash: text("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
|
||||
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyHeaderAuth = sqliteTable(
|
||||
"resourcePolicyHeaderAuth",
|
||||
{
|
||||
headerAuthId: integer("headerAuthId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
headerAuthHash: text("headerAuthHash").notNull(),
|
||||
extendedCompatibility: integer("extendedCompatibility", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(true),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||
"resourceHeaderAuthExtendedCompatibility",
|
||||
{
|
||||
@@ -1023,6 +1076,77 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const rolePolicies = sqliteTable("rolePolicies", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userPolicies = sqliteTable("userPolicies", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
|
||||
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
||||
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicies = sqliteTable("resourcePolicies", {
|
||||
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
scope: text("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = sqliteTable("supporterKey", {
|
||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||
key: text("key").notNull(),
|
||||
@@ -1196,19 +1320,30 @@ 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>;
|
||||
@@ -1278,3 +1413,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -24,7 +24,8 @@ export enum TierFeature {
|
||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules",
|
||||
WildcardSubdomain = "wildcardSubdomain"
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
ResourcePolicies = "resourcePolicies"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -66,5 +67,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -162,9 +162,10 @@ export const HeaderSchema = z.object({
|
||||
});
|
||||
|
||||
// Schema for individual resource
|
||||
export const ResourceSchema = z
|
||||
export const PublicResourceSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
policy: z.string().optional(),
|
||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
@@ -340,7 +341,8 @@ export const ResourceSchema = z
|
||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||
if (parts.length < 3) return false; // need at least *.label.tld
|
||||
|
||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
const labelRegex =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||
},
|
||||
{
|
||||
@@ -354,7 +356,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
return Object.keys(resource).length === 1 && resource.targets;
|
||||
}
|
||||
|
||||
export const ClientResourceSchema = z
|
||||
export const PrivateResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
@@ -435,19 +437,19 @@ export const ClientResourceSchema = z
|
||||
export const ConfigSchema = z
|
||||
.object({
|
||||
"proxy-resources": z
|
||||
.record(z.string(), ResourceSchema)
|
||||
.record(z.string(), PublicResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"public-resources": z
|
||||
.record(z.string(), ResourceSchema)
|
||||
.record(z.string(), PublicResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"client-resources": z
|
||||
.record(z.string(), ClientResourceSchema)
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"private-resources": z
|
||||
.record(z.string(), ClientResourceSchema)
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||
@@ -472,10 +474,13 @@ export const ConfigSchema = z
|
||||
}
|
||||
|
||||
return data as {
|
||||
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
|
||||
"proxy-resources": Record<
|
||||
string,
|
||||
z.infer<typeof PublicResourceSchema>
|
||||
>;
|
||||
"client-resources": Record<
|
||||
string,
|
||||
z.infer<typeof ClientResourceSchema>
|
||||
z.infer<typeof PrivateResourceSchema>
|
||||
>;
|
||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||
};
|
||||
@@ -614,5 +619,5 @@ export const ConfigSchema = z
|
||||
// Type inference from the schema
|
||||
export type Site = z.infer<typeof SiteSchema>;
|
||||
export type Target = z.infer<typeof TargetSchema>;
|
||||
export type Resource = z.infer<typeof ResourceSchema>;
|
||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./verifySiteResourceAccess";
|
||||
export * from "./logActionAudit";
|
||||
export * from "./verifyOlmAccess";
|
||||
export * from "./verifyLimits";
|
||||
export * from "./verifyResourcePolicyAccess";
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess";
|
||||
export * from "./verifyApiKeySiteResourceAccess";
|
||||
export * from "./verifyApiKeyIdpAccess";
|
||||
export * from "./verifyApiKeyDomainAccess";
|
||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicies, apiKeyOrg } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyResourcePolicyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const apiKey = req.apiKey;
|
||||
const resourcePolicyId =
|
||||
req.params.resourcePolicyId ||
|
||||
req.body.resourcePolicyId ||
|
||||
req.query.resourcePolicyId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve the resource policy
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any resource policy in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!policy.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource policy with ID ${resourcePolicyId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the API key is linked to the resource policy's organization
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgResult = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, policy.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgResult.length > 0) {
|
||||
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource policy access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
127
server/middlewares/verifyResourcePolicyAccess.ts
Normal file
127
server/middlewares/verifyResourcePolicyAccess.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicies, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyResourcePolicyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const resourcePolicyIdStr =
|
||||
req.params?.resourcePolicyId ||
|
||||
req.body?.resourcePolicyId ||
|
||||
req.query?.resourcePolicyId;
|
||||
const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
|
||||
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
|
||||
|
||||
try {
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
let policy: typeof resourcePolicies.$inferSelect | null = null;
|
||||
|
||||
if (orgId && niceId) {
|
||||
const [policyRes] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, niceId),
|
||||
eq(resourcePolicies.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
policy = policyRes ?? null;
|
||||
} else {
|
||||
const resourcePolicyId = parseInt(resourcePolicyIdStr);
|
||||
if (isNaN(resourcePolicyId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid resource policy ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
const [policyRes] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
policy = policyRes ?? null;
|
||||
}
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
const userOrgRes = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, policy.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg || req.userOrg.orgId !== policy.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
|
||||
const policyCheck = await checkOrgAccessPolicy({
|
||||
orgId: req.userOrg.orgId,
|
||||
userId,
|
||||
session: req.session
|
||||
});
|
||||
req.orgPolicyAllowed = policyCheck.allowed;
|
||||
if (!policyCheck.allowed || policyCheck.error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Failed organization access policy check: " +
|
||||
(policyCheck.error || "Unknown error")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
policy.orgId
|
||||
);
|
||||
req.userOrgId = policy.orgId;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource policy access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
"User does not have permission to set user organization roles"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum OpenAPITags {
|
||||
Org = "Organization",
|
||||
PublicResource = "Public Resource",
|
||||
PrivateResource = "Private Resource",
|
||||
Policy = "Policy",
|
||||
Role = "Role",
|
||||
User = "User",
|
||||
Invitation = "User Invitation",
|
||||
|
||||
@@ -31,6 +31,8 @@ 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 resource from "#private/routers/resource";
|
||||
import * as policy from "#private/routers/policy";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -44,7 +46,8 @@ import {
|
||||
verifyUserCanSetUserOrgRoles,
|
||||
verifySiteProvisioningKeyAccess,
|
||||
verifyIsLoggedInUser,
|
||||
verifyAdmin
|
||||
verifyAdmin,
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
@@ -382,6 +385,39 @@ authenticated.get(
|
||||
approval.countApprovals
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
|
||||
logActionAudit(ActionsEnum.deleteResourcePolicy),
|
||||
policy.deleteResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource-policies",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.listResourcePolicies),
|
||||
logActionAudit(ActionsEnum.listResourcePolicies),
|
||||
policy.listResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/resource-policy",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createResourcePolicy),
|
||||
logActionAudit(ActionsEnum.createResourcePolicy),
|
||||
policy.createResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/approvals/:approvalId",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -45,8 +45,11 @@ import {
|
||||
users,
|
||||
userOrgs,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
userResources,
|
||||
userPolicies,
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
userOrgRoles,
|
||||
roles
|
||||
} from "@server/db";
|
||||
@@ -430,7 +433,10 @@ hybridRouter.get(
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||
const decryptedKey = decrypt(
|
||||
cert.keyFile!,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
@@ -531,7 +537,10 @@ hybridRouter.get(
|
||||
wildcardCandidates.length > 0
|
||||
? and(
|
||||
eq(resources.wildcard, true),
|
||||
inArray(resources.fullDomain, wildcardCandidates)
|
||||
inArray(
|
||||
resources.fullDomain,
|
||||
wildcardCandidates
|
||||
)
|
||||
)
|
||||
: sql`false`
|
||||
)
|
||||
@@ -545,10 +554,10 @@ hybridRouter.get(
|
||||
|
||||
if (
|
||||
result &&
|
||||
await checkExitNodeOrg(
|
||||
(await checkExitNodeOrg(
|
||||
remoteExitNode.exitNodeId,
|
||||
result.resources.orgId
|
||||
)
|
||||
))
|
||||
) {
|
||||
// If the exit node is not allowed for the org, return an error
|
||||
return next(
|
||||
@@ -1132,22 +1141,43 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(rolePolicies.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
const result =
|
||||
roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
|
||||
const result = direct[0] ?? viaPolicies[0] ?? null;
|
||||
|
||||
return response<typeof roleResources.$inferSelect | null>(res, {
|
||||
data: result,
|
||||
data: result as any,
|
||||
success: true,
|
||||
error: false,
|
||||
message: result
|
||||
@@ -1222,21 +1252,44 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select({
|
||||
resourceId: roleResources.resourceId,
|
||||
roleId: roleResources.roleId
|
||||
})
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
);
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
resourceId: roleResources.resourceId,
|
||||
roleId: roleResources.roleId
|
||||
})
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
),
|
||||
roleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
roleId: rolePolicies.roleId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
inArray(rolePolicies.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const result =
|
||||
roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
const combined = [...direct, ...viaPolicies];
|
||||
const result = combined.length > 0 ? combined : null;
|
||||
|
||||
return response<{ resourceId: number; roleId: number }[] | null>(
|
||||
res,
|
||||
@@ -1397,10 +1450,45 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
const [directRules, policyRules] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
resourcePolicyRules.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
]);
|
||||
|
||||
const maxDirectPriority = directRules.reduce(
|
||||
(max, r) => Math.max(max, r.priority),
|
||||
0
|
||||
);
|
||||
const offsetPolicyRules = policyRules.map((r) => ({
|
||||
...r,
|
||||
priority: maxDirectPriority + r.priority
|
||||
}));
|
||||
|
||||
const rules = [
|
||||
...directRules,
|
||||
...offsetPolicyRules
|
||||
] as (typeof resourceRules.$inferSelect)[];
|
||||
|
||||
// backward compatibility: COUNTRY -> GEOIP
|
||||
// TODO: remove this after a few versions once all exit nodes are updated
|
||||
|
||||
417
server/private/routers/policy/createResourcePolicy.ts
Normal file
417
server/private/routers/policy/createResourcePolicy.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* 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 { hashPassword } from "@server/auth/password";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
orgs,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userOrgs,
|
||||
userPolicies,
|
||||
users,
|
||||
type ResourcePolicy
|
||||
} from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, inArray, type InferInsertModel } 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 createResourcePolicyParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const ruleSchema = z.strictObject({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||
type: "string",
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
priority: z.int().openapi({
|
||||
type: "integer",
|
||||
description: "Rule priority"
|
||||
}),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
const createResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
// Access control
|
||||
sso: z.boolean().default(true),
|
||||
skipToIdpId: z
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.nullable()
|
||||
.openapi({ type: "integer" }),
|
||||
roleIds: z
|
||||
.array(z.string().transform(Number).pipe(z.int().positive()))
|
||||
.optional()
|
||||
.default([]),
|
||||
userIds: z.array(z.string()).optional().default([]),
|
||||
// auth methods
|
||||
password: z.string().min(4).max(100).nullable().optional(),
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.or(z.null())
|
||||
.optional(),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
// email OTP
|
||||
emailWhitelistEnabled: z.boolean().optional().default(false),
|
||||
emails: z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
// rules
|
||||
applyRules: z.boolean().default(false),
|
||||
rules: z.array(ruleSchema).optional().default([])
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/resource-policy",
|
||||
description: "Create a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: createResourcePolicyParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createResourcePolicyBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Validate request params
|
||||
const parsedParams = createResourcePolicyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
}
|
||||
|
||||
// get the org
|
||||
const org = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (org.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = createResourcePolicyBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
sso,
|
||||
userIds,
|
||||
roleIds,
|
||||
skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled,
|
||||
password,
|
||||
pincode,
|
||||
headerAuth,
|
||||
emails,
|
||||
rules
|
||||
} = parsedBody.data;
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (skipToIdpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Identity provider not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(inArray(roles.roleId, roleIds)));
|
||||
|
||||
const hasAdminRole = existingRoles.some((role) => role.isAdmin);
|
||||
|
||||
if (hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Admin role cannot be assigned to resource policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||
.where(
|
||||
and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds))
|
||||
);
|
||||
|
||||
const niceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const policy = await db.transaction(async (trx) => {
|
||||
const [newPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
sso,
|
||||
idpId: skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled
|
||||
})
|
||||
.returning();
|
||||
|
||||
const rolesToAdd = [
|
||||
{
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}
|
||||
] satisfies InferInsertModel<typeof rolePolicies>[];
|
||||
|
||||
rolesToAdd.push(
|
||||
...existingRoles.map((role) => ({
|
||||
roleId: role.roleId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
|
||||
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||
|
||||
const usersToAdd: InferInsertModel<typeof userPolicies>[] = [];
|
||||
|
||||
if (
|
||||
req.user &&
|
||||
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||
) {
|
||||
// make sure the user can access the policy
|
||||
usersToAdd.push({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
usersToAdd.push(
|
||||
...existingUsers.map(({ user }) => ({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await trx.insert(userPolicies).values(usersToAdd);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await trx.insert(resourcePolicyPassword).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
passwordHash
|
||||
});
|
||||
}
|
||||
|
||||
if (pincode) {
|
||||
const pincodeHash = await hashPassword(pincode);
|
||||
|
||||
await trx.insert(resourcePolicyPincode).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
pincodeHash,
|
||||
digitLength: 6
|
||||
});
|
||||
}
|
||||
|
||||
if (headerAuth) {
|
||||
const headerAuthHash = await hashPassword(
|
||||
Buffer.from(
|
||||
`${headerAuth.user}:${headerAuth.password}`
|
||||
).toString("base64")
|
||||
);
|
||||
|
||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
headerAuthHash,
|
||||
extendedCompatibility: headerAuth.extendedCompatibility
|
||||
});
|
||||
}
|
||||
|
||||
if (emailWhitelistEnabled && emails.length > 0) {
|
||||
await trx.insert(resourcePolicyWhiteList).values(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (rules.length > 0) {
|
||||
await trx.insert(resourcePolicyRules).values(
|
||||
rules.map((rule) => ({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
...rule
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return newPolicy;
|
||||
});
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
return response<ResourcePolicy>(res, {
|
||||
data: policy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "resource policy created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
107
server/private/routers/policy/deleteResourcePolicy.ts
Normal file
107
server/private/routers/policy/deleteResourcePolicy.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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, resourcePolicies, resources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourcePolicySchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Delete a resource policy.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: deleteResourcePolicySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = deleteResourcePolicySchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
if (!existingResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const totalAffectedResources = await db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourcePolicyId, resourcePolicyId))
|
||||
);
|
||||
|
||||
if (totalAffectedResources > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`Cannot delete Policy '${existingResource.name}' as it's being used by at least one resource`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// delete policy
|
||||
await db
|
||||
.delete(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource Policy deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
16
server/private/routers/policy/index.ts
Normal file
16
server/private/routers/policy/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 "./createResourcePolicy";
|
||||
export * from "./listResourcePolicies";
|
||||
export * from "./deleteResourcePolicy";
|
||||
271
server/private/routers/policy/listResourcePolicies.ts
Normal file
271
server/private/routers/policy/listResourcePolicies.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* 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,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
rolePolicies,
|
||||
userPolicies
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import type {
|
||||
ListResourcePoliciesResponse,
|
||||
ResourcePolicyWithResources
|
||||
} from "@server/routers/resource/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listResourcePoliciesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listResourcePoliciesSchema = 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 queryResourcePoliciesBase() {
|
||||
return db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||
name: resourcePolicies.name,
|
||||
niceId: resourcePolicies.niceId,
|
||||
orgId: resourcePolicies.orgId
|
||||
})
|
||||
.from(resourcePolicies);
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policies",
|
||||
description: "List resource policies for an organization.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string()
|
||||
}),
|
||||
query: listResourcePoliciesSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listResourcePolicies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listResourcePoliciesSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize, query } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourcePoliciesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId =
|
||||
parsedParams.data.orgId ||
|
||||
req.userOrg?.orgId ||
|
||||
req.apiKeyOrg?.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
|
||||
if (req.user) {
|
||||
accessibleResourcePolicies = await db
|
||||
.select({
|
||||
resourcePolicyId: sql<number>`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})`
|
||||
})
|
||||
.from(userPolicies)
|
||||
.fullJoin(
|
||||
rolePolicies,
|
||||
eq(
|
||||
userPolicies.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userPolicies.userId, req.user!.userId),
|
||||
inArray(rolePolicies.roleId, req.userOrgRoleIds || [])
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleResourcePolicies = await db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleResourceIds = accessibleResourcePolicies.map(
|
||||
(resource) => resource.resourcePolicyId
|
||||
);
|
||||
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
accessibleResourceIds
|
||||
),
|
||||
eq(resourcePolicies.orgId, orgId),
|
||||
eq(resourcePolicies.scope, "global")
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resourcePolicies.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resourcePolicies.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcePoliciesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(baseQuery.as("filtered_policies"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(resourcePolicies.resourcePolicyId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const attachedResources =
|
||||
rows.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
resourcePolicyId: resources.resourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(
|
||||
inArray(
|
||||
resources.resourcePolicyId,
|
||||
rows.map((row) => row.resourcePolicyId)
|
||||
)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourcePolicyWithResources>();
|
||||
|
||||
for (const row of rows) {
|
||||
let entry = map.get(row.resourcePolicyId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
...row,
|
||||
resources: []
|
||||
};
|
||||
map.set(row.resourcePolicyId, entry);
|
||||
}
|
||||
|
||||
entry.resources = attachedResources.filter(
|
||||
(r) => r.resourcePolicyId === entry?.resourcePolicyId
|
||||
);
|
||||
}
|
||||
|
||||
const policiesList = Array.from(map.values());
|
||||
|
||||
return response<ListResourcePoliciesResponse>(res, {
|
||||
data: {
|
||||
policies: policiesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -671,7 +671,8 @@ export async function verifyResourceSession(
|
||||
resourceData.org
|
||||
);
|
||||
|
||||
localCache.set(userAccessCacheKey, allowedUserData, 5);
|
||||
// this is query intensive so let it cache a little longer
|
||||
localCache.set(userAccessCacheKey, allowedUserData, 12);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1003,11 +1004,7 @@ async function checkRules(
|
||||
isIpInCidr(clientIp, rule.value)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "IP" &&
|
||||
clientIp == rule.value
|
||||
) {
|
||||
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
path &&
|
||||
@@ -1015,10 +1012,7 @@ async function checkRules(
|
||||
isPathAllowed(rule.value, path)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "COUNTRY"
|
||||
) {
|
||||
} else if (clientIp && rule.match == "COUNTRY") {
|
||||
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
rule.value.toUpperCase() === "ALL" &&
|
||||
@@ -1030,10 +1024,7 @@ async function checkRules(
|
||||
if (await isIpInGeoIP(ipCC, rule.value)) {
|
||||
return rule.action as any;
|
||||
}
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "ASN"
|
||||
) {
|
||||
} else if (clientIp && rule.match == "ASN") {
|
||||
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
(rule.value.toUpperCase() === "ALL" ||
|
||||
@@ -1272,11 +1263,15 @@ export async function isIpInRegion(
|
||||
if (region.id === checkRegionCode) {
|
||||
for (const subregion of region.includes) {
|
||||
if (subregion.countries.includes(upperCode)) {
|
||||
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is in region ${region.id} (${region.name})`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is not in region ${region.id} (${region.name})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1284,10 +1279,14 @@ export async function isIpInRegion(
|
||||
for (const subregion of region.includes) {
|
||||
if (subregion.id === checkRegionCode) {
|
||||
if (subregion.countries.includes(upperCode)) {
|
||||
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
||||
import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as resource from "./resource";
|
||||
import * as policy from "./policy";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
@@ -42,7 +43,8 @@ import {
|
||||
verifyUserIsOrgOwner,
|
||||
verifySiteResourceAccess,
|
||||
verifyOlmAccess,
|
||||
verifyLimits
|
||||
verifyLimits,
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
@@ -103,7 +105,6 @@ authenticated.put(
|
||||
site.createSite
|
||||
);
|
||||
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/sites",
|
||||
verifyOrgAccess,
|
||||
@@ -540,6 +541,7 @@ authenticated.get(
|
||||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
@@ -646,6 +648,29 @@ authenticated.post(
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource-policy/:niceId",
|
||||
verifyOrgAccess,
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/policies",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
|
||||
policy.updateResourcePolicy
|
||||
);
|
||||
|
||||
// authenticated.get(
|
||||
// "/role/:roleId",
|
||||
// verifyRoleAccess,
|
||||
@@ -697,6 +722,59 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/access-control",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||
policy.setResourcePolicyAccessControl
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/password",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||
policy.setResourcePolicyPassword
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/pincode",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||
policy.setResourcePolicyPincode
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/header-auth",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
policy.setResourcePolicyHeaderAuth
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/whitelist",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||
policy.setResourcePolicyWhitelist
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/rules",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRules),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyResourceAccess,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as blueprints from "./blueprints";
|
||||
import * as resource from "./resource";
|
||||
import * as policy from "./policy";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
@@ -29,7 +30,9 @@ import {
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyApiKeyDomainAccess
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyUserHasAction
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
@@ -459,6 +462,20 @@ authenticated.get(
|
||||
resource.getResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/policies",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -468,6 +485,13 @@ authenticated.post(
|
||||
resource.updateResource
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
|
||||
policy.updateResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -619,6 +643,63 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/access-control",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
||||
policy.setResourcePolicyAccessControl
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/password",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||
policy.setResourcePolicyPassword
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/pincode",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||
policy.setResourcePolicyPincode
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/header-auth",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
policy.setResourcePolicyHeaderAuth
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/whitelist",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||
policy.setResourcePolicyWhitelist
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/rules",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/roles/add",
|
||||
verifyApiKeyResourceAccess,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
231
server/routers/policy/getResourcePolicy.ts
Normal file
231
server/routers/policy/getResourcePolicy.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
resourcePolicyRules,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userPolicies,
|
||||
users
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull, not, or, type SQL } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getResourcePolicySchema = z
|
||||
.strictObject({
|
||||
niceId: z.string(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.or(
|
||||
z.strictObject({
|
||||
resourcePolicyId: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.openapi({
|
||||
type: "integer",
|
||||
description: "Resource policy ID"
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
export async function queryResourcePolicy(
|
||||
params: z.infer<typeof getResourcePolicySchema>
|
||||
) {
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if ("resourcePolicyId" in params) {
|
||||
conditions.push(
|
||||
eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId)
|
||||
);
|
||||
} else {
|
||||
conditions.push(
|
||||
eq(resourcePolicies.niceId, params.niceId),
|
||||
eq(resourcePolicies.orgId, params.orgId)
|
||||
);
|
||||
}
|
||||
|
||||
const [res] = await db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||
sso: resourcePolicies.sso,
|
||||
applyRules: resourcePolicies.applyRules,
|
||||
emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled,
|
||||
idpId: resourcePolicies.idpId,
|
||||
niceId: resourcePolicies.niceId,
|
||||
name: resourcePolicies.name,
|
||||
passwordId: resourcePolicyPassword.passwordId,
|
||||
pincodeId: resourcePolicyPincode.pincodeId,
|
||||
headerAuth: {
|
||||
id: resourcePolicyHeaderAuth.headerAuthId,
|
||||
extendedCompability:
|
||||
resourcePolicyHeaderAuth.extendedCompatibility
|
||||
}
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const policyUsers = await db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
username: users.username,
|
||||
type: users.type,
|
||||
idpName: idp.name
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(users, eq(userPolicies.userId, users.userId))
|
||||
.leftJoin(idp, eq(idp.idpId, users.idpId))
|
||||
.where(eq(userPolicies.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
const policyRoles = await db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
name: roles.name
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
roles,
|
||||
and(
|
||||
eq(rolePolicies.roleId, roles.roleId),
|
||||
or(isNull(roles.isAdmin), not(roles.isAdmin))
|
||||
)
|
||||
)
|
||||
.where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
const policyEmailWhiteList = await db
|
||||
.select({
|
||||
whiteListId: resourcePolicyWhiteList.whitelistId,
|
||||
email: resourcePolicyWhiteList.email
|
||||
})
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId)
|
||||
);
|
||||
|
||||
const policyRules = await db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.where(eq(resourcePolicyRules.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
return {
|
||||
...res,
|
||||
roles: policyRoles,
|
||||
users: policyUsers,
|
||||
emailWhiteList: policyEmailWhiteList,
|
||||
rules: policyRules
|
||||
};
|
||||
}
|
||||
|
||||
export type GetResourcePolicyResponse = NonNullable<
|
||||
Awaited<ReturnType<typeof queryResourcePolicy>>
|
||||
>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policy/{niceId}",
|
||||
description:
|
||||
"Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string(),
|
||||
niceId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Get a resource policy by its resourcePolicyId.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
resourcePolicyId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getResourcePolicySchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const policy = await queryResourcePolicy(parsedParams.data);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetResourcePolicyResponse>(res, {
|
||||
data: policy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource Policy retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
8
server/routers/policy/index.ts
Normal file
8
server/routers/policy/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./getResourcePolicy";
|
||||
export * from "./updateResourcePolicy";
|
||||
export * from "./setResourcePolicyAccessControl";
|
||||
export * from "./setResourcePolicyPassword";
|
||||
export * from "./setResourcePolicyPincode";
|
||||
export * from "./setResourcePolicyHeaderAuth";
|
||||
export * from "./setResourcePolicyWhitelist";
|
||||
export * from "./setResourcePolicyRules";
|
||||
237
server/routers/policy/setResourcePolicyAccessControl.ts
Normal file
237
server/routers/policy/setResourcePolicyAccessControl.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
resourcePolicies,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { userPolicies } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyAcccessControlBodySchema = z.strictObject({
|
||||
sso: z.boolean(),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.int().positive()).openapi({
|
||||
type: "array"
|
||||
}),
|
||||
skipToIdpId: z.int().positive().optional().nullable().openapi({
|
||||
type: "integer",
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
const setResourcePolicyAccessControlParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource-policy/{resourceId}/access-control",
|
||||
description:
|
||||
"Set access control users for a resource policy, including SSO, users, roles, Identity provider.",
|
||||
tags: [OpenAPITags.Policy, OpenAPITags.User],
|
||||
request: {
|
||||
params: setResourcePolicyAccessControlParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyAcccessControlBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyAccessControl(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data;
|
||||
|
||||
const parsedParams =
|
||||
setResourcePolicyAccessControlParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Resource policy not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (idpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(
|
||||
and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Identity provider not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of the roleIds are admin roles
|
||||
const rolesToCheck = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(roles.orgId, policy.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const hasAdminRole = rolesToCheck.some((role) => role.isAdmin);
|
||||
|
||||
if (hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Admin role cannot be assigned to resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get all admin role IDs for this org to exclude from deletion
|
||||
const adminRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId)));
|
||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, policy.orgId),
|
||||
inArray(users.userId, userIds)
|
||||
)
|
||||
);
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, policy.orgId),
|
||||
inArray(roles.roleId, roleIds)
|
||||
)
|
||||
);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// Update SSO status
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
sso,
|
||||
idpId
|
||||
})
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
// Update roles
|
||||
if (adminRoleIds.length > 0) {
|
||||
await trx.delete(rolePolicies).where(
|
||||
and(
|
||||
eq(rolePolicies.resourcePolicyId, resourcePolicyId),
|
||||
ne(rolePolicies.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await trx
|
||||
.delete(rolePolicies)
|
||||
.where(eq(rolePolicies.resourcePolicyId, resourcePolicyId));
|
||||
}
|
||||
|
||||
const rolesToAdd = existingRoles.map(({ roleId }) => ({
|
||||
roleId,
|
||||
resourcePolicyId
|
||||
}));
|
||||
|
||||
if (rolesToAdd.length > 0) {
|
||||
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||
}
|
||||
|
||||
// Update users
|
||||
await trx
|
||||
.delete(userPolicies)
|
||||
.where(eq(userPolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
const usersToAdd = existingUsers.map(({ user }) => ({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: resourcePolicyId
|
||||
}));
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await trx.insert(userPolicies).values(usersToAdd);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy succesfully updated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
117
server/routers/policy/setResourcePolicyHeaderAuth.ts
Normal file
117
server/routers/policy/setResourcePolicyHeaderAuth.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicyHeaderAuth } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyHeaderAuthParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyHeaderAuthBodySchema = z.strictObject({
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
})
|
||||
.nullable()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/header-auth",
|
||||
description:
|
||||
"Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyHeaderAuthParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyHeaderAuthBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyHeaderAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyHeaderAuthParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyHeaderAuthBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { headerAuth } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (headerAuth !== null) {
|
||||
const headerAuthHash = await hashPassword(
|
||||
Buffer.from(
|
||||
`${headerAuth.user}:${headerAuth.password}`
|
||||
).toString("base64")
|
||||
);
|
||||
|
||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||
resourcePolicyId,
|
||||
headerAuthHash,
|
||||
extendedCompatibility: headerAuth.extendedCompatibility
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Header Authentication set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
106
server/routers/policy/setResourcePolicyPassword.ts
Normal file
106
server/routers/policy/setResourcePolicyPassword.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicyPassword } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyPasswordParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyPasswordBodySchema = z.strictObject({
|
||||
password: z.string().min(4).max(100).nullable()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/password",
|
||||
description:
|
||||
"Set the password for a resource policy. Setting the password to null will remove it.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyPasswordParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyPasswordBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyPassword(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyPasswordParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyPasswordBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { password } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyPassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await trx
|
||||
.insert(resourcePolicyPassword)
|
||||
.values({ resourcePolicyId, passwordHash });
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy password set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
106
server/routers/policy/setResourcePolicyPincode.ts
Normal file
106
server/routers/policy/setResourcePolicyPincode.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicyPincode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyPincodeParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyPincodeBodySchema = z.strictObject({
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.or(z.null())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/pincode",
|
||||
description:
|
||||
"Set the PIN code for a resource policy. Setting the PIN code to null will remove it.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyPincodeParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyPincodeBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyPincode(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyPincodeParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyPincodeBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { pincode } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyPincode)
|
||||
.where(
|
||||
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||
);
|
||||
|
||||
if (pincode) {
|
||||
const pincodeHash = await hashPassword(pincode);
|
||||
|
||||
await trx
|
||||
.insert(resourcePolicyPincode)
|
||||
.values({ resourcePolicyId, pincodeHash, digitLength: 6 });
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy PIN code set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
167
server/routers/policy/setResourcePolicyRules.ts
Normal file
167
server/routers/policy/setResourcePolicyRules.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const ruleSchema = z.strictObject({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||
type: "string",
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
priority: z.int().openapi({
|
||||
type: "integer",
|
||||
description: "Rule priority"
|
||||
}),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
const setResourcePolicyRulesBodySchema = z.strictObject({
|
||||
applyRules: z.boolean(),
|
||||
rules: z.array(ruleSchema)
|
||||
});
|
||||
|
||||
const setResourcePolicyRulesParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/rules",
|
||||
description:
|
||||
"Set all rules for a resource policy at once. This will replace all existing rules.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyRulesParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyRulesBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyRules(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyRulesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyRulesBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { applyRules, rules } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ applyRules })
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
await trx
|
||||
.delete(resourcePolicyRules)
|
||||
.where(
|
||||
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
|
||||
);
|
||||
|
||||
if (rules.length > 0) {
|
||||
await trx.insert(resourcePolicyRules).values(
|
||||
rules.map((rule) => ({
|
||||
resourcePolicyId,
|
||||
...rule
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy rules set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
132
server/routers/policy/setResourcePolicyWhitelist.ts
Normal file
132
server/routers/policy/setResourcePolicyWhitelist.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicies, resourcePolicyWhiteList } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyWhitelistBodySchema = z.strictObject({
|
||||
emailWhitelistEnabled: z.boolean(),
|
||||
emails: z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
});
|
||||
|
||||
const setResourcePolicyWhitelistParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/whitelist",
|
||||
description:
|
||||
"Set email whitelist for a resource policy. This will replace all existing emails.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyWhitelistParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyWhitelistBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyWhitelist(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = setResourcePolicyWhitelistBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = setResourcePolicyWhitelistParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { emailWhitelistEnabled, emails } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ emailWhitelistEnabled })
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
// delete all whitelist emails
|
||||
await trx
|
||||
.delete(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyWhiteList.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (emailWhitelistEnabled && emails.length > 0) {
|
||||
await trx.insert(resourcePolicyWhiteList).values(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
resourcePolicyId
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Whitelist set for resource policy successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
157
server/routers/policy/updateResourcePolicy.ts
Normal file
157
server/routers/policy/updateResourcePolicy.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import z from "zod";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import response from "@server/lib/response";
|
||||
|
||||
const updateResourcePolicyParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const updateResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Update a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: updateResourcePolicyParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: updateResourcePolicyBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = updateResourcePolicyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId));
|
||||
|
||||
const policy = result?.resourcePolicies;
|
||||
const org = result?.orgs;
|
||||
|
||||
if (!policy || !org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingPolicy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, updateData.niceId),
|
||||
eq(resourcePolicies.orgId, policy.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingPolicy &&
|
||||
existingPolicy.resourcePolicyId !== policy.resourcePolicyId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource policy with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPolicy = await db.transaction(async (trx) => {
|
||||
const [updated] = await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
...updateData
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
policy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (!updatedPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<ResourcePolicy>(res, {
|
||||
data: updatedPolicy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
db,
|
||||
loginPage,
|
||||
orgs,
|
||||
Resource,
|
||||
resources,
|
||||
resourcePolicies,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userResources
|
||||
userPolicies,
|
||||
userResources,
|
||||
domainNamespaces
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -20,13 +24,18 @@ import logger from "@server/logger";
|
||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
getUniqueResourceName,
|
||||
getUniqueResourcePolicyName
|
||||
} from "@server/db/names";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -311,8 +320,46 @@ async function createHttpResource(
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const adminRole = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId: policyNiceId,
|
||||
orgId,
|
||||
name: `default policy for ${niceId}`,
|
||||
sso: true,
|
||||
scope: "resource"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// make this policy visible by the admin role
|
||||
await trx.insert(rolePolicies).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
|
||||
// make this policy visible by the current user
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
@@ -328,22 +375,11 @@ async function createHttpResource(
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath,
|
||||
wildcard,
|
||||
health: "unknown"
|
||||
health: "unknown",
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
@@ -369,7 +405,7 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
if (build !== "oss") {
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
@@ -410,22 +446,10 @@ async function createRawResource(
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort
|
||||
// enableProxy
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
const adminRole = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
@@ -437,6 +461,44 @@ async function createRawResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId: policyNiceId,
|
||||
orgId,
|
||||
name: `default policy for ${niceId}`,
|
||||
sso: true,
|
||||
scope: "resource"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// make this policy visible by the admin role
|
||||
await trx.insert(rolePolicies).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
|
||||
// make this policy visible by the current user
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort,
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { newts, resources, sites, targets } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
newts,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { addPeer } from "../gerbil/peers";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourceSchema = z.strictObject({
|
||||
@@ -113,6 +118,18 @@ export async function deleteResource(
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete default resource policy
|
||||
if (deletedResource.defaultResourcePolicyId) {
|
||||
await db
|
||||
.delete(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
deletedResource.defaultResourcePolicyId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -60,64 +60,53 @@ export async function getResourceAuthInfo(
|
||||
|
||||
const isGuidInteger = /^\d+$/.test(resourceGuid);
|
||||
|
||||
const buildQuery = (whereClause: ReturnType<typeof eq>) =>
|
||||
db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePolicies,
|
||||
or(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.resourcePolicyId
|
||||
),
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(whereClause)
|
||||
.limit(1);
|
||||
|
||||
const [result] =
|
||||
isGuidInteger && build === "saas"
|
||||
? await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, Number(resourceGuid)))
|
||||
.limit(1)
|
||||
: await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceGuid, resourceGuid))
|
||||
.limit(1);
|
||||
? await buildQuery(
|
||||
eq(resources.resourceId, Number(resourceGuid))
|
||||
)
|
||||
: await buildQuery(eq(resources.resourceGuid, resourceGuid));
|
||||
|
||||
const resource = result?.resources;
|
||||
if (!resource) {
|
||||
@@ -126,11 +115,10 @@ export async function getResourceAuthInfo(
|
||||
);
|
||||
}
|
||||
|
||||
const pincode = result?.resourcePincode;
|
||||
const password = result?.resourcePassword;
|
||||
const headerAuth = result?.resourceHeaderAuth;
|
||||
const headerAuthExtendedCompatibility =
|
||||
result?.resourceHeaderAuthExtendedCompatibility;
|
||||
const policy = result?.resourcePolicies;
|
||||
const pincode = result?.resourcePolicyPincode;
|
||||
const password = result?.resourcePolicyPassword;
|
||||
const headerAuth = result?.resourcePolicyHeaderAuth;
|
||||
|
||||
const url = resource.fullDomain
|
||||
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
@@ -146,13 +134,13 @@ export async function getResourceAuthInfo(
|
||||
pincode: pincode !== null,
|
||||
headerAuth: headerAuth !== null,
|
||||
headerAuthExtendedCompatibility:
|
||||
headerAuthExtendedCompatibility !== null,
|
||||
sso: resource.sso,
|
||||
headerAuth?.extendedCompatibility ?? false,
|
||||
sso: policy?.sso ?? false,
|
||||
blockAccess: resource.blockAccess,
|
||||
url: url ?? "",
|
||||
wildcard: resource.wildcard ?? false,
|
||||
fullDomain: resource.fullDomain,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
whitelist: policy?.emailWhitelistEnabled ?? false,
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId,
|
||||
postAuthPath: resource.postAuthPath ?? null
|
||||
|
||||
109
server/routers/resource/getResourcePolicies.ts
Normal file
109
server/routers/resource/getResourcePolicies.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { db, resources } from "@server/db";
|
||||
import {
|
||||
queryResourcePolicy,
|
||||
type GetResourcePolicyResponse
|
||||
} from "@server/routers/policy/getResourcePolicy";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getResourcePoliciesParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
export type GetResourcePoliciesResponse = {
|
||||
defaultPolicy: GetResourcePolicyResponse;
|
||||
sharedPolicy: GetResourcePolicyResponse | null;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}/policies",
|
||||
description: "Get the inline and shared policies associated with a resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: getResourcePoliciesParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getResourcePolicies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getResourcePoliciesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select({
|
||||
defaultResourcePolicyId: resources.defaultResourcePolicyId,
|
||||
resourcePolicyId: resources.resourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.defaultResourcePolicyId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Resource has no default policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy, sharedPolicy] = await Promise.all([
|
||||
queryResourcePolicy({
|
||||
resourcePolicyId: resource.defaultResourcePolicyId
|
||||
}),
|
||||
resource.resourcePolicyId
|
||||
? queryResourcePolicy({
|
||||
resourcePolicyId: resource.resourcePolicyId
|
||||
})
|
||||
: null
|
||||
]);
|
||||
|
||||
return response<GetResourcePoliciesResponse>(res, {
|
||||
data: {
|
||||
defaultPolicy:
|
||||
// the policy will always be non nullable
|
||||
defaultPolicy as unknown as GetResourcePolicyResponse,
|
||||
sharedPolicy
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policies retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,3 +33,4 @@ export * from "./removeUserFromResource";
|
||||
export * from "./listAllResourceNames";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getResourcePolicies";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
db,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resources,
|
||||
roleResources,
|
||||
sites,
|
||||
@@ -163,10 +163,10 @@ function queryResourcesBase() {
|
||||
name: resources.name,
|
||||
ssl: resources.ssl,
|
||||
fullDomain: resources.fullDomain,
|
||||
passwordId: resourcePassword.passwordId,
|
||||
sso: resources.sso,
|
||||
pincodeId: resourcePincode.pincodeId,
|
||||
whitelist: resources.emailWhitelistEnabled,
|
||||
passwordId: resourcePolicyPassword.passwordId,
|
||||
sso: resourcePolicies.sso,
|
||||
pincodeId: resourcePolicyPincode.pincodeId,
|
||||
whitelist: resourcePolicies.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
@@ -174,29 +174,45 @@ function queryResourcesBase() {
|
||||
domainId: resources.domainId,
|
||||
niceId: resources.niceId,
|
||||
wildcard: resources.wildcard,
|
||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibilityId:
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||
health: resources.health
|
||||
health: resources.health,
|
||||
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibility:
|
||||
resourcePolicyHeaderAuth.extendedCompatibility
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
resourcePolicies,
|
||||
or(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.resourcePolicyId
|
||||
),
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(resourceHeaderAuth.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
@@ -206,10 +222,10 @@ function queryResourcesBase() {
|
||||
)
|
||||
.groupBy(
|
||||
resources.resourceId,
|
||||
resourcePassword.passwordId,
|
||||
resourcePincode.pincodeId,
|
||||
resourceHeaderAuth.headerAuthId,
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resourcePolicyPassword.passwordId,
|
||||
resourcePolicyPincode.pincodeId,
|
||||
resourcePolicyHeaderAuth.headerAuthId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,21 +371,21 @@ export async function listResources(
|
||||
case "protected":
|
||||
conditions.push(
|
||||
or(
|
||||
eq(resources.sso, true),
|
||||
eq(resources.emailWhitelistEnabled, true),
|
||||
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePincode.pincodeId)),
|
||||
not(isNull(resourcePassword.passwordId))
|
||||
eq(resourcePolicies.sso, true),
|
||||
eq(resourcePolicies.emailWhitelistEnabled, true),
|
||||
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePolicyPincode.pincodeId)),
|
||||
not(isNull(resourcePolicyPassword.passwordId))
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "not_protected":
|
||||
conditions.push(
|
||||
not(eq(resources.sso, true)),
|
||||
not(eq(resources.emailWhitelistEnabled, true)),
|
||||
isNull(resourceHeaderAuth.headerAuthId),
|
||||
isNull(resourcePincode.pincodeId),
|
||||
isNull(resourcePassword.passwordId)
|
||||
not(eq(resourcePolicies.sso, true)),
|
||||
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
|
||||
isNull(resourcePolicyHeaderAuth.headerAuthId),
|
||||
isNull(resourcePolicyPincode.pincodeId),
|
||||
isNull(resourcePolicyPassword.passwordId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -446,9 +462,9 @@ export async function listResources(
|
||||
ssl: row.ssl,
|
||||
fullDomain: row.fullDomain,
|
||||
passwordId: row.passwordId,
|
||||
sso: row.sso,
|
||||
sso: row.sso ?? false,
|
||||
pincodeId: row.pincodeId,
|
||||
whitelist: row.whitelist,
|
||||
whitelist: row.whitelist ?? false,
|
||||
http: row.http,
|
||||
protocol: row.protocol,
|
||||
proxyPort: row.proxyPort,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Resource, ResourcePolicy } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type GetMaintenanceInfoResponse = {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
@@ -8,3 +11,19 @@ export type GetMaintenanceInfoResponse = {
|
||||
maintenanceMessage: string | null;
|
||||
maintenanceEstimatedTime: string | null;
|
||||
};
|
||||
|
||||
export type AttachedResource = Pick<
|
||||
Resource,
|
||||
"resourceId" | "name" | "fullDomain"
|
||||
>;
|
||||
|
||||
export type ResourcePolicyWithResources = Pick<
|
||||
ResourcePolicy,
|
||||
"resourcePolicyId" | "niceId" | "name" | "orgId"
|
||||
> & {
|
||||
resources: Array<AttachedResource>;
|
||||
};
|
||||
|
||||
export type ListResourcePoliciesResponse = PaginatedResponse<{
|
||||
policies: Array<ResourcePolicyWithResources>;
|
||||
}>;
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
domainNamespaces,
|
||||
loginPage,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceRules,
|
||||
resourceWhitelist
|
||||
} from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
Resource,
|
||||
resourcePolicies,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
@@ -24,7 +35,10 @@ 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";
|
||||
@@ -68,7 +82,8 @@ 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()
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
resourcePolicyId: z.number().nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -165,7 +180,8 @@ const updateRawResourceBodySchema = z
|
||||
stickySession: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
proxyProtocol: z.boolean().optional(),
|
||||
proxyProtocolVersion: z.int().min(1).optional()
|
||||
proxyProtocolVersion: z.int().min(1).optional(),
|
||||
resourcePolicyId: z.number().nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -301,6 +317,42 @@ async function updateHttpResource(
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.wildcardSubdomain
|
||||
);
|
||||
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existingPolicy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
@@ -326,10 +378,6 @@ async function updateHttpResource(
|
||||
|
||||
// Wildcard subdomains are a paid feature
|
||||
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.wildcardSubdomain
|
||||
);
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -474,10 +522,6 @@ async function updateHttpResource(
|
||||
headers = null;
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.maintencePage
|
||||
);
|
||||
if (!isLicensed) {
|
||||
updateData.maintenanceModeEnabled = undefined;
|
||||
updateData.maintenanceModeType = undefined;
|
||||
@@ -535,38 +579,122 @@ async function updateRawResource(
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
let updatedResource: Resource | null = null;
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.niceId, updateData.niceId),
|
||||
eq(resources.orgId, resource.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingResource &&
|
||||
existingResource.resourceId !== resource.resourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedResource = await db
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
.limit(1);
|
||||
|
||||
if (updatedResource.length === 0) {
|
||||
await db.transaction(async (trx) => {
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
const [existingPolicy] = await trx
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// we are in an inline policy and we need to clear out the old tables
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(
|
||||
eq(
|
||||
resourceRules.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResourceConflict] = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.niceId, updateData.niceId),
|
||||
eq(resources.orgId, resource.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingResourceConflict &&
|
||||
existingResourceConflict.resourceId !== resource.resourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
[updatedResource] = await trx
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
});
|
||||
|
||||
if (!updatedResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -576,7 +704,7 @@ async function updateRawResource(
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource[0],
|
||||
data: updatedResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Non-http Resource updated successfully",
|
||||
|
||||
@@ -135,7 +135,7 @@ const listSitesSchema = z.object({
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
|
||||
@@ -144,7 +144,7 @@ export async function getOrgUser(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
"User does not have permission to get organization user details"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
23
src/app/[orgId]/settings/(private)/policies/layout.tsx
Normal file
23
src/app/[orgId]/settings/(private)/policies/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface PolicyLayoutPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
let org: GetOrgResponse | null = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings`);
|
||||
}
|
||||
|
||||
return <OrgProvider org={org}>{props.children}</OrgProvider>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface EditPolicyPageProps {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let policyResponse: GetResourcePolicyResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourcePolicyResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/resource-policy/${params.niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
policyResponse = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||
}
|
||||
|
||||
if (!policyResponse) {
|
||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePolicySetting", {
|
||||
policyName: policyResponse.name
|
||||
})}
|
||||
description={t("resourcePolicySettingDescription")}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResourcePolicyProvider policy={policyResponse}>
|
||||
<EditPolicyForm />
|
||||
</ResourcePolicyProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
|
||||
export interface CreateResourcePolicyPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function CreateResourcePolicyPage(
|
||||
props: CreateResourcePolicyPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePoliciesCreate")}
|
||||
description={t("resourcePoliciesCreateDescription")}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CreatePolicyForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface ResourcePoliciesPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default async function ResourcePoliciesPage(
|
||||
props: ResourcePoliciesPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let org: GetOrgResponse | null = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
let policies: ListResourcePoliciesResponse["policies"] = [];
|
||||
let pagination: ListResourcePoliciesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListResourcePoliciesResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/resource-policies?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
policies = responseData.policies;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePoliciesTitle")}
|
||||
description={t("resourcePoliciesDescription")}
|
||||
/>
|
||||
|
||||
<ResourcePoliciesTable
|
||||
policies={policies}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { orgNavSections } from "@app/app/navigation";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
try {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
`/org/${params.orgId}/user/${user.userId}`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const orgUser = await getOrgUser();
|
||||
const orgUser = await getCachedOrgUser(params.orgId, user.userId);
|
||||
|
||||
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
|
||||
throw new Error(t("userErrorNotAdminOrOwner"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,10 +96,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
|
||||
});
|
||||
navItems.push({
|
||||
title: t("rules"),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
|
||||
});
|
||||
// navItems.push({
|
||||
// title: t("rules"),
|
||||
// href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
|
||||
// });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -92,7 +92,13 @@ import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useTransition,
|
||||
useEffect
|
||||
} from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -218,7 +224,7 @@ export default function Page() {
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createLoading, startTransition] = useTransition();
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
const [niceId, setNiceId] = useState<string>("");
|
||||
|
||||
@@ -328,7 +334,7 @@ export default function Page() {
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description:
|
||||
build == "saas"
|
||||
build === "saas"
|
||||
? t("resourceRawDescriptionCloud")
|
||||
: t("resourceRawDescription")
|
||||
}
|
||||
@@ -473,8 +479,6 @@ export default function Page() {
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
setCreateLoading(true);
|
||||
|
||||
const baseData = baseForm.getValues();
|
||||
const isHttp = baseData.http;
|
||||
|
||||
@@ -610,8 +614,6 @@ export default function Page() {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
setCreateLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1471,7 +1473,7 @@ export default function Page() {
|
||||
console.log(httpForm.getValues());
|
||||
|
||||
if (baseValid && settingsValid) {
|
||||
onSubmit();
|
||||
startTransition(onSubmit);
|
||||
}
|
||||
}}
|
||||
loading={createLoading}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
GlobeIcon,
|
||||
GlobeLock,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ScanEye,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldIcon,
|
||||
SquareMousePointer,
|
||||
TicketCheck,
|
||||
Unplug,
|
||||
@@ -99,7 +101,7 @@ export const orgNavSections = (
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "saas"
|
||||
...(build === "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarRemoteExitNodes",
|
||||
@@ -134,6 +136,24 @@ export const orgNavSections = (
|
||||
}
|
||||
]
|
||||
},
|
||||
...(build !== "oss"
|
||||
? [
|
||||
{
|
||||
title: "sidebarPolicies",
|
||||
|
||||
icon: <ShieldIcon className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarResourcePolicies",
|
||||
href: "/{orgId}/settings/policies/resource",
|
||||
icon: (
|
||||
<GlobeIcon className="size-4 flex-none" />
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// PaidFeaturesAlert
|
||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
|
||||
@@ -28,15 +28,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -519,21 +519,21 @@ export default function HealthChecksTable({
|
||||
const health = row.original.hcHealth;
|
||||
if (health === "healthy") {
|
||||
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" />
|
||||
<span>{t("standaloneHcHealthStateHealthy")}</span>
|
||||
</span>
|
||||
);
|
||||
} else if (health === "unhealthy") {
|
||||
return (
|
||||
<span className="flex items-center space-x-2">
|
||||
<span className="text-red-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
<span>{t("standaloneHcHealthStateUnhealthy")}</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" />
|
||||
<span>{t("standaloneHcHealthStateUnknown")}</span>
|
||||
</span>
|
||||
|
||||
@@ -285,14 +285,14 @@ export default function MachineClientsTable({
|
||||
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("connected")}</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("disconnected")}</span>
|
||||
</span>
|
||||
|
||||
@@ -228,14 +228,14 @@ export default function PendingSitesTable({
|
||||
) {
|
||||
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>
|
||||
|
||||
@@ -194,22 +194,17 @@ export default function ProxyResourcesTable({
|
||||
});
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
const deleteResource = async (resourceId: number) => {
|
||||
await api.delete(`/resource/${resourceId}`).catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
});
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
@@ -775,7 +770,11 @@ export default function ProxyResourcesTable({
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
onConfirm={async () =>
|
||||
startTransition(() =>
|
||||
deleteResource(selectedResource!.id)
|
||||
)
|
||||
}
|
||||
string={selectedResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
|
||||
@@ -89,12 +89,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isAvailable ? (
|
||||
<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>Online</span>
|
||||
</span>
|
||||
) : (
|
||||
<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>Offline</span>
|
||||
</span>
|
||||
|
||||
311
src/components/ResourcePoliciesTable.tsx
Normal file
311
src/components/ResourcePoliciesTable.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type {
|
||||
AttachedResource,
|
||||
ListResourcePoliciesResponse
|
||||
} from "@server/routers/resource/types";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Waypoints
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import type { ExtendedColumnDef } from "./ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "./ui/dropdown-menu";
|
||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number];
|
||||
|
||||
export type ResourcePoliciesTableProps = {
|
||||
policies: Array<ResourcePolicyRow>;
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export function ResourcePoliciesTable({
|
||||
policies,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: ResourcePoliciesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResourcePolicy, setSelectedResourcePolicy] =
|
||||
useState<ResourcePolicyRow | null>(null);
|
||||
|
||||
const deleteResourcePolicy = async (resourcePolicyId: number) => {
|
||||
await api
|
||||
.delete(`/resource-policy/${resourcePolicyId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function ResourceListCell({
|
||||
resources
|
||||
}: {
|
||||
resources?: AttachedResource[];
|
||||
}) {
|
||||
if (!resources || resources.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="LOOK_FOR_ME"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResourcesEmpty")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResources", {
|
||||
count: resources.length
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-70">
|
||||
{resources.map((resource) => (
|
||||
<DropdownMenuItem
|
||||
key={resource.resourceId}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.name}
|
||||
</div>
|
||||
<span
|
||||
className={`capitalize text-muted-foreground`}
|
||||
>
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const proxyColumns: ExtendedColumnDef<ResourcePolicyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.name}</span>
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.niceId || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
accessorKey: "resources",
|
||||
friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("resourcePoliciesAttachedResourcesColumnTitle")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <ResourceListCell resources={row.original.resources} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const policyRow = 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">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedResourcePolicy(policyRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.ResourcePolicies]}
|
||||
/>
|
||||
{selectedResourcePolicy && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedResourcePolicy(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourcePolicyQuestionRemove")}</p>
|
||||
<p>{t("resourcePolicyMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourcePolicyDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteResourcePolicy(
|
||||
selectedResourcePolicy.resourcePolicyId
|
||||
)
|
||||
}
|
||||
string={selectedResourcePolicy.name}
|
||||
title={t("resourcePolicyDelete")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
columns={proxyColumns}
|
||||
rows={policies}
|
||||
tableId="resource-policies"
|
||||
searchPlaceholder={t("resourcePoliciesSearch")}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/policies/resource/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
addButtonText={t("resourcePoliciesAdd")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
enableColumnVisibility
|
||||
columnVisibility={{ niceId: false }}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -61,12 +61,19 @@ export function SettingsSectionBody({
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,12 +34,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.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>
|
||||
|
||||
@@ -226,14 +226,14 @@ export default function SitesTable({
|
||||
) {
|
||||
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>
|
||||
|
||||
@@ -25,11 +25,15 @@ export function StrategySelect<TValue extends string>({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
cols
|
||||
cols = 1
|
||||
}: StrategySelectProps<TValue>) {
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||
TValue | undefined
|
||||
>(defaultValue);
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
|
||||
const selected = isControlled
|
||||
? (controlledValue ?? undefined)
|
||||
: uncontrolledSelected;
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
@@ -39,7 +43,11 @@ export function StrategySelect<TValue extends string>({
|
||||
if (!isControlled) setUncontrolledSelected(typedValue);
|
||||
onChange?.(typedValue);
|
||||
}}
|
||||
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
|
||||
style={{
|
||||
// @ts-expect-error
|
||||
"--cols": `repeat(${cols}, 1fr)`
|
||||
}}
|
||||
className="grid md:grid-cols-(--cols) gap-4"
|
||||
>
|
||||
{options.map((option: StrategyOption<TValue>) => (
|
||||
<label
|
||||
|
||||
@@ -436,14 +436,14 @@ export default function UserDevicesTable({
|
||||
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("connected")}</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("disconnected")}</span>
|
||||
</span>
|
||||
|
||||
@@ -23,6 +23,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
onSearch: (query: string) => void;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
lockedIds?: Set<string>;
|
||||
};
|
||||
|
||||
export function MultiSelectContent<T extends TagValue>({
|
||||
@@ -32,7 +33,8 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
value,
|
||||
options,
|
||||
onSearch,
|
||||
onChange
|
||||
onChange,
|
||||
lockedIds
|
||||
}: MultiSelectTagsProps<T>) {
|
||||
const t = useTranslations();
|
||||
const selectedValues = new Set(value.map((v) => v.id));
|
||||
@@ -48,33 +50,38 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
{emptyPlaceholder ?? t("noResults")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
value={option.id}
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [...value, option];
|
||||
}
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.has(option.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
))}
|
||||
{options.map((option) => {
|
||||
const isLocked = lockedIds?.has(option.id);
|
||||
return (
|
||||
<CommandItem
|
||||
value={option.id}
|
||||
key={option.id}
|
||||
disabled={isLocked}
|
||||
onSelect={() => {
|
||||
if (isLocked) return;
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [...value, option];
|
||||
}
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.has(option.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type MultiSelectTagsProps,
|
||||
type TagValue,
|
||||
@@ -16,10 +16,12 @@ export interface MultiSelectInputProps<
|
||||
T extends TagValue
|
||||
> extends MultiSelectTagsProps<T> {
|
||||
buttonText?: string;
|
||||
lockedIds?: Set<string>;
|
||||
}
|
||||
|
||||
export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonText,
|
||||
lockedIds,
|
||||
...props
|
||||
}: MultiSelectInputProps<T>) {
|
||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||
@@ -52,46 +54,63 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
"overflow-x-auto"
|
||||
)}
|
||||
>
|
||||
{props.value.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{option.text}
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = props.value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
{props.value.map((option) => {
|
||||
const isLocked = lockedIds?.has(option.id);
|
||||
return (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
|
||||
isLocked && "opacity-60"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{option.text}
|
||||
{isLocked ? (
|
||||
<span className="p-0.5 flex-none">
|
||||
<LockIcon className="size-3" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (
|
||||
selectedValues.has(
|
||||
option.id
|
||||
)
|
||||
) {
|
||||
newValues =
|
||||
props.value.filter(
|
||||
(v) =>
|
||||
v.id !==
|
||||
option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="pl-1 font-normal">{buttonText}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MultiSelectContent {...props} />
|
||||
<MultiSelectContent {...props} lockedIds={lockedIds} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyAuthMethodsSectionForm ───────────────────────────────────────
|
||||
|
||||
const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
|
||||
export type CreatePolicyAuthMethodsSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
};
|
||||
|
||||
export function CreatePolicyAuthMethodsSectionForm({
|
||||
form: parentForm
|
||||
}: CreatePolicyAuthMethodsSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
password: null,
|
||||
pincode: null,
|
||||
headerAuth: null
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("password", values.password as any);
|
||||
parentForm.setValue("pincode", values.pincode as any);
|
||||
parentForm.setValue("headerAuth", values.headerAuth as any);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const password = useWatch({
|
||||
control: form.control,
|
||||
name: "password"
|
||||
});
|
||||
const pincode = useWatch({
|
||||
control: form.control,
|
||||
name: "pincode"
|
||||
});
|
||||
const headerAuth = useWatch({
|
||||
control: form.control,
|
||||
name: "headerAuth"
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const pincodeForm = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: "" }
|
||||
});
|
||||
|
||||
const headerAuthForm = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyAuthMethodAdd")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Password Credenza */}
|
||||
<Credenza
|
||||
open={isSetPasswordOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPasswordOpen(val);
|
||||
if (!val) passwordForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePasswordSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePasswordSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit((data) => {
|
||||
form.setValue("password", data);
|
||||
setIsSetPasswordOpen(false);
|
||||
passwordForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-password-form"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-password-form">
|
||||
{t("resourcePasswordSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Pincode Credenza */}
|
||||
<Credenza
|
||||
open={isSetPincodeOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPincodeOpen(val);
|
||||
if (!val) pincodeForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePincodeSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePincodeSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...pincodeForm}>
|
||||
<form
|
||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||
form.setValue("pincode", data);
|
||||
setIsSetPincodeOpen(false);
|
||||
pincodeForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-pincode-form"
|
||||
>
|
||||
<FormField
|
||||
control={pincodeForm.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePincode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
autoComplete="false"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-pincode-form">
|
||||
{t("resourcePincodeSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Header Auth Credenza */}
|
||||
<Credenza
|
||||
open={isSetHeaderAuthOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetHeaderAuthOpen(val);
|
||||
if (!val) headerAuthForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceHeaderAuthSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...headerAuthForm}>
|
||||
<form
|
||||
onSubmit={headerAuthForm.handleSubmit(
|
||||
(data) => {
|
||||
form.setValue("headerAuth", data);
|
||||
setIsSetHeaderAuthOpen(false);
|
||||
headerAuthForm.reset();
|
||||
}
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="set-header-auth-form"
|
||||
>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-toggle"
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-header-auth-form">
|
||||
{t("resourceHeaderAuthSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{/* Password row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
{t("resourcePasswordProtection", {
|
||||
status: password
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
password
|
||||
? () => form.setValue("password", null)
|
||||
: () => setIsSetPasswordOpen(true)
|
||||
}
|
||||
>
|
||||
{password
|
||||
? t("passwordRemove")
|
||||
: t("passwordAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pincode row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
{t("resourcePincodeProtection", {
|
||||
status: pincode
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
pincode
|
||||
? () => form.setValue("pincode", null)
|
||||
: () => setIsSetPincodeOpen(true)
|
||||
}
|
||||
>
|
||||
{pincode ? t("pincodeRemove") : t("pincodeAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header auth row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
|
||||
>
|
||||
<Bot size="14" />
|
||||
<span>
|
||||
{headerAuth
|
||||
? t(
|
||||
"resourceHeaderAuthProtectionEnabled"
|
||||
)
|
||||
: t(
|
||||
"resourceHeaderAuthProtectionDisabled"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
headerAuth
|
||||
? () =>
|
||||
form.setValue("headerAuth", null)
|
||||
: () => setIsSetHeaderAuthOpen(true)
|
||||
}
|
||||
>
|
||||
{headerAuth
|
||||
? t("headerAuthRemove")
|
||||
: t("headerAuthAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user