Compare commits

..

14 Commits

Author SHA1 Message Date
Owen Schwartz 0ae293c653 New translations en-us.json (Spanish) 2026-04-22 14:07:41 -07:00
Owen Schwartz 7dfae64e1e New translations en-us.json (Norwegian Bokmal) 2026-04-22 14:07:39 -07:00
Owen Schwartz 0925c31d64 New translations en-us.json (Chinese Simplified) 2026-04-22 14:07:37 -07:00
Owen Schwartz ee59fba976 New translations en-us.json (Turkish) 2026-04-22 14:07:35 -07:00
Owen Schwartz 13b0ca583e New translations en-us.json (Russian) 2026-04-22 14:07:33 -07:00
Owen Schwartz 10c26a3b94 New translations en-us.json (Portuguese) 2026-04-22 14:07:31 -07:00
Owen Schwartz 21a307196f New translations en-us.json (Polish) 2026-04-22 14:07:30 -07:00
Owen Schwartz 96895b9da5 New translations en-us.json (Dutch) 2026-04-22 14:07:28 -07:00
Owen Schwartz 3b26ca4683 New translations en-us.json (Korean) 2026-04-22 14:07:26 -07:00
Owen Schwartz 40d1e62c39 New translations en-us.json (Italian) 2026-04-22 14:07:24 -07:00
Owen Schwartz 82a9ac2390 New translations en-us.json (German) 2026-04-22 14:07:22 -07:00
Owen Schwartz 0148df5a16 New translations en-us.json (Czech) 2026-04-22 14:07:20 -07:00
Owen Schwartz d03ff3df67 New translations en-us.json (Bulgarian) 2026-04-22 14:07:18 -07:00
Owen Schwartz 9749f8d817 New translations en-us.json (French) 2026-04-22 14:07:16 -07:00
795 changed files with 27992 additions and 75378 deletions
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth.
-7
View File
@@ -1,7 +0,0 @@
---
description:
alwaysApply: true
---
Proxy resources = public resources
Private resources = client resources = site resources
-7
View File
@@ -1,7 +0,0 @@
---
alwaysApply: true
---
When writing TypeScript:
Prefer to use types instead of interfaces.
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When creating forms, use React form for validation and use Zod schemas.
-2
View File
@@ -34,5 +34,3 @@ build.ts
tsconfig.json tsconfig.json
Dockerfile* Dockerfile*
drizzle.config.ts drizzle.config.ts
allowedDevOrigins.json
scratch/
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [fosrl]
+2 -3
View File
@@ -14,13 +14,12 @@ body:
label: Environment label: Environment
description: Please fill out the relevant details below for your environment. description: Please fill out the relevant details below for your environment.
value: | value: |
- OS Type & Version: - OS Type & Version: (e.g., Ubuntu 22.04)
- Pangolin Version: - Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version: - Gerbil Version:
- Traefik Version: - Traefik Version:
- Newt Version: - Newt Version:
- Client Version: - Olm Version: (if applicable)
validations: validations:
required: true required: true
+29 -19
View File
@@ -1,42 +1,52 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
npm-dependencies: dev-patch-updates:
patterns: dependency-type: "development"
- "*" update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
- package-ecosystem: "docker" - package-ecosystem: "docker"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
docker-dependencies: patch-updates:
patterns: update-types:
- "*" - "patch"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/install" directory: "/install"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
go-install-dependencies: patch-updates:
patterns: update-types:
- "*" - "patch"
minor-updates:
update-types:
- "minor"
+94 -38
View File
@@ -62,7 +62,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Monitor storage space - name: Monitor storage space
run: | run: |
@@ -77,7 +77,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -134,7 +134,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Monitor storage space - name: Monitor storage space
run: | run: |
@@ -149,7 +149,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -256,7 +256,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Extract tag name - name: Extract tag name
id: get-tag id: get-tag
@@ -407,27 +407,35 @@ jobs:
shell: bash shell: bash
- name: Login to GitHub Container Registry (for cosign) - name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign - name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing # cosign is used to sign and verify container images (key and keyless)
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: v3.0.6
- name: Sign (GHCR, keyless) - name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor. # Sign each image by digest using keyless (OIDC) and key-based signing,
# Signatures are stored in the registry alongside the image. # then verify both the public key signature and the keyless OIDC signature.
env: env:
TAG: ${{ env.TAG }} TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true" COSIGN_YES: "true"
run: | run: |
set -euo pipefail set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release # Determine if this is an RC release
IS_RC="false" IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then if [[ "$TAG" == *"-rc."* ]]; then
@@ -455,47 +463,95 @@ jobs:
) )
fi fi
FAILED_TAGS=() # Sign each image variant for both registries
SUCCESSFUL_TAGS=() for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do # Wrap the entire tag processing in error handling
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}" (
TAG_FAILED=false set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
( echo "==> cosign sign (keyless) --recursive ${REF}"
set -e cosign sign --recursive "${REF}"
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${GHCR_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}" echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --recursive "${REF}" cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then # Retry wrapper for verification to handle registry propagation delays
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}" retry_verify() {
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") local cmd="$1"
else local attempts=6
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}" local delay=5
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") local i=1
fi until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
fi
done
done done
# Report summary
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Sign Summary" echo "Sign and Verify Summary"
echo "==========================================" echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:" echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag" echo " - $tag"
done done
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway" echo ""
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
else else
echo "✓ All images signed successfully!" echo "✓ All images signed and verified successfully!"
fi fi
shell: bash shell: bash
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '24' node-version: '24'
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
skopeo --version skopeo --version
- name: Install cosign - name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Input check - name: Input check
run: | run: |
+39
View File
@@ -0,0 +1,39 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"
+160
View File
@@ -0,0 +1,160 @@
name: SAAS Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
pre-run:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances started"
release-arm:
name: Build and Release (ARM64)
runs-on: [self-hosted, linux, arm64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download MaxMind GeoLite2 databases
env:
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
run: |
echo "Downloading MaxMind GeoLite2 databases..."
# Download GeoLite2-Country
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-Country.tar.gz
# Download GeoLite2-ASN
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-ASN.tar.gz
# Extract the .mmdb files
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
# Verify files exist
if [ ! -f "GeoLite2-Country.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
exit 1
fi
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
exit 1
fi
# Clean up tar files
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
echo "MaxMind databases downloaded successfully"
ls -lh GeoLite2-*.mmdb
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Build and push Docker images (Docker Hub - ARM64)
run: |
TAG=${{ env.TAG }}
make build-saas tag=$TAG
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
shell: bash
post-run:
needs: [pre-run, release-arm]
if: >-
${{
always() &&
needs.pre-run.result == 'success' &&
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
}}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances stopped"
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Node - name: Install Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '24' node-version: '24'
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build Docker image sqlite - name: Build Docker image sqlite
run: make dev-build-sqlite run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build Docker image pg - name: Build Docker image pg
run: make dev-build-pg run: make dev-build-pg
+2 -4
View File
@@ -17,9 +17,9 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
*.db *.db
*.sqlite* *.sqlite
!Dockerfile.sqlite !Dockerfile.sqlite
*.sqlite3* *.sqlite3
*.log *.log
.machinelogs*.json .machinelogs*.json
*-audit.json *-audit.json
@@ -54,5 +54,3 @@ hydrateSaas.ts
CLAUDE.md CLAUDE.md
drizzle.config.ts drizzle.config.ts
server/setup/migrations.ts server/setup/migrations.ts
solo.yml
allowedDevOrigins.json
+1 -4
View File
@@ -18,8 +18,5 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true
"cSpell.words": [
"nessicary"
]
} }
+2 -2
View File
@@ -41,7 +41,7 @@
</strong> </strong>
</p> </p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls. Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
## Installation ## Installation
@@ -107,7 +107,7 @@ the docs to illustrate some basic ideas.
## Licensing ## Licensing
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net). Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
## Contributions ## Contributions
-28
View File
@@ -1,28 +0,0 @@
import { CommandModule } from "yargs";
import { db, certificates } from "@server/db";
type ClearCertificatesArgs = {};
export const clearCertificates: CommandModule<{}, ClearCertificatesArgs> = {
command: "clear-certificates",
describe: "Delete all entries from the certificates table",
builder: (yargs) => {
return yargs;
},
handler: async (argv: {}) => {
try {
console.log("Clearing all certificates from the database...");
const deleted = await db.delete(certificates).returning();
console.log(
`Deleted ${deleted.length} certificate(s) from the database`
);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};
-60
View File
@@ -1,60 +0,0 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
/**
* Disable 2FA for a user by email address.
*/
type DisableUser2faArgs = {
email: string;
};
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
command: "disable-user-2fa",
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
builder: (yargs) => {
return yargs.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
});
},
handler: async (argv: { email: string }) => {
try {
const { email } = argv;
console.log(`Looking for user with email: ${email}`);
// Find the user by email
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (!user.twoFactorEnabled) {
console.log(`2FA is already disabled for user '${email}'.`);
process.exit(0);
}
// Update user: disable 2FA and clear secret
await db.update(users)
.set({
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorSetupRequested: false
})
.where(eq(users.userId, user.userId));
console.log(`2FA disabled for user '${email}'.`);
process.exit(0);
} catch (error) {
console.error("Error disabling 2FA:", error);
process.exit(1);
}
}
};
+1 -133
View File
@@ -1,5 +1,5 @@
import { CommandModule } from "yargs"; import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db"; import { db, idpOidcConfig, licenseKey } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto"; import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -129,15 +129,9 @@ export const rotateServerSecret: CommandModule<
console.log("\nReading encrypted data from database..."); console.log("\nReading encrypted data from database...");
const idpConfigs = await db.select().from(idpOidcConfig); const idpConfigs = await db.select().from(idpOidcConfig);
const licenseKeys = await db.select().from(licenseKey); const licenseKeys = await db.select().from(licenseKey);
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`); console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
// Prepare all decrypted and re-encrypted values // Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values..."); console.log("\nDecrypting and re-encrypting values...");
@@ -155,27 +149,8 @@ export const rotateServerSecret: CommandModule<
encryptedInstanceId: string; encryptedInstanceId: string;
}; };
type CertUpdate = {
certId: number;
encryptedCertFile: string | null;
encryptedKeyFile: string | null;
};
type StreamingDestinationUpdate = {
destinationId: number;
encryptedConfig: string;
};
type WebhookActionUpdate = {
webhookActionId: number;
encryptedConfig: string;
};
const idpUpdates: IdpUpdate[] = []; const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
// Process idpOidcConfig entries // Process idpOidcConfig entries
for (const idpConfig of idpConfigs) { for (const idpConfig of idpConfigs) {
@@ -242,70 +217,6 @@ export const rotateServerSecret: CommandModule<
} }
} }
// Process certificate entries
for (const cert of certs) {
try {
const encryptedCertFile = cert.certFile
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
: null;
const encryptedKeyFile = cert.keyFile
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
: null;
certUpdates.push({
certId: cert.certId,
encryptedCertFile,
encryptedKeyFile
});
} catch (error) {
console.error(
`Error processing certificate ${cert.certId} (${cert.domain}):`,
error
);
throw error;
}
}
// Process eventStreamingDestinations entries
for (const dest of streamingDestinations) {
try {
const decryptedConfig = decrypt(dest.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
streamingDestinationUpdates.push({
destinationId: dest.destinationId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing event streaming destination ${dest.destinationId}:`,
error
);
throw error;
}
}
// Process alertWebhookActions entries
for (const webhook of webhookActions) {
try {
if (webhook.config == null) continue;
const decryptedConfig = decrypt(webhook.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
webhookActionUpdates.push({
webhookActionId: webhook.webhookActionId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing alert webhook action ${webhook.webhookActionId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction // Perform all database updates in a single transaction
console.log("\nUpdating database in transaction..."); console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
@@ -339,50 +250,10 @@ export const rotateServerSecret: CommandModule<
instanceId: update.encryptedInstanceId instanceId: update.encryptedInstanceId
}); });
} }
// Update certificate entries
for (const update of certUpdates) {
await trx
.update(certificates)
.set({
certFile: update.encryptedCertFile,
keyFile: update.encryptedKeyFile
})
.where(eq(certificates.certId, update.certId));
}
// Update event streaming destination entries
for (const update of streamingDestinationUpdates) {
await trx
.update(eventStreamingDestinations)
.set({ config: update.encryptedConfig })
.where(
eq(
eventStreamingDestinations.destinationId,
update.destinationId
)
);
}
// Update alert webhook action entries
for (const update of webhookActionUpdates) {
await trx
.update(alertWebhookActions)
.set({ config: update.encryptedConfig })
.where(
eq(
alertWebhookActions.webhookActionId,
update.webhookActionId
)
);
}
}); });
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
// Update config file with new secret // Update config file with new secret
console.log("\nUpdating config file..."); console.log("\nUpdating config file...");
@@ -399,9 +270,6 @@ export const rotateServerSecret: CommandModule<
console.log(`\nSummary:`); console.log(`\nSummary:`);
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
console.log(` - License keys: ${licenseKeyUpdates.length}`); console.log(` - License keys: ${licenseKeyUpdates.length}`);
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log( console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.` `\n IMPORTANT: Restart the server for the new secret to take effect.`
); );
-85
View File
@@ -1,85 +0,0 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
type SetServerAdminArgs = {
email: string;
remove: boolean;
};
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
command: "set-server-admin",
describe: "Add or remove server admin by email address",
builder: (yargs) => {
return yargs
.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
})
.option("remove", {
type: "boolean",
default: false,
describe: "Remove server admin status from the user"
});
},
handler: async (argv: SetServerAdminArgs) => {
try {
const email = argv.email.trim().toLowerCase();
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (argv.remove) {
if (!user.serverAdmin) {
console.log(`User '${email}' is not a server admin`);
process.exit(0);
}
const serverAdmins = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (serverAdmins.length <= 1) {
console.error(
"Cannot remove server admin: at least one server admin must exist"
);
process.exit(1);
}
await db
.update(users)
.set({ serverAdmin: false })
.where(eq(users.userId, user.userId));
console.log(`Server admin status removed from user '${email}'`);
process.exit(0);
}
if (user.serverAdmin) {
console.log(`User '${email}' is already a server admin`);
process.exit(0);
}
await db
.update(users)
.set({ serverAdmin: true })
.where(eq(users.userId, user.userId));
console.log(`User '${email}' has been marked as a server admin`);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};
-6
View File
@@ -9,9 +9,6 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient"; import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa";
import { setServerAdmin } from "./commands/setServerAdmin";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -22,8 +19,5 @@ yargs(hideBin(process.argv))
.command(clearLicenseKeys) .command(clearLicenseKeys)
.command(deleteClient) .command(deleteClient)
.command(generateOrgCaKeys) .command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.command(setServerAdmin)
.demandCommand() .demandCommand()
.help().argv; .help().argv;
-12
View File
@@ -1,12 +0,0 @@
services:
mailer:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
volumes:
- mailpit-storage:/data
environment:
- MP_DATABASE=/data/mailpit.db
volumes:
mailpit-storage:
+22 -15
View File
@@ -1,47 +1,54 @@
api: api:
insecure: true insecure: true
dashboard: true dashboard: true
providers: providers:
http: http:
endpoint: http://pangolin:3001/api/v1/traefik-config endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: 5s pollInterval: "5s"
file: file:
filename: /etc/traefik/dynamic_config.yml filename: "/etc/traefik/dynamic_config.yml"
experimental: experimental:
plugins: plugins:
badger: badger:
moduleName: github.com/fosrl/badger moduleName: "github.com/fosrl/badger"
version: v1.4.1 version: "{{.BadgerVersion}}"
log: log:
level: INFO level: "INFO"
format: common format: "common"
maxSize: 100 maxSize: 100
maxBackups: 3 maxBackups: 3
maxAge: 3 maxAge: 3
compress: true compress: true
certificatesResolvers: certificatesResolvers:
letsencrypt: letsencrypt:
acme: acme:
httpChallenge: httpChallenge:
entryPoint: web entryPoint: web
email: '{{.LetsEncryptEmail}}' email: "{{.LetsEncryptEmail}}"
storage: /letsencrypt/acme.json storage: "/letsencrypt/acme.json"
caServer: https://acme-v02.api.letsencrypt.org/directory caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints: entryPoints:
web: web:
address: ':80' address: ":80"
websecure: websecure:
address: ':443' address: ":443"
transport: transport:
respondingTimeouts: respondingTimeouts:
readTimeout: 30m readTimeout: "30m"
http: http:
tls: tls:
certResolver: letsencrypt certResolver: "letsencrypt"
encodedCharacters: encodedCharacters:
allowEncodedSlash: true allowEncodedSlash: true
allowEncodedQuestionMark: true allowEncodedQuestionMark: true
serversTransport: serversTransport:
insecureSkipVerify: true insecureSkipVerify: true
ping: ping:
entryPoint: web entryPoint: "web"
View File
+1 -1
View File
@@ -1,4 +1,4 @@
import { APP_PATH } from "./server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import path from "path"; import path from "path";
+1 -5
View File
@@ -22,8 +22,7 @@ server:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"] allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false credentials: false
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}} {{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
{{if .EnableEmail}} {{if .EnableEmail}}
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"
@@ -37,6 +36,3 @@ flags:
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: false disable_user_create_org: false
allow_raw_resources: true allow_raw_resources: true
{{if .IsPostgreSQL}}postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
+11 -60
View File
@@ -1,23 +1,15 @@
name: pangolin name: pangolin
services: services:
pangolin: pangolin:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
limits: limits:
memory: 2g memory: 1g
reservations: reservations:
memory: 512m memory: 256m
{{if or .IsPostgreSQL .IsRedis}}depends_on:
{{if .IsPostgreSQL}}postgres:
condition: service_healthy{{end}}
{{if .IsRedis}}redis:
condition: service_healthy{{end}}
networks:
- default
- backend{{end}}
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:
@@ -25,8 +17,8 @@ services:
interval: "10s" interval: "10s"
timeout: "10s" timeout: "10s"
retries: 15 retries: 15
{{if .InstallGerbil}}
{{if .InstallGerbil}}gerbil: gerbil:
image: docker.io/fosrl/gerbil:{{.GerbilVersion}} image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
@@ -47,16 +39,17 @@ services:
- 21820:21820/udp - 21820:21820/udp
- 443:443 - 443:443
- 443:443/udp # For http3 QUIC if desired - 443:443/udp # For http3 QUIC if desired
- 80:80{{end}} - 80:80
{{end}}
traefik: traefik:
image: docker.io/traefik:v3.6 image: docker.io/traefik:v3.6
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} {{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
ports: ports:
- 443:443 - 443:443
- 80:80{{end}} - 80:80
{{end}}
depends_on: depends_on:
pangolin: pangolin:
condition: service_healthy condition: service_healthy
@@ -67,50 +60,8 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
{{if .IsPostgreSQL}}postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: pangolin
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
POSTGRES_DB: pangolin
volumes:
- ./postgres18:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pangolin"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend{{end}}
{{if .IsRedis}}redis:
image: redis:8-trixie
container_name: redis
restart: unless-stopped
command: >
redis-server
--save 3600 1000
--appendonly yes
--requirepass {{.IsRedisPass}}
volumes:
- ./redis8:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
networks:
- backend{{end}}
networks: networks:
default: default:
driver: bridge driver: bridge
name: pangolin_frontend name: pangolin
{{if .EnableIPv6}} enable_ipv6: true{{end}} {{if .EnableIPv6}} enable_ipv6: true{{end}}
{{if or .IsPostgreSQL .IsRedis}} backend:
driver: bridge
name: pangolin_backend
internal: true{{end}}
-4
View File
@@ -1,4 +0,0 @@
{{if .IsRedis}}redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"{{end}}
+1 -70
View File
@@ -6,13 +6,12 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func installCrowdsec(config Config, installDir string) error { func installCrowdsec(config Config) error {
if err := stopContainers(config.InstallationContainerType); err != nil { if err := stopContainers(config.InstallationContainerType); err != nil {
return fmt.Errorf("failed to stop containers: %v", err) return fmt.Errorf("failed to stop containers: %v", err)
@@ -41,8 +40,6 @@ func installCrowdsec(config Config, installDir string) error {
os.Exit(1) os.Exit(1)
} }
setupTraefikLogRotate(installDir)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err) fmt.Printf("Error copying docker service: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -211,69 +208,3 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
fmt.Println("Added dependency of crowdsec to traefik") fmt.Println("Added dependency of crowdsec to traefik")
return nil return nil
} }
// setupTraefikLogRotate writes a logrotate config for the Traefik access log
// that CrowdSec depends on. This is only needed when CrowdSec is installed
// because the default Pangolin install does not enable Traefik access logs.
//
// copytruncate is used so Traefik does not need to be restarted or sent a
// signal after rotation — it keeps writing to the same file descriptor while
// the rotated copy is made and the original is truncated in place.
func setupTraefikLogRotate(installDir string) {
const logrotateDir = "/etc/logrotate.d"
const logrotateFile = "/etc/logrotate.d/pangolin-traefik"
logPath := filepath.Join(installDir, "config/traefik/logs/access.log")
if os.Geteuid() != 0 {
fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.")
fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,")
fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:")
printLogrotateConfig(logPath)
return
}
config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec.
# Generated by the Pangolin installer. Safe to edit.
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
if err := os.MkdirAll(logrotateDir, 0755); err != nil {
fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err)
return
}
if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil {
fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err)
fmt.Println("[logrotate] Set it up manually:")
printLogrotateConfig(logPath)
return
}
fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile)
fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.")
}
// printLogrotateConfig prints a logrotate config block to stdout so users can
// set it up manually when the installer cannot write to /etc.
func printLogrotateConfig(logPath string) {
fmt.Printf(`
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
}
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.25.0
require ( require (
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.44.0 golang.org/x/term v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
) )
+4 -4
View File
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+21 -58
View File
@@ -4,7 +4,6 @@ import (
"crypto/rand" "crypto/rand"
"embed" "embed"
"encoding/base64" "encoding/base64"
"flag"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -54,13 +53,9 @@ type Config struct {
InstallGerbil bool InstallGerbil bool
TraefikBouncerKey string TraefikBouncerKey string
DoCrowdsecInstall bool DoCrowdsecInstall bool
EnableMaxMind bool EnableGeoblocking bool
Secret string Secret string
IsEnterprise bool IsEnterprise bool
IsPostgreSQL bool
IsPostgreSQLPass string
IsRedis bool
IsRedisPass string
} }
type SupportedContainer string type SupportedContainer string
@@ -71,14 +66,8 @@ const (
Undefined SupportedContainer = "undefined" Undefined SupportedContainer = "undefined"
) )
var redisFlag *bool
func main() { func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!") fmt.Println("Welcome to the Pangolin installer!")
@@ -130,11 +119,11 @@ func main() {
fmt.Println("\nConfiguration files created successfully!") fmt.Println("\nConfiguration files created successfully!")
// Download MaxMind Country / ASN database if requested // Download MaxMind database if requested
if config.EnableMaxMind { if config.EnableGeoblocking {
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===") fmt.Println("\n=== Downloading MaxMind Database ===")
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind databases: %v\n", err) fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can download it manually later if needed.") fmt.Println("You can download it manually later if needed.")
} }
} }
@@ -195,15 +184,15 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===") fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil { if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
fmt.Println("MaxMind GeoLite2 Country database found.") fmt.Println("MaxMind GeoLite2 Country database found.")
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) { if readBool("Would you like to update the MaxMind database to the latest version?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error updating MaxMind database: %v\n", err) fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.") fmt.Println("You can try updating it manually later if needed.")
} }
} }
} else { } else {
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.") fmt.Println("MaxMind GeoLite2 Country database not found.")
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) { if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.") fmt.Println("You can try downloading it manually later if needed.")
@@ -211,15 +200,13 @@ func main() {
// Now you need to update your config file accordingly to enable geoblocking // Now you need to update your config file accordingly to enable geoblocking
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n") fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server // add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server fmt.Println("Add the following line under the 'server' section:")
fmt.Println("Add the following lines under the 'server' section:")
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"") fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
} }
} }
} }
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() { if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===") fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed // check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) { if readBool("Would you like to install CrowdSec?", false) {
@@ -272,7 +259,7 @@ func main() {
} }
config.DoCrowdsecInstall = true config.DoCrowdsecInstall = true
err := installCrowdsec(config, installDir) err := installCrowdsec(config)
if err != nil { if err != nil {
fmt.Printf("Error installing CrowdSec: %v\n", err) fmt.Printf("Error installing CrowdSec: %v\n", err)
return return
@@ -493,17 +480,6 @@ func collectUserInput() Config {
fmt.Println("\n=== Basic Configuration ===") fmt.Println("\n=== Basic Configuration ===")
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
if config.IsEnterprise {
if *redisFlag {
config.IsRedis = true
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
}
}
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
if config.IsPostgreSQL {
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
}
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "") config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
@@ -547,7 +523,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===") fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true) config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
if config.DashboardDomain == "" { if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required") fmt.Println("Error: Dashboard Domain name is required")
@@ -800,42 +776,29 @@ func checkPortsAvailable(port int) error {
} }
func downloadMaxMindDatabase() error { func downloadMaxMindDatabase() error {
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...") fmt.Println("Downloading MaxMind GeoLite2 Country database...")
// Download the GeoLite2 Country databases // Download the GeoLite2 Country database
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz", if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil { "https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err) return fmt.Errorf("failed to download GeoLite2 database: %v", err)
}
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
} }
// Extract the Country database // Extract the database
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil { if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err) return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
}
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
} }
// Find the .mmdb file and move it to the config directory // Find the .mmdb file and move it to the config directory
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil { if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err) return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
}
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
} }
// Clean up the downloaded files // Clean up the downloaded files
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil { if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err) fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
}
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
} }
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!") fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
return nil return nil
} }
+80 -512
View File
File diff suppressed because it is too large Load Diff
+80 -512
View File
File diff suppressed because it is too large Load Diff
-3608
View File
File diff suppressed because it is too large Load Diff
+108 -540
View File
File diff suppressed because it is too large Load Diff
+54 -517
View File
File diff suppressed because it is too large Load Diff
+80 -512
View File
File diff suppressed because it is too large Load Diff
+74 -506
View File
File diff suppressed because it is too large Load Diff
+79 -511
View File
File diff suppressed because it is too large Load Diff
+80 -512
View File
File diff suppressed because it is too large Load Diff
+81 -513
View File
File diff suppressed because it is too large Load Diff
+79 -511
View File
File diff suppressed because it is too large Load Diff
+78 -510
View File
File diff suppressed because it is too large Load Diff
+80 -512
View File
File diff suppressed because it is too large Load Diff
+75 -507
View File
@@ -25,10 +25,6 @@
"subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.", "subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.",
"trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.", "trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.",
"trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.", "trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.",
"billingTrialBannerTitle": "Бесплатная версия активна",
"billingTrialBannerDescription": "Вы в настоящее время находитесь на бесплатном пробном периоде бизнес-уровня. Когда пробный период закончится, ваш аккаунт автоматически вернётся к функциям и лимитам базового уровня. Обновите в любое время, чтобы сохранить доступ к функциям текущего плана.",
"billingTrialBannerUpgrade": "Обновить сейчас",
"billingTrialBadge": "Бесплатная версия",
"trialActive": "Бесплатный пробный период активен", "trialActive": "Бесплатный пробный период активен",
"trialExpired": "Пробный период истек", "trialExpired": "Пробный период истек",
"trialHasEnded": "Ваш пробный период окончен.", "trialHasEnded": "Ваш пробный период окончен.",
@@ -66,15 +62,9 @@
"local": "Локальный", "local": "Локальный",
"edit": "Редактировать", "edit": "Редактировать",
"siteConfirmDelete": "Подтвердить удаление сайта", "siteConfirmDelete": "Подтвердить удаление сайта",
"siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов",
"siteDelete": "Удалить сайт", "siteDelete": "Удалить сайт",
"siteDeleteAndResources": "Удалить сайт и ресурсы",
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.", "siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
"siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.",
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
"siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?",
"sitesTableDeleteSite": "Удалить сайт",
"sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы",
"siteManageSites": "Управление сайтами", "siteManageSites": "Управление сайтами",
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
"sitesBannerTitle": "Подключить любую сеть", "sitesBannerTitle": "Подключить любую сеть",
@@ -103,12 +93,8 @@
"siteConfirmCopy": "Я скопировал(а) конфигурацию", "siteConfirmCopy": "Я скопировал(а) конфигурацию",
"searchSitesProgress": "Поиск сайтов...", "searchSitesProgress": "Поиск сайтов...",
"siteAdd": "Добавить сайт", "siteAdd": "Добавить сайт",
"sitesTableViewPublicResources": "Просмотр публичных ресурсов",
"sitesTableViewPrivateResources": "Просмотр частных ресурсов",
"siteInstallNewt": "Установить Newt", "siteInstallNewt": "Установить Newt",
"siteInstallNewtDescription": "Запустите Newt в вашей системе", "siteInstallNewtDescription": "Запустите Newt в вашей системе",
"siteInstallKubernetesDocsDescription": "Для получения дополнительной информации об установке Kubernetes, см. <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
"siteInstallAdvantechDocsDescription": "Для инструкций по установке модема Advantech, см. <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
"WgConfiguration": "Конфигурация WireGuard", "WgConfiguration": "Конфигурация WireGuard",
"WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети", "WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети",
"operatingSystem": "Операционная система", "operatingSystem": "Операционная система",
@@ -124,21 +110,6 @@
"siteUpdatedDescription": "Сайт был успешно обновлён.", "siteUpdatedDescription": "Сайт был успешно обновлён.",
"siteGeneralDescription": "Настройте общие параметры для этого сайта", "siteGeneralDescription": "Настройте общие параметры для этого сайта",
"siteSettingDescription": "Настройка параметров на сайте", "siteSettingDescription": "Настройка параметров на сайте",
"siteResourcesTab": "Ресурсы",
"siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.",
"siteResourcesSectionPublic": "Публичные ресурсы",
"siteResourcesSectionPrivate": "Частные ресурсы",
"siteResourcesSectionPublicDescription": "Ресурсы, доступные извне через домены или порты.",
"siteResourcesSectionPrivateDescription": "Ресурсы доступны на вашем частном сетевом ресурсе через сайт.",
"siteResourcesViewAllPublic": "Просмотреть все ресурсы",
"siteResourcesViewAllPrivate": "Просмотреть все ресурсы",
"siteResourcesDialogDescription": "Обзор публичных и частных ресурсов, связанных с этим сайтом.",
"siteResourcesShowMore": "Показать еще",
"siteResourcesPermissionDenied": "У вас нет разрешения на просмотр этих ресурсов.",
"siteResourcesEmptyPublic": "Ни один публичный ресурс еще не нацелен на этот сайт.",
"siteResourcesEmptyPrivate": "С этим сайтом еще не связано ни одного частного ресурса.",
"siteResourcesHowToAccess": "Как получить доступ",
"siteResourcesTargetsOnSite": "Цели на этом сайте",
"siteSetting": "Настройки {siteName}", "siteSetting": "Настройки {siteName}",
"siteNewtTunnel": "Новый сайт (рекомендуется)", "siteNewtTunnel": "Новый сайт (рекомендуется)",
"siteNewtTunnelDescription": "Самый простой способ создать точку входа в любую сеть. Дополнительная настройка не требуется.", "siteNewtTunnelDescription": "Самый простой способ создать точку входа в любую сеть. Дополнительная настройка не требуется.",
@@ -164,10 +135,6 @@
"shareErrorDeleteMessage": "Произошла ошибка при удалении ссылки", "shareErrorDeleteMessage": "Произошла ошибка при удалении ссылки",
"shareDeleted": "Ссылка удалена", "shareDeleted": "Ссылка удалена",
"shareDeletedDescription": "Ссылка была успешно удалена", "shareDeletedDescription": "Ссылка была успешно удалена",
"shareDelete": "Удалить общую ссылку",
"shareDeleteConfirm": "Подтвердить удаление общей ссылки",
"shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?",
"shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.",
"shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.", "shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.",
"accessToken": "Токен доступа", "accessToken": "Токен доступа",
"usageExamples": "Примеры использования", "usageExamples": "Примеры использования",
@@ -184,8 +151,6 @@
"shareErrorCreateDescription": "Произошла ошибка при создании общей ссылки", "shareErrorCreateDescription": "Произошла ошибка при создании общей ссылки",
"shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу", "shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу",
"shareTitleOptional": "Заголовок (необязательно)", "shareTitleOptional": "Заголовок (необязательно)",
"sharePathOptional": "Путь (необязательно)",
"sharePathDescription": "Ссылка перенаправит пользователей на этот путь после аутентификации.",
"expireIn": "Срок действия", "expireIn": "Срок действия",
"neverExpire": "Бессрочный доступ", "neverExpire": "Бессрочный доступ",
"shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.", "shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.",
@@ -209,8 +174,8 @@
"shareErrorSelectResource": "Пожалуйста, выберите ресурс", "shareErrorSelectResource": "Пожалуйста, выберите ресурс",
"proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceTitle": "Управление публичными ресурсами",
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
"publicResourcesBannerTitle": "Веб-доступ к публичным ресурсам", "proxyResourcesBannerTitle": "Общедоступный доступ через веб",
"publicResourcesBannerDescription": "Публичные ресурсы это HTTPS-прокси, доступные для любого пользователя Интернета через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать в себя политики доступа, учитывающие идентичность и контекст.", "proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
"clientResourceTitle": "Управление приватными ресурсами", "clientResourceTitle": "Управление приватными ресурсами",
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
@@ -218,37 +183,11 @@
"resourcesSearch": "Поиск ресурсов...", "resourcesSearch": "Поиск ресурсов...",
"resourceAdd": "Добавить ресурс", "resourceAdd": "Добавить ресурс",
"resourceErrorDelte": "Ошибка при удалении ресурса", "resourceErrorDelte": "Ошибка при удалении ресурса",
"resourcePoliciesBannerTitle": "Повторное использование правил аутентификации и доступа",
"resourcePoliciesBannerDescription": "Политики общих ресурсов позволяют один раз определить методы аутентификации и правила доступа, а затем прикреплять их к нескольким публичным ресурсам. Когда вы обновляете политику, каждое связанное с ней наследует изменение автоматически.",
"resourcePoliciesBannerButtonText": "Узнать больше",
"resourcePoliciesTitle": "Управление политиками публичных ресурсов",
"resourcePoliciesAttachedResourcesColumnTitle": "Ресурсы",
"resourcePoliciesAttachedResources": "{count} ресурс(ов)",
"resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурсов}}",
"resourcePoliciesAttachedResourcesEmpty": "нет ресурсов",
"resourcePoliciesDescription": "Создание и управление политиками аутентификации для контроля доступа к вашим публичным ресурсам",
"resourcePoliciesSearch": "Поиск политик...",
"resourcePoliciesAdd": "Добавить политику",
"resourcePoliciesDefaultBadgeText": "Политика по умолчанию",
"resourcePoliciesCreate": "Создать политику публичного ресурса",
"resourcePoliciesCreateDescription": "Следуйте шагам ниже, чтобы создать новую политику",
"resourcePolicyName": "Имя политики",
"resourcePolicyNameDescription": "Дайте этой политике имя для идентификации ее в ваших ресурсах",
"resourcePolicyNamePlaceholder": "например, Политика внутреннего доступа",
"resourcePoliciesSeeAll": "Просмотреть все политики",
"resourcePolicyAuthMethodAdd": "Добавить метод аутентификации",
"resourcePolicyOtpEmailAdd": "Добавить OTP на email",
"resourcePolicyRulesAdd": "Добавить правила",
"resourcePolicyAuthMethodsDescription": "Разрешить доступ к ресурсам через дополнительные методы аутентификации",
"resourcePolicyUsersRolesDescription": "Настройте, какие пользователи и роли могут посещать связанные ресурсы",
"rulesResourcePolicyDescription": "Настройте правила для управления доступом к ресурсам, связанным с этой политикой",
"authentication": "Аутентификация", "authentication": "Аутентификация",
"protected": "Защищён", "protected": "Защищён",
"notProtected": "Не защищён", "notProtected": "Не защищён",
"resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.", "resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.",
"resourceQuestionRemove": "Вы уверены, что хотите удалить ресурс из организации?", "resourceQuestionRemove": "Вы уверены, что хотите удалить ресурс из организации?",
"resourcePolicyMessageRemove": "После удаления политика ресурса больше не будет доступна. Все ресурсы, связанные с ресурсом, будут отключены и останутся без аутентификации.",
"resourcePolicyQuestionRemove": "Вы уверены, что хотите удалить политику ресурса из организации?",
"resourceHTTP": "HTTPS-ресурс", "resourceHTTP": "HTTPS-ресурс",
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.", "resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
"resourceRaw": "Сырой TCP/UDP-ресурс", "resourceRaw": "Сырой TCP/UDP-ресурс",
@@ -256,9 +195,8 @@
"resourceRawDescriptionCloud": "Прокси запросы через необработанный TCP/UDP с использованием номера порта. Требуется подключение сайтов к удаленному узлу.", "resourceRawDescriptionCloud": "Прокси запросы через необработанный TCP/UDP с использованием номера порта. Требуется подключение сайтов к удаленному узлу.",
"resourceCreate": "Создание ресурса", "resourceCreate": "Создание ресурса",
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса", "resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
"resourceCreateGeneralDescription": "Настройте основные параметры ресурса, включая его имя и тип",
"resourceSeeAll": "Посмотреть все ресурсы", "resourceSeeAll": "Посмотреть все ресурсы",
"resourceCreateGeneral": "Общие", "resourceInfo": "Информация о ресурсе",
"resourceNameDescription": "Отображаемое имя ресурса.", "resourceNameDescription": "Отображаемое имя ресурса.",
"siteSelect": "Выберите сайт", "siteSelect": "Выберите сайт",
"siteSearch": "Поиск сайта", "siteSearch": "Поиск сайта",
@@ -268,15 +206,12 @@
"noCountryFound": "Страна не найдена.", "noCountryFound": "Страна не найдена.",
"siteSelectionDescription": "Этот сайт предоставит подключение к цели.", "siteSelectionDescription": "Этот сайт предоставит подключение к цели.",
"resourceType": "Тип ресурса", "resourceType": "Тип ресурса",
"resourceTypeDescription": "Это контролирует протокол ресурса и то, как он будет отображаться в браузере. Это нельзя изменить позже.", "resourceTypeDescription": "Определить как получить доступ к ресурсу",
"resourceDomainDescription": "Ресурс будет предоставлен по этому полностью определенному доменному имени.",
"resourceHTTPSSettings": "Настройки HTTPS", "resourceHTTPSSettings": "Настройки HTTPS",
"resourceHTTPSSettingsDescription": "Настройка доступа к ресурсу по HTTPS", "resourceHTTPSSettingsDescription": "Настройка доступа к ресурсу по HTTPS",
"resourcePortDescription": "Внешний порт на экземпляре или узле Pangolin, где ресурс будет доступен.",
"domainType": "Тип домена", "domainType": "Тип домена",
"subdomain": "Поддомен", "subdomain": "Поддомен",
"baseDomain": "Базовый домен", "baseDomain": "Базовый домен",
"configure": "Настроить",
"subdomnainDescription": "Поддомен, в котором ресурс будет доступен.", "subdomnainDescription": "Поддомен, в котором ресурс будет доступен.",
"resourceRawSettings": "Настройки TCP/UDP", "resourceRawSettings": "Настройки TCP/UDP",
"resourceRawSettingsDescription": "Настройка доступа к ресурсу по TCP/UDP", "resourceRawSettingsDescription": "Настройка доступа к ресурсу по TCP/UDP",
@@ -287,35 +222,14 @@
"back": "Назад", "back": "Назад",
"cancel": "Отмена", "cancel": "Отмена",
"resourceConfig": "Фрагменты конфигурации", "resourceConfig": "Фрагменты конфигурации",
"resourceConfigDescription": "Скопируйте и вставьте эти фрагменты конфигурации для настройки ресурса TCP/UDP.", "resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса",
"resourceAddEntrypoints": "Traefik: Добавить точки входа", "resourceAddEntrypoints": "Traefik: Добавить точки входа",
"resourceExposePorts": "Gerbil: Открыть порты в Docker Compose", "resourceExposePorts": "Gerbil: Открыть порты в Docker Compose",
"resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы", "resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы",
"resourceBack": "Назад к ресурсам", "resourceBack": "Назад к ресурсам",
"resourceGoTo": "Перейти к ресурсу", "resourceGoTo": "Перейти к ресурсу",
"resourcePolicyDelete": "Удалить политику ресурса",
"resourcePolicyDeleteConfirm": "Подтвердите удаление политики ресурса",
"resourceDelete": "Удалить ресурс", "resourceDelete": "Удалить ресурс",
"resourceDeleteConfirm": "Подтвердить удаление", "resourceDeleteConfirm": "Подтвердить удаление",
"labelDelete": "Удалить метку",
"labelAdd": "Добавить метку",
"labelCreateSuccessMessage": "Метка успешно создана",
"labelDuplicateError": "Повторяющаяся метка",
"labelDuplicateErrorDescription": "Метка с таким именем уже существует.",
"labelEditSuccessMessage": "Метка успешно изменена",
"labelNameField": "Название метки",
"labelColorField": "Цвет метки",
"labelPlaceholder": "Напр.: homelab",
"labelCreate": "Создать метку",
"createLabelDialogTitle": "Создать метку",
"createLabelDialogDescription": "Создайте новую метку, которая может быть прикреплена к этой организации",
"labelEdit": "Редактировать метку",
"editLabelDialogTitle": "Обновить метку",
"editLabelDialogDescription": "Измените новую метку, которую можно прикрепить к этой организации",
"labelDeleteConfirm": "Подтвердите удаление метки",
"labelErrorDelete": "Не удалось удалить метку",
"labelMessageRemove": "Это действие необратимо. Все сайты, ресурсы и клиенты, помеченные этой меткой, будут разметены.",
"labelQuestionRemove": "Вы уверены, что хотите удалить метку из организации?",
"visibility": "Видимость", "visibility": "Видимость",
"enabled": "Включено", "enabled": "Включено",
"disabled": "Отключено", "disabled": "Отключено",
@@ -326,8 +240,6 @@
"rules": "Правила", "rules": "Правила",
"resourceSettingDescription": "Настройка параметров ресурса", "resourceSettingDescription": "Настройка параметров ресурса",
"resourceSetting": "Настройки {resourceName}", "resourceSetting": "Настройки {resourceName}",
"resourcePolicySettingDescription": "Настройте параметры этой политики публичного ресурса",
"resourcePolicySetting": "Настройки {policyName}",
"alwaysAllow": "Авторизация байпасса", "alwaysAllow": "Авторизация байпасса",
"alwaysDeny": "Блокировать доступ", "alwaysDeny": "Блокировать доступ",
"passToAuth": "Переход к аутентификации", "passToAuth": "Переход к аутентификации",
@@ -590,12 +502,6 @@
"userMessageOrgRemove": "После удаления этот пользователь больше не будет иметь доступ к организации. Вы всегда можете пригласить его заново, но ему нужно будет снова принять приглашение.", "userMessageOrgRemove": "После удаления этот пользователь больше не будет иметь доступ к организации. Вы всегда можете пригласить его заново, но ему нужно будет снова принять приглашение.",
"userRemoveOrgConfirm": "Подтвердить удаление пользователя", "userRemoveOrgConfirm": "Подтвердить удаление пользователя",
"userRemoveOrg": "Удалить пользователя из организации", "userRemoveOrg": "Удалить пользователя из организации",
"userQuestionOrgRemoveSelf": "Вы уверены, что хотите удалить себя из этой организации?",
"userMessageOrgRemoveSelf": "Вы немедленно потеряете доступ. Администратор сможет снова пригласить вас позже, но вам нужно будет принять новое приглашение.",
"userRemoveOrgConfirmSelf": "Подтвердите удаление себя",
"userRemoveOrgSelf": "Удалите себя из организации",
"userRemoveOrgSelfWarning": "Вы немедленно потеряете доступ к этой организации.",
"userRemoveOrgConfirmPhraseSelf": "Удалить себя из организации",
"users": "Пользователи", "users": "Пользователи",
"accessRoleMember": "Участник", "accessRoleMember": "Участник",
"accessRoleOwner": "Владелец", "accessRoleOwner": "Владелец",
@@ -604,11 +510,6 @@
"emailInvalid": "Неверный адрес Email", "emailInvalid": "Неверный адрес Email",
"inviteValidityDuration": "Пожалуйста, выберите продолжительность", "inviteValidityDuration": "Пожалуйста, выберите продолжительность",
"accessRoleSelectPlease": "Пожалуйста, выберите роль", "accessRoleSelectPlease": "Пожалуйста, выберите роль",
"removeOwnAdminRoleConfirmTitle": "Удалить доступ администратора?",
"removeOwnAdminRoleConfirmDescription": "После сохранения у вас больше не будет прав администратора в этой организации. Другой администратор может восстановить доступ, если это необходимо.",
"removeOwnAdminRoleConfirmButton": "Удалить мой доступ администратора",
"removeOwnAdminRoleConfirmPhrase": "УДАЛИТЬ МОЙ ДОСТУП АДМИНИСТРАТОРА",
"ownerMustRetainAdminRole": "Владелец организации должен сохранить по крайней мере одну роль администратора.",
"usernameRequired": "Имя пользователя обязательно", "usernameRequired": "Имя пользователя обязательно",
"idpSelectPlease": "Пожалуйста, выберите Identity Provider", "idpSelectPlease": "Пожалуйста, выберите Identity Provider",
"idpGenericOidc": "Обычный OAuth2/OIDC provider.", "idpGenericOidc": "Обычный OAuth2/OIDC provider.",
@@ -693,7 +594,7 @@
"createdAt": "Создано в", "createdAt": "Создано в",
"proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.", "proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.",
"proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.", "proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.",
"proxyEnableSSL": "Включить TLS", "proxyEnableSSL": "Включить SSL",
"proxyEnableSSLDescription": "Включить шифрование SSL/TLS для безопасных HTTPS соединений с целями.", "proxyEnableSSLDescription": "Включить шифрование SSL/TLS для безопасных HTTPS соединений с целями.",
"target": "Target", "target": "Target",
"configureTarget": "Настроить адресаты", "configureTarget": "Настроить адресаты",
@@ -734,9 +635,8 @@
"targetSubmit": "Добавить цель", "targetSubmit": "Добавить цель",
"targetNoOne": "Этот ресурс не имеет никаких целей. Добавьте цель для настройки, где отправлять запросы в бэкэнд.", "targetNoOne": "Этот ресурс не имеет никаких целей. Добавьте цель для настройки, где отправлять запросы в бэкэнд.",
"targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.", "targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.",
"targetsSubmit": "Сохранить настройки", "targetsSubmit": "Сохранить цели",
"addTarget": "Добавить цель", "addTarget": "Добавить цель",
"proxyMultiSiteRoundRobinNodeHelp": "Роутинг с балансировкой нагрузки не будет работать между сайтами, не подключенными к одному и тому же узлу, но подмена будет работать.",
"targetErrorInvalidIp": "Неверный IP-адрес", "targetErrorInvalidIp": "Неверный IP-адрес",
"targetErrorInvalidIpDescription": "Пожалуйста, введите действительный IP адрес или имя хоста", "targetErrorInvalidIpDescription": "Пожалуйста, введите действительный IP адрес или имя хоста",
"targetErrorInvalidPort": "Неверный порт", "targetErrorInvalidPort": "Неверный порт",
@@ -768,11 +668,11 @@
"rulesErrorDuplicate": "Дублирующее правило", "rulesErrorDuplicate": "Дублирующее правило",
"rulesErrorDuplicateDescription": "Правило с такими настройками уже существует", "rulesErrorDuplicateDescription": "Правило с такими настройками уже существует",
"rulesErrorInvalidIpAddressRange": "Неверный CIDR", "rulesErrorInvalidIpAddressRange": "Неверный CIDR",
"rulesErrorInvalidIpAddressRangeDescription": "Введите действительный диапазон CIDR (например, 10.0.0.0/8).", "rulesErrorInvalidIpAddressRangeDescription": "Пожалуйста, введите корректное значение CIDR",
"rulesErrorInvalidUrl": "Неверный путь", "rulesErrorInvalidUrl": "Неверный URL путь",
"rulesErrorInvalidUrlDescription": "Введите действительный URL-путь или шаблон (например, /api/*).", "rulesErrorInvalidUrlDescription": "Пожалуйста, введите корректное значение URL пути",
"rulesErrorInvalidIpAddress": "Недействительный IP адрес", "rulesErrorInvalidIpAddress": "Неверный IP",
"rulesErrorInvalidIpAddressDescription": "Введите действительный адрес IPv4 или IPv6.", "rulesErrorInvalidIpAddressDescription": "Пожалуйста, введите корректный IP адрес",
"rulesErrorUpdate": "Не удалось обновить правила", "rulesErrorUpdate": "Не удалось обновить правила",
"rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил", "rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил",
"rulesUpdated": "Включить правила", "rulesUpdated": "Включить правила",
@@ -781,23 +681,14 @@
"rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)", "rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)",
"rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)", "rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)",
"rulesErrorInvalidPriority": "Неверный приоритет", "rulesErrorInvalidPriority": "Неверный приоритет",
"rulesErrorInvalidPriorityDescription": "Введите целое число 1 или больше.", "rulesErrorInvalidPriorityDescription": "Пожалуйста, введите корректный приоритет",
"rulesErrorDuplicatePriority": "Повторяющиеся приоритеты", "rulesErrorDuplicatePriority": "Дублирующие приоритеты",
"rulesErrorDuplicatePriorityDescription": "Каждое правило должно иметь уникальный номер приоритета.", "rulesErrorDuplicatePriorityDescription": "Пожалуйста, введите уникальные приоритеты",
"rulesErrorValidation": "Неверные правила",
"rulesErrorValidationRuleDescription": "Правило {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Выберите действительный тип совпадения (путь, IP, CIDR, страна, регион или ASN).",
"rulesErrorValueRequired": "Введите значение для этого правила.",
"rulesErrorInvalidCountry": "Недействительная страна",
"rulesErrorInvalidCountryDescription": "Выберите правильную страну.",
"rulesErrorInvalidAsn": "Недействительный ASN",
"rulesErrorInvalidAsnDescription": "Введите действительный ASN (например, AS15169).",
"ruleUpdated": "Правила обновлены", "ruleUpdated": "Правила обновлены",
"ruleUpdatedDescription": "Правила успешно обновлены", "ruleUpdatedDescription": "Правила успешно обновлены",
"ruleErrorUpdate": "Операция не удалась", "ruleErrorUpdate": "Операция не удалась",
"ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения", "ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения",
"rulesPriority": "Приоритет", "rulesPriority": "Приоритет",
"rulesReorderDragHandle": "Перетащите, чтобы изменить приоритет правила",
"rulesAction": "Действие", "rulesAction": "Действие",
"rulesMatchType": "Тип совпадения", "rulesMatchType": "Тип совпадения",
"value": "Значение", "value": "Значение",
@@ -816,60 +707,9 @@
"rulesResource": "Конфигурация правил ресурса", "rulesResource": "Конфигурация правил ресурса",
"rulesResourceDescription": "Настройка правил для контроля доступа к ресурсу", "rulesResourceDescription": "Настройка правил для контроля доступа к ресурсу",
"ruleSubmit": "Добавить правило", "ruleSubmit": "Добавить правило",
"rulesNoOne": "Пока нет правил.", "rulesNoOne": "Нет правил. Добавьте правило с помощью формы.",
"rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.", "rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.",
"rulesSubmit": "Сохранить правила", "rulesSubmit": "Сохранить правила",
"policyErrorCreate": "Ошибка создания политики",
"policyErrorCreateDescription": "Произошла ошибка при создании политики",
"policyErrorCreateMessageDescription": "Произошла неожиданная ошибка",
"policyErrorUpdate": "Ошибка обновления политики",
"policyErrorUpdateDescription": "Произошла ошибка при обновлении политики",
"policyErrorUpdateMessageDescription": "Произошла неожиданная ошибка",
"policyCreatedSuccess": "Политика ресурса успешно создана",
"policyUpdatedSuccess": "Политика ресурса успешно обновлена",
"authMethodsSave": "Сохранить настройки",
"policyAuthStackTitle": "Аутентификация",
"policyAuthStackDescription": "Контроль, какие методы аутентификации требуются для доступа к этому ресурсу",
"policyAuthOrLogicTitle": "Несколько методов аутентификации активны",
"policyAuthOrLogicBanner": "Посетители могут аутентифицироваться, используя любой из активных методов ниже. Им не нужно выполнять все.",
"policyAuthMethodActive": "Активно",
"policyAuthMethodOff": "Отключено",
"policyAuthSsoTitle": "Платформа SSO",
"policyAuthSsoDescription": "Требуется войти через поставщика удостоверений вашей организации",
"policyAuthSsoSummary": "{idp} · {users} пользователей, {roles} ролей",
"policyAuthSsoDefaultIdp": "Поставщик по умолчанию",
"policyAuthAddDefaultIdentityProvider": "Добавить поставщика удостоверений по умолчанию",
"policyAuthOtherMethodsTitle": "Другие методы",
"policyAuthOtherMethodsDescription": "Дополнительные методы, которые посетители могут использовать вместо или вместе с платформой SSO",
"policyAuthPasscodeTitle": "Пароль",
"policyAuthPasscodeDescription": "Требуется общий буквенно-цифровой пароль для доступа к ресурсу",
"policyAuthPasscodeSummary": "Пароль установлен",
"policyAuthPincodeTitle": "ПИН-код",
"policyAuthPincodeDescription": "Краткий числовой код, необходимый для доступа к ресурсу",
"policyAuthPincodeSummary": "Установлен 6-значный PIN-код",
"policyAuthEmailTitle": "Белый список email",
"policyAuthEmailDescription": "Разрешить перечисленные email-адреса с одноразовыми паролями",
"policyAuthEmailSummary": "Разрешено адресов: {count}",
"policyAuthEmailOtpCallout": "Включение белого списка email отправляет одноразовый пароль на email посетителя при входе.",
"policyAuthHeaderAuthTitle": "Базовая аутентификация заголовка",
"policyAuthHeaderAuthDescription": "Проверка пользовательского имени и значения HTTP-заголовка для каждого запроса",
"policyAuthHeaderAuthSummary": "Заголовок настроен",
"policyAuthHeaderName": "Имя заголовка",
"policyAuthHeaderValue": "Ожидаемое значение",
"policyAuthSetPasscode": "Установить пароль",
"policyAuthSetPincode": "Установить ПИН-код",
"policyAuthSetEmailWhitelist": "Установить белый список email",
"policyAuthSetHeaderAuth": "Установить базовую аутентификацию заголовка",
"policyAccessRulesTitle": "Правила доступа",
"policyAccessRulesEnableDescription": "При включении правила оцениваются в порядке убывания до тех пор, пока одно из них не оценивается как истинное.",
"policyAccessRulesFirstMatch": "Правила оцениваются сверху вниз. Первое совпадающее правило определяет результат.",
"policyAccessRulesHowItWorks": "Правила сопоставляют запросы по пути, IP-адресу, местоположению или другим критериям. Каждое правило применяет действие: обойти аутентификацию, заблокировать доступ или передать для аутентификации. Если правило не подписано, трафик продолжается для аутентификации.",
"policyAccessRulesFallthroughOff": "Когда правила отключены, весь трафик проходит для аутентификации.",
"policyAccessRulesFallthroughOn": "Когда правило не совпадает, трафик проходит для аутентификации.",
"rulesPlaceholderCidr": "10.0.0.0/8",
"rulesPlaceholderPath": "/admin/*",
"rulesPlaceholderGeo": "RU, KP",
"rulesSave": "Сохранить правила",
"resourceErrorCreate": "Ошибка при создании ресурса", "resourceErrorCreate": "Ошибка при создании ресурса",
"resourceErrorCreateDescription": "Произошла ошибка при создании ресурса", "resourceErrorCreateDescription": "Произошла ошибка при создании ресурса",
"resourceErrorCreateMessage": "Ошибка создания ресурса:", "resourceErrorCreateMessage": "Ошибка создания ресурса:",
@@ -906,7 +746,6 @@
"newtEndpoint": "Endpoint", "newtEndpoint": "Endpoint",
"newtId": "ID", "newtId": "ID",
"newtSecretKey": "Секретный ключ", "newtSecretKey": "Секретный ключ",
"newtVersion": "Версия",
"architecture": "Архитектура", "architecture": "Архитектура",
"sites": "Сайты", "sites": "Сайты",
"siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.", "siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.",
@@ -933,17 +772,6 @@
"pincodeAdd": "Добавить PIN-код", "pincodeAdd": "Добавить PIN-код",
"pincodeRemove": "Удалить PIN-код", "pincodeRemove": "Удалить PIN-код",
"resourceAuthMethods": "Методы аутентификации", "resourceAuthMethods": "Методы аутентификации",
"resourcePolicyAuthMethodsEmpty": "Нет метода аутентификации",
"resourcePolicyOtpEmpty": "Нет одноразового пароля",
"resourcePolicyReadOnly": "Эта политика только для чтения",
"resourcePolicyReadOnlyDescription": "Эта политика ресурса разделяется между несколькими ресурсами, вы не можете улучшить ее на этой странице.",
"editSharedPolicy": "Редактировать общую политику",
"resourcePolicyTypeSave": "Сохранить тип ресурса",
"resourcePolicySelect": "Выберите политику ресурса",
"resourcePolicySelectError": "Выберите политику ресурса",
"resourcePolicyNotFound": "Политика не найдена",
"resourcePolicySearch": "Поиск политик",
"resourcePolicyRulesEmpty": "Нет правил аутентификации",
"resourceAuthMethodsDescriptions": "Разрешить доступ к ресурсу через дополнительные методы аутентификации", "resourceAuthMethodsDescriptions": "Разрешить доступ к ресурсу через дополнительные методы аутентификации",
"resourceAuthSettingsSave": "Успешно сохранено", "resourceAuthSettingsSave": "Успешно сохранено",
"resourceAuthSettingsSaveDescription": "Настройки аутентификации сохранены", "resourceAuthSettingsSaveDescription": "Настройки аутентификации сохранены",
@@ -979,20 +807,6 @@
"resourcePincodeSetupTitle": "Установить PIN-код", "resourcePincodeSetupTitle": "Установить PIN-код",
"resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса", "resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса",
"resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.", "resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.",
"resourcePolicySelectTitle": "Политика доступа к ресурсам",
"resourcePolicySelectDescription": "Выберите тип политики ресурса для аутентификации",
"resourcePolicyTypeLabel": "Тип политики",
"resourcePolicyLabel": "Политика ресурса",
"resourcePolicyInline": "Политика ресурса на месте",
"resourcePolicyInlineDescription": "Политика доступа ограничена только этим ресурсом",
"resourcePolicyShared": "Общая политика ресурса",
"resourcePolicySharedDescription": "Этот ресурс использует общую политику.",
"sharedPolicy": "Общая политика",
"sharedPolicyNoneDescription": "У этого ресурса есть своя политика.",
"resourceSharedPolicyOwnDescription": "У этого ресурса есть собственные средства управления аутентификацией и правилами доступа.",
"resourceSharedPolicyInheritedDescription": "Этот ресурс наследует от <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyAuthenticationNotice": "Этот ресурс использует общую политику. Некоторые настройки аутентификации можно изменить в этом ресурсе, чтобы добавить их в политику. Чтобы изменить основную политику, отредактируйте <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyRulesNotice": "Этот ресурс использует общую политику. Некоторые правила доступа могут быть отредактированы для этого ресурса. Чтобы изменить основную политику, вы должны отредактировать <policyLink>{policyName}</policyLink>.",
"resourceUsersRoles": "Контроль доступа", "resourceUsersRoles": "Контроль доступа",
"resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу", "resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу",
"resourceUsersRolesSubmit": "Сохранить контроль доступа", "resourceUsersRolesSubmit": "Сохранить контроль доступа",
@@ -1017,14 +831,7 @@
"resourceVisibilityTitle": "Видимость", "resourceVisibilityTitle": "Видимость",
"resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса", "resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса",
"resourceGeneral": "Общие настройки", "resourceGeneral": "Общие настройки",
"resourceGeneralDescription": "Настройте имя, адрес и политику доступа для этого ресурса.", "resourceGeneralDescription": "Настройте общие параметры этого ресурса",
"resourceGeneralDetailsSubsection": "Детали ресурса",
"resourceGeneralDetailsSubsectionDescription": "Установите отображаемое имя, идентификатор и публично доступный домен для этого ресурса.",
"resourceGeneralDetailsSubsectionPortDescription": "Установите отображаемое имя, идентификатор и публичный порт для этого ресурса.",
"resourceGeneralPublicAddressSubsection": "Публичный адрес",
"resourceGeneralPublicAddressSubsectionDescription": "Настройте, как пользователи будут получать доступ к этому ресурсу.",
"resourceGeneralAuthenticationAccessSubsection": "Аутентификация и доступ",
"resourceGeneralAuthenticationAccessSubsectionDescription": "Выберите, будет ли этот ресурс использовать собственную политику или наследовать от общей политики.",
"resourceEnable": "Ресурс активен", "resourceEnable": "Ресурс активен",
"resourceTransfer": "Перенести ресурс", "resourceTransfer": "Перенести ресурс",
"resourceTransferDescription": "Перенесите этот ресурс на другой сайт", "resourceTransferDescription": "Перенесите этот ресурс на другой сайт",
@@ -1295,21 +1102,6 @@
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
"idpErrorNotFound": "IdP не найден", "idpErrorNotFound": "IdP не найден",
"inviteInvalid": "Недействительное приглашение", "inviteInvalid": "Недействительное приглашение",
"labels": "Метки",
"orgLabelsDescription": "Управление метками в этой организации.",
"addLabels": "Добавить метки",
"siteLabelsTab": "Метки",
"siteLabelsDescription": "Управляйте метками, связанными с этим сайтом.",
"labelsNotFound": "Метки не найдены.",
"labelsEmptyCreateHint": "Начните печатать выше, чтобы создать метку.",
"labelSearch": "Поиск меток",
"labelSearchOrCreate": "Найти или создать метку",
"accessLabelFilterCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
"labelOverflowCount": "+{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
"accessLabelFilterClear": "Очистить фильтры меток",
"accessFilterClear": "Очистить фильтры",
"selectColor": "Выберите цвет",
"createNewLabel": "Создать новую метку организации \"{label}\"",
"inviteInvalidDescription": "Ссылка на приглашение недействительна.", "inviteInvalidDescription": "Ссылка на приглашение недействительна.",
"inviteErrorWrongUser": "Приглашение не для этого пользователя", "inviteErrorWrongUser": "Приглашение не для этого пользователя",
"inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.", "inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.",
@@ -1544,8 +1336,6 @@
"sidebarResources": "Ресурсы", "sidebarResources": "Ресурсы",
"sidebarProxyResources": "Публичный", "sidebarProxyResources": "Публичный",
"sidebarClientResources": "Приватный", "sidebarClientResources": "Приватный",
"sidebarPolicies": "Общие политики",
"sidebarResourcePolicies": "Публичные ресурсы",
"sidebarAccessControl": "Контроль доступа", "sidebarAccessControl": "Контроль доступа",
"sidebarLogsAndAnalytics": "Журналы и аналитика", "sidebarLogsAndAnalytics": "Журналы и аналитика",
"sidebarTeam": "Команда", "sidebarTeam": "Команда",
@@ -1553,7 +1343,7 @@
"sidebarAdmin": "Админ", "sidebarAdmin": "Админ",
"sidebarInvitations": "Приглашения", "sidebarInvitations": "Приглашения",
"sidebarRoles": "Роли", "sidebarRoles": "Роли",
"sidebarShareableLinks": "Общие ссылки", "sidebarShareableLinks": "Ссылки",
"sidebarApiKeys": "API ключи", "sidebarApiKeys": "API ключи",
"sidebarProvisioning": "Подготовка", "sidebarProvisioning": "Подготовка",
"sidebarSettings": "Настройки", "sidebarSettings": "Настройки",
@@ -1625,7 +1415,6 @@
"alertingTriggerHcToggle": "Статус проверки здоровья изменяется", "alertingTriggerHcToggle": "Статус проверки здоровья изменяется",
"alertingTriggerResourceHealthy": "Ресурс в нормальном состоянии", "alertingTriggerResourceHealthy": "Ресурс в нормальном состоянии",
"alertingTriggerResourceUnhealthy": "Ресурс в ненормальном состоянии", "alertingTriggerResourceUnhealthy": "Ресурс в ненормальном состоянии",
"alertingTriggerResourceDegraded": "Ресурс ухудшен",
"alertingSearchHealthChecks": "Поиск проверок здоровья…", "alertingSearchHealthChecks": "Поиск проверок здоровья…",
"alertingHealthChecksEmpty": "Нет доступных проверок здоровья.", "alertingHealthChecksEmpty": "Нет доступных проверок здоровья.",
"alertingTriggerResourceToggle": "Статус ресурса изменяется", "alertingTriggerResourceToggle": "Статус ресурса изменяется",
@@ -1729,8 +1518,7 @@
"standaloneHcFilterSiteIdFallback": "Сайт {id}", "standaloneHcFilterSiteIdFallback": "Сайт {id}",
"standaloneHcFilterResourceIdFallback": "Ресурс {id}", "standaloneHcFilterResourceIdFallback": "Ресурс {id}",
"blueprints": "Чертежи", "blueprints": "Чертежи",
"blueprintsLog": "Журнал чертежей", "blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски",
"blueprintsDescription": "Просмотреть предыдущие приложения с чертежами и их результаты или применить новый чертеж",
"blueprintAdd": "Добавить чертёж", "blueprintAdd": "Добавить чертёж",
"blueprintGoBack": "Посмотреть все чертежи", "blueprintGoBack": "Посмотреть все чертежи",
"blueprintCreate": "Создать чертёж", "blueprintCreate": "Создать чертёж",
@@ -1748,17 +1536,7 @@
"contents": "Содержание", "contents": "Содержание",
"parsedContents": "Переработанное содержимое (только для чтения)", "parsedContents": "Переработанное содержимое (только для чтения)",
"enableDockerSocket": "Включить чертёж Docker", "enableDockerSocket": "Включить чертёж Docker",
"enableDockerSocketDescription": "Включить сбор меток Docker Socket для чертежей. Путь сокета должен быть предоставлен подключателю сайта. Прочтите о том, как это работает, в <docsLink>документации</docsLink>.", "enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
"newtAutoUpdate": "Включить автообновление сайта",
"newtAutoUpdateDescription": "При включении разъемы сайта автоматически загрузят последнюю версию и перезапустятся. Это можно переопределить на уровне каждого сайта.",
"siteAutoUpdate": "Автообновление сайта",
"siteAutoUpdateLabel": "Включить автообновление",
"siteAutoUpdateDescription": "При включении разъем этого сайта автоматически скачает последнюю версию и перезапустится.",
"siteAutoUpdateOrgDefault": "Значение по умолчанию для организации: {state}",
"siteAutoUpdateOverriding": "Переопределение настройки организации",
"siteAutoUpdateResetToOrg": "Сброс до значения по умолчанию для организации",
"siteAutoUpdateEnabled": "включено",
"siteAutoUpdateDisabled": "отключено",
"viewDockerContainers": "Просмотр контейнеров Docker", "viewDockerContainers": "Просмотр контейнеров Docker",
"containersIn": "Контейнеры в {siteName}", "containersIn": "Контейнеры в {siteName}",
"selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.", "selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.",
@@ -1800,10 +1578,8 @@
"initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.", "initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.",
"createAdminAccount": "Создать учётную запись администратора", "createAdminAccount": "Создать учётную запись администратора",
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
"certificateStatus": "Сертификат", "certificateStatus": "Статус сертификата",
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
"loading": "Загрузка", "loading": "Загрузка",
"loadingEllipsis": "Загрузка...",
"loadingAnalytics": "Загрузка аналитики", "loadingAnalytics": "Загрузка аналитики",
"restart": "Перезагрузка", "restart": "Перезагрузка",
"domains": "Домены", "domains": "Домены",
@@ -1851,9 +1627,9 @@
"accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!", "accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!",
"documentation": "Документация", "documentation": "Документация",
"saveAllSettings": "Сохранить все настройки", "saveAllSettings": "Сохранить все настройки",
"saveResourceTargets": "Сохранить настройки", "saveResourceTargets": "Сохранить цели",
"saveResourceHttp": "Сохранить настройки", "saveResourceHttp": "Сохранить настройки прокси",
"saveProxyProtocol": "Сохранить настройки", "saveProxyProtocol": "Сохранить настройки прокси-протокола",
"settingsUpdated": "Настройки обновлены", "settingsUpdated": "Настройки обновлены",
"settingsUpdatedDescription": "Настройки успешно обновлены", "settingsUpdatedDescription": "Настройки успешно обновлены",
"settingsErrorUpdate": "Не удалось обновить настройки", "settingsErrorUpdate": "Не удалось обновить настройки",
@@ -1871,7 +1647,6 @@
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску", "pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
"newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailable": "Доступно обновление",
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"domainPickerEnterDomain": "Домен", "domainPickerEnterDomain": "Домен",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
@@ -2030,7 +1805,6 @@
"billingManageLicenseSubscription": "Управление подпиской на платные лицензионные ключи собственного хостинга", "billingManageLicenseSubscription": "Управление подпиской на платные лицензионные ключи собственного хостинга",
"billingCurrentKeys": "Текущие ключи", "billingCurrentKeys": "Текущие ключи",
"billingModifyCurrentPlan": "Изменить текущий план", "billingModifyCurrentPlan": "Изменить текущий план",
"billingManageLicenseSubscriptionDescription": "Управление вашей подпиской на платные ключи лицензии для самостоятельной установки и загрузка счетов.",
"billingConfirmUpgrade": "Подтвердить обновление", "billingConfirmUpgrade": "Подтвердить обновление",
"billingConfirmDowngrade": "Подтверждение понижения", "billingConfirmDowngrade": "Подтверждение понижения",
"billingConfirmUpgradeDescription": "Вы собираетесь обновить тарифный план. Проверьте новые лимиты и цены ниже.", "billingConfirmUpgradeDescription": "Вы собираетесь обновить тарифный план. Проверьте новые лимиты и цены ниже.",
@@ -2110,13 +1884,12 @@
"healthCheckUnknown": "Неизвестно", "healthCheckUnknown": "Неизвестно",
"healthCheck": "Проверка здоровья", "healthCheck": "Проверка здоровья",
"configureHealthCheck": "Настроить проверку здоровья", "configureHealthCheck": "Настроить проверку здоровья",
"configureHealthCheckDescription": "Настройте мониторинг вашего ресурса, чтобы обеспечить его постоянную доступность", "configureHealthCheckDescription": "Настройте мониторинг состояния для {target}",
"enableHealthChecks": "Включить проверки здоровья", "enableHealthChecks": "Включить проверки здоровья",
"healthCheckDisabledStateDescription": "Когда отключен, сайт не будет выполнять проверки состояния и состояние будет считаться неизвестным.",
"enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.", "enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.",
"healthScheme": "Метод", "healthScheme": "Метод",
"healthSelectScheme": "Выберите метод", "healthSelectScheme": "Выберите метод",
"healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535", "healthCheckPortInvalid": "Порт проверки здоровья должен быть от 1 до 65535",
"healthCheckPath": "Путь", "healthCheckPath": "Путь",
"healthHostname": "IP / хост", "healthHostname": "IP / хост",
"healthPort": "Порт", "healthPort": "Порт",
@@ -2128,42 +1901,7 @@
"timeIsInSeconds": "Время указано в секундах", "timeIsInSeconds": "Время указано в секундах",
"requireDeviceApproval": "Требовать подтверждения устройства", "requireDeviceApproval": "Требовать подтверждения устройства",
"requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.", "requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.",
"sshSettings": "Настройки SSH", "sshAccess": "SSH доступ",
"sshAccess": "Доступ по SSH",
"rdpSettings": "Настройки RDP",
"vncSettings": "Настройки VNC",
"sshServer": "SSH сервер",
"rdpServer": "RDP сервер",
"vncServer": "VNC сервер",
"sshServerDescription": "Настройка метода аутентификации, местоположения демона и пункта назначения сервера",
"rdpServerDescription": "Настройте пункт назначения и порт RDP-сервера",
"vncServerDescription": "Настройте пункт назначения и порт VNC-сервера",
"sshServerMode": "Режим",
"sshServerModeStandard": "Стандартный SSH-сервер",
"sshServerModePangolin": "SSH Pangolin",
"sshServerModeStandardDescription": "Маршрутизация команд по сети к SSH-серверу, такому как OpenSSH.",
"sshServerModeNative": "Родной SSH-сервер",
"sshServerModeNativeDescription": "Выполняет команды напрямую на хосте через сайт-коннектор. Настройка сети не требуется.",
"sshAuthenticationMethod": "Метод аутентификации",
"sshAuthMethodManual": "Ручная аутентификация",
"sshAuthMethodManualDescription": "Требуется наличие существующих учетных данных хоста. Обходит автоматическое предоставление.",
"sshAuthMethodAutomated": "Автоматизированное предоставление",
"sshAuthMethodAutomatedDescription": "Автоматически создает пользователей, группы и разрешения sudo на хосте.",
"sshAuthDaemonLocation": "Местоположение демона аутентификации",
"sshDaemonLocationSiteDescription": "Выполняется локально на машине, размещающей сайт-коннектор.",
"sshDaemonLocationRemote": "На удаленном хосте",
"sshDaemonLocationRemoteDescription": "Выполняется на отдельной целевой машине в той же сети.",
"sshDaemonDisclaimer": "Убедитесь, что целевой хост правильно настроен для запуска демона аутентификации перед завершением этой настройки, иначе предоставление не удастся.",
"sshDaemonPort": "Порт демона",
"sshServerDestination": "Пункт назначения сервера",
"sshServerDestinationDescription": "Настройте адрес сервера SSH",
"destination": "Пункт назначения",
"destinationRequired": "Требуется указание пункта назначения.",
"domainRequired": "Требуется домен.",
"proxyPortRequired": "Требуется порт.",
"invalidPathConfiguration": "Недействительная конфигурация пути.",
"invalidRewritePathConfiguration": "Недействительная конфигурация пути переписывания.",
"bgTargetMultiSiteDisclaimer": "Выбор нескольких сайтов включает в себя устойчивую маршрутизацию и автоматический отказ для обеспечения высокой доступности.",
"roleAllowSsh": "Разрешить SSH", "roleAllowSsh": "Разрешить SSH",
"roleAllowSshAllow": "Разрешить", "roleAllowSshAllow": "Разрешить",
"roleAllowSshDisallow": "Запретить", "roleAllowSshDisallow": "Запретить",
@@ -2177,25 +1915,10 @@
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.", "sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
"sshSudo": "Разрешить sudo", "sshSudo": "Разрешить sudo",
"sshSudoCommands": "Sudo Команды", "sshSudoCommands": "Sudo Команды",
"sshSudoCommandsDescription": "Список команд, которые пользователь может запускать с sudo, разделенный запятыми, пробелами или новыми строками. Должны использоваться абсолютные пути.", "sshSudoCommandsDescription": "Список команд, разделенных запятыми, которые пользователю разрешено запускать с помощью sudo.",
"sshCreateHomeDir": "Создать домашний каталог", "sshCreateHomeDir": "Создать домашний каталог",
"sshUnixGroups": "Unix группы", "sshUnixGroups": "Unix группы",
"sshUnixGroupsDescription": "Группы Unix, к которым пользователь добавляется на целевом хосте, разделяются запятыми, пробелами или новыми строками.", "sshUnixGroupsDescription": "Группы Unix через запятую, чтобы добавить пользователя на целевой хост.",
"roleTextFieldPlaceholder": "Введите значения или перетащите файл .txt или .csv",
"roleTextImportTitle": "Импорт из файла",
"roleTextImportDescription": "Импортирую {fileName} в {fieldLabel}.",
"roleTextImportSkipHeader": "Пропустить первую строку (заголовок)",
"roleTextImportOverride": "Заменить существующее",
"roleTextImportAppend": "Добавить к существующему",
"roleTextImportMode": "Режим импорта",
"roleTextImportPreview": "Предпросмотр",
"roleTextImportItemCount": "{count, plural, =0 {Нет элементов для импорта} one {# элемент для импорта} few {# элемента для импорта} many {# элементов для импорта} other {# элементов для импорта}}",
"roleTextImportTotalCount": "{existing} существующих + {imported} импортированных = {total} всего",
"roleTextImportConfirm": "Импортировать",
"roleTextImportInvalidFile": "Неподдерживаемый тип файла",
"roleTextImportInvalidFileDescription": "Поддерживаются только файлы .txt и .csv.",
"roleTextImportEmpty": "Элементы в файле не найдены",
"roleTextImportEmptyDescription": "Файл не содержит элементов, которые можно импортировать.",
"retryAttempts": "Количество попыток повторного запроса", "retryAttempts": "Количество попыток повторного запроса",
"expectedResponseCodes": "Ожидаемые коды ответов", "expectedResponseCodes": "Ожидаемые коды ответов",
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
@@ -2224,8 +1947,6 @@
"httpMethod": "HTTP метод", "httpMethod": "HTTP метод",
"selectHttpMethod": "Выберите HTTP метод", "selectHttpMethod": "Выберите HTTP метод",
"domainPickerSubdomainLabel": "Поддомен", "domainPickerSubdomainLabel": "Поддомен",
"domainPickerWildcard": "Подстановочный знак",
"domainPickerWildcardPaidOnly": "Wildcard поддомены являются платной функцией. Пожалуйста, обновите подписку, чтобы воспользоваться этой функцией.",
"domainPickerBaseDomainLabel": "Основной домен", "domainPickerBaseDomainLabel": "Основной домен",
"domainPickerSearchDomains": "Поиск доменов...", "domainPickerSearchDomains": "Поиск доменов...",
"domainPickerNoDomainsFound": "Доменов не найдено", "domainPickerNoDomainsFound": "Доменов не найдено",
@@ -2251,12 +1972,12 @@
"resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.", "resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.",
"resourcesTableClients": "Клиенты", "resourcesTableClients": "Клиенты",
"resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.", "resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.",
"resourcesTableNoTargets": "Нет ярлыков",
"resourcesTableHealthy": "Здоровые", "resourcesTableHealthy": "Здоровые",
"resourcesTableDegraded": "Ухудшение", "resourcesTableDegraded": "Ухудшение",
"resourcesTableUnhealthy": "Проблемные", "resourcesTableOffline": "Оффлайн",
"resourcesTableUnknown": "Неизвестен", "resourcesTableUnknown": "Неизвестен",
"resourcesTableNotMonitored": "Не отслеживается", "resourcesTableNotMonitored": "Не отслеживается",
"resourcesTableNoTargets": "Нет целей",
"editInternalResourceDialogEditClientResource": "Изменить приватный ресурс", "editInternalResourceDialogEditClientResource": "Изменить приватный ресурс",
"editInternalResourceDialogUpdateResourceProperties": "Обновить настройки ресурса и элементы управления доступом для {resourceName}", "editInternalResourceDialogUpdateResourceProperties": "Обновить настройки ресурса и элементы управления доступом для {resourceName}",
"editInternalResourceDialogResourceProperties": "Свойства ресурса", "editInternalResourceDialogResourceProperties": "Свойства ресурса",
@@ -2284,9 +2005,8 @@
"editInternalResourceDialogModeCidr": "СИДР", "editInternalResourceDialogModeCidr": "СИДР",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Схема", "editInternalResourceDialogScheme": "Схема",
"editInternalResourceDialogEnableSsl": "Включить TLS", "editInternalResourceDialogEnableSsl": "Включить SSL",
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.", "editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
"editInternalResourceDialogDestination": "Пункт назначения", "editInternalResourceDialogDestination": "Пункт назначения",
"editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
@@ -2334,17 +2054,15 @@
"createInternalResourceDialogModeCidr": "СИДР", "createInternalResourceDialogModeCidr": "СИДР",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Схема", "scheme": "Схема",
"createInternalResourceDialogScheme": "Схема", "createInternalResourceDialogScheme": "Схема",
"createInternalResourceDialogEnableSsl": "Включить TLS", "createInternalResourceDialogEnableSsl": "Включить SSL",
"createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.", "createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.",
"createInternalResourceDialogDestination": "Пункт назначения", "createInternalResourceDialogDestination": "Пункт назначения",
"createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
"createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.",
"internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.",
"internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов",
"internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов",
"siteConfiguration": "Конфигурация", "siteConfiguration": "Конфигурация",
@@ -2471,7 +2189,7 @@
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
"introTitle": "Управляемый Само-Хост Панголина", "introTitle": "Управляемый Само-Хост Панголина",
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.", "introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - ваши туннели, завершение TLS и трафик остаются на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через наш облачный интерфейс, что открывает ряд преимуществ:", "introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
"benefitSimplerOperations": { "benefitSimplerOperations": {
"title": "Более простые операции", "title": "Более простые операции",
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки." "description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
@@ -2600,7 +2318,7 @@
"domainPickerVerified": "Подтверждено", "domainPickerVerified": "Подтверждено",
"domainPickerUnverified": "Не подтверждено", "domainPickerUnverified": "Не подтверждено",
"domainPickerManual": "Ручной", "domainPickerManual": "Ручной",
"domainPickerInvalidSubdomainStructure": "Недопустимые символы будут очищены при сохранении.", "domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
"domainPickerError": "Ошибка", "domainPickerError": "Ошибка",
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации", "domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
"domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена", "domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена",
@@ -2613,7 +2331,7 @@
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
"orgAuthSignInWithPangolin": "Войти через Pangolin", "orgAuthSignInWithPangolin": "Войти через Pangolin",
"orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)", "orgAuthSignInToOrg": "Войти в организацию",
"orgAuthSelectOrgTitle": "Вход в организацию", "orgAuthSelectOrgTitle": "Вход в организацию",
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
"orgAuthOrgIdPlaceholder": "ваша-организация", "orgAuthOrgIdPlaceholder": "ваша-организация",
@@ -2906,8 +2624,6 @@
"validPassword": "Допустимый пароль", "validPassword": "Допустимый пароль",
"validEmail": "Valid email", "validEmail": "Valid email",
"validSSO": "Valid SSO", "validSSO": "Valid SSO",
"view": "Просмотр",
"configManaged": "Конфигурация управляется",
"connectedClient": "Подключенный клиент", "connectedClient": "Подключенный клиент",
"resourceBlocked": "Ресурс заблокирован", "resourceBlocked": "Ресурс заблокирован",
"droppedByRule": "Отброшено по правилам", "droppedByRule": "Отброшено по правилам",
@@ -2916,19 +2632,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Причина", "reason": "Причина",
"requestLogs": "HTTP Запросы Логи", "requestLogs": "Запросить журналы",
"requestAnalytics": "Аналитика запроса", "requestAnalytics": "Аналитика запроса",
"host": "Хост", "host": "Хост",
"location": "Местоположение", "location": "Местоположение",
"actionLogs": "Журнал действий", "actionLogs": "Журнал действий",
"sidebarLogsRequest": "HTTP Запросы Логи", "sidebarLogsRequest": "Запросить журналы",
"sidebarLogsAccess": "Журналы доступа", "sidebarLogsAccess": "Журналы доступа",
"sidebarLogsAction": "Журнал действий", "sidebarLogsAction": "Журнал действий",
"logRetention": "Сохранение журнала", "logRetention": "Сохранение журнала",
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их", "logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации", "requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
"requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации", "requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации",
"logRetentionRequestLabel": "Сохранение HTTP Запросов Лога", "logRetentionRequestLabel": "Запросить сохранение журнала",
"logRetentionRequestDescription": "Как долго сохранять журналы запросов", "logRetentionRequestDescription": "Как долго сохранять журналы запросов",
"logRetentionAccessLabel": "Хранение журнала доступа", "logRetentionAccessLabel": "Хранение журнала доступа",
"logRetentionAccessDescription": "Как долго сохранять журналы доступа", "logRetentionAccessDescription": "Как долго сохранять журналы доступа",
@@ -2974,17 +2690,15 @@
"orgOrDomainIdMissing": "Отсутствует организация или ID домена", "orgOrDomainIdMissing": "Отсутствует организация или ID домена",
"loadingDNSRecords": "Загрузка записей DNS...", "loadingDNSRecords": "Загрузка записей DNS...",
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
"updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.",
"client": "Клиент", "client": "Клиент",
"proxyProtocol": "Настройки протокола прокси", "proxyProtocol": "Настройки протокола прокси",
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
"enableProxyProtocol": "Включить Прокси Протокол", "enableProxyProtocol": "Включить Прокси Протокол",
"proxyProtocolInfo": "Сохранять IP-адреса клиента для backend'ов TCP", "proxyProtocolInfo": "Сохранять IP-адреса клиента для backend'ов TCP",
"proxyProtocolVersion": "Версия протокола прокси", "proxyProtocolVersion": "Версия протокола прокси",
"version1": "Версия 1 (рекомендуется)", "version1": " Версия 1 (рекомендуется)",
"version2": "Версия 2", "version2": "Версия 2",
"version1Description": "Основано на тексте и широко поддерживается. Убедитесь, что транспорт сервера добавлен в динамическую конфигурацию.", "versionDescription": "Версия 1 основана на тексте и широко поддерживается. Версия 2 является бинарной и более эффективной, но менее совместимой.",
"version2Description": "Бинарная и более эффективная, но менее совместимая. Убедитесь, что транспорт сервера добавлен в динамическую конфигурацию.",
"warning": "Предупреждение", "warning": "Предупреждение",
"proxyProtocolWarning": "Бэкэнд приложение должно быть настроено на принятие соединений прокси-протокола. Если ваш бэкэнд не поддерживает Прокси-протокол, то включение этой опции прервет все подключения, поэтому включите это только если вы знаете, что вы делаете. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik.", "proxyProtocolWarning": "Бэкэнд приложение должно быть настроено на принятие соединений прокси-протокола. Если ваш бэкэнд не поддерживает Прокси-протокол, то включение этой опции прервет все подключения, поэтому включите это только если вы знаете, что вы делаете. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik.",
"restarting": "Перезапуск...", "restarting": "Перезапуск...",
@@ -3141,7 +2855,7 @@
"enterConfirmation": "Введите подтверждение", "enterConfirmation": "Введите подтверждение",
"blueprintViewDetails": "Подробности", "blueprintViewDetails": "Подробности",
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию", "defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
"defaultIdentityProviderDescription": "Пользователь будет автоматически перенаправлен к этому поставщику удостоверений для аутентификации.", "defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.",
"editInternalResourceDialogNetworkSettings": "Настройки сети", "editInternalResourceDialogNetworkSettings": "Настройки сети",
"editInternalResourceDialogAccessPolicy": "Политика доступа", "editInternalResourceDialogAccessPolicy": "Политика доступа",
"editInternalResourceDialogAddRoles": "Добавить роли", "editInternalResourceDialogAddRoles": "Добавить роли",
@@ -3149,8 +2863,6 @@
"editInternalResourceDialogAddClients": "Добавить клиентов", "editInternalResourceDialogAddClients": "Добавить клиентов",
"editInternalResourceDialogDestinationLabel": "Пункт назначения", "editInternalResourceDialogDestinationLabel": "Пункт назначения",
"editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.", "editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.",
"internalResourceFormMultiSiteRoutingHelp": "Выбор нескольких сайтов позволяет обеспечить отказоустойчивую маршрутизацию и фейловер для высокой доступности.",
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Узнать больше",
"editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.", "editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.",
"createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP",
"createInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.", "createInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.",
@@ -3177,12 +2889,11 @@
"learnMore": "Узнать больше", "learnMore": "Узнать больше",
"backToHome": "Вернуться домой", "backToHome": "Вернуться домой",
"needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?", "needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?",
"maintenanceMode": "Страница обслуживания", "maintenanceMode": "Режим обслуживания",
"maintenanceModeDescription": "Показать страницу обслуживания посетителям", "maintenanceModeDescription": "Показать страницу обслуживания посетителям",
"maintenanceModeType": "Тип режима обслуживания", "maintenanceModeType": "Тип режима обслуживания",
"showMaintenancePage": "Показать страницу обслуживания посетителям", "showMaintenancePage": "Показать страницу обслуживания посетителям",
"enableMaintenanceMode": "Включить режим обслуживания", "enableMaintenanceMode": "Включить режим обслуживания",
"enableMaintenanceModeDescription": "Когда включено, посетители увидят страницу обслуживания вместо вашего ресурса.",
"automatic": "Автоматический", "automatic": "Автоматический",
"automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.", "automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.",
"forced": "Принудительно", "forced": "Принудительно",
@@ -3190,8 +2901,6 @@
"warning:": "Предупреждение:", "warning:": "Предупреждение:",
"forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.", "forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.",
"pageTitle": "Заголовок страницы", "pageTitle": "Заголовок страницы",
"maintenancePageContentSubsection": "Содержимое страницы",
"maintenancePageContentSubsectionDescription": "Настройте содержимое, отображаемое на странице обслуживания",
"pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания", "pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания",
"maintenancePageMessage": "Сообщение об обслуживании", "maintenancePageMessage": "Сообщение об обслуживании",
"maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.", "maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.",
@@ -3199,7 +2908,6 @@
"maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)", "maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)",
"privateMaintenanceScreenTitle": "Экраны частной заглушки", "privateMaintenanceScreenTitle": "Экраны частной заглушки",
"privateMaintenanceScreenMessage": "Этот домен используется на частном ресурсе. Пожалуйста, подключитесь с помощью клиента Pangolin для доступа к этому ресурсу.", "privateMaintenanceScreenMessage": "Этот домен используется на частном ресурсе. Пожалуйста, подключитесь с помощью клиента Pangolin для доступа к этому ресурсу.",
"privateMaintenanceScreenSteps": "После подключения, если это сообщение по-прежнему отображается, кэш DNS вашего браузера может указывать на старый адрес. Чтобы исправить эту неисправность: полностью закройте и снова откройте эту вкладку или браузер, затем вернитесь на эту страницу.",
"maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера", "maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера",
"maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания", "maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания",
"editDomain": "Редактировать домен", "editDomain": "Редактировать домен",
@@ -3210,7 +2918,6 @@
"maintenanceScreenEstimatedCompletion": "Предполагаемое завершение:", "maintenanceScreenEstimatedCompletion": "Предполагаемое завершение:",
"createInternalResourceDialogDestinationRequired": "Укажите адрес назначения. Это может быть имя хоста или IP-адрес.", "createInternalResourceDialogDestinationRequired": "Укажите адрес назначения. Это может быть имя хоста или IP-адрес.",
"available": "Доступно", "available": "Доступно",
"disabledResourceDescription": "Когда отключено, ресурс будет недоступен для всех.",
"archived": "Архивировано", "archived": "Архивировано",
"noArchivedDevices": "Архивные устройства не найдены", "noArchivedDevices": "Архивные устройства не найдены",
"deviceArchived": "Устройство архивировано", "deviceArchived": "Устройство архивировано",
@@ -3324,7 +3031,7 @@
"streamingDatadogTitle": "Datadog", "streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.", "streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.",
"streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.", "streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.",
"streamingLastSyncError": "Во время последней синхронизации произошла ошибка", "streamingFailedToLoad": "Не удалось загрузить места назначения",
"streamingUnexpectedError": "Произошла непредвиденная ошибка.", "streamingUnexpectedError": "Произошла непредвиденная ошибка.",
"streamingFailedToUpdate": "Не удалось обновить место назначения", "streamingFailedToUpdate": "Не удалось обновить место назначения",
"streamingDeletedSuccess": "Адрес назначения успешно удален", "streamingDeletedSuccess": "Адрес назначения успешно удален",
@@ -3341,34 +3048,7 @@
"S3DestEditTitle": "Редактировать пункт назначения", "S3DestEditTitle": "Редактировать пункт назначения",
"S3DestAddTitle": "Добавить S3 пункт назначения", "S3DestAddTitle": "Добавить S3 пункт назначения",
"S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.", "S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.",
"S3DestAddDescription": "Настройте новый Amazon S3 (или совместимое S3) хранилище для получения событий вашей организации.", "S3DestAddDescription": "Настройте новую S3 конечную точку для получения событий вашей организации.",
"s3DestTabSettings": "Настройки",
"s3DestTabFormat": "Формат",
"s3DestNameLabel": "Имя",
"s3DestNamePlaceholder": "Моя S3 конечная точка",
"s3DestAccessKeyIdLabel": "Идентификатор ключа доступа AWS",
"s3DestSecretAccessKeyLabel": "Секретный ключ доступа AWS",
"s3DestSecretAccessKeyPlaceholder": "Ваш секретный ключ доступа AWS",
"s3DestRegionLabel": "Регион AWS",
"s3DestBucketLabel": "Имя хранилища",
"s3DestPrefixLabel": "Префикс ключа (по желанию)",
"s3DestPrefixDescription": "Необязательный префикс пути, добавляется к каждому ключу объекта. Объекты хранятся в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Пользовательская конечная точка (по желанию)",
"s3DestEndpointDescription": "Переопределите конечную точку S3 для совместимого хранилища, такого как MinIO или Cloudflare R2. Оставьте пустым для стандартного AWS S3.",
"s3DestGzipLabel": "Сжатие Gzip",
"s3DestGzipDescription": "Сжимайте каждый загруженный объект с помощью gzip. Уменьшает стоимость хранения и размер загрузки.",
"s3DestFormatTitle": "Формат файла",
"s3DestFormatDescription": "Как события сериализуются внутри каждого загруженного объекта.",
"s3DestFormatJsonArrayDescription": "Каждый объект — это JSON массив записей событий. Совместим с большинством аналитических инструментов.",
"s3DestFormatNdjsonDescription": "Каждый объект содержит одну запись JSON на строку (JSON, разделённый новой строкой). Совместим с Athena, BigQuery и Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Каждый объект представляет собой CSV файл по стандарту RFC-4180 с заголовочной строкой. Имена столбцов выведены из полей данных событий.",
"s3DestSaveChanges": "Сохранить изменения",
"s3DestCreateDestination": "Создать конечную точку",
"s3DestUpdatedSuccess": "Конечная точка успешно обновлена",
"s3DestCreatedSuccess": "Конечная точка успешно создана",
"s3DestUpdateFailed": "Не удалось обновить конечную точку",
"s3DestCreateFailed": "Не удалось создать конечную точку",
"datadogDestEditTitle": "Редактировать пункт назначения", "datadogDestEditTitle": "Редактировать пункт назначения",
"datadogDestAddTitle": "Добавить пункт назначения Datadog", "datadogDestAddTitle": "Добавить пункт назначения Datadog",
"datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.", "datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.",
@@ -3423,7 +3103,7 @@
"httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.", "httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.",
"httpDestConnectionLogsTitle": "Журнал подключений", "httpDestConnectionLogsTitle": "Журнал подключений",
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.", "httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
"httpDestRequestLogsTitle": "HTTP Запросы Логи", "httpDestRequestLogsTitle": "Запросить журналы",
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.", "httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
"httpDestSaveChanges": "Сохранить изменения", "httpDestSaveChanges": "Сохранить изменения",
"httpDestCreateDestination": "Создать адрес назначения", "httpDestCreateDestination": "Создать адрес назначения",
@@ -3456,8 +3136,6 @@
"idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?", "idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?",
"idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.", "idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.",
"idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений", "idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений",
"idpConfirmDeleteAndRemoveMeFromOrg": "УДАЛИТЬ И ИЗВЛЕЧЬ МЕНЯ ИЗ ОРГАНИЗАЦИИ",
"idpUnassociateAndRemoveMeFromOrg": "РАЗОРВАТЬ СВЯЗЬ И УДАЛИТЬ МЕНЯ ИЗ ОРГАНИЗАЦИИ",
"idpUnassociateWarning": "Это не может быть отменено для этой организации.", "idpUnassociateWarning": "Это не может быть отменено для этой организации.",
"idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией", "idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией",
"idpUnassociateMenu": "Рассоединить", "idpUnassociateMenu": "Рассоединить",
@@ -3465,144 +3143,34 @@
"publicIpEndpoint": "Конечная точка", "publicIpEndpoint": "Конечная точка",
"lastTriggeredAt": "Последний триггер", "lastTriggeredAt": "Последний триггер",
"reject": "Отклонить", "reject": "Отклонить",
"uptimeDaysAgo": "{count} дней назад", "uptimeDaysAgo": "{count} days ago",
"uptimeToday": "Сегодня", "uptimeToday": "Today",
"uptimeNoDataAvailable": "Нет доступных данных", "uptimeNoDataAvailable": "No data available",
"uptimeSuffix": "время работы", "uptimeSuffix": "uptime",
"uptimeDowntimeSuffix": "время простоя", "uptimeDowntimeSuffix": "downtime",
"uptimeTooltipUptimeLabel": "Время работы", "uptimeTooltipUptimeLabel": "Uptime",
"uptimeTooltipDowntimeLabel": "Время простоя", "uptimeTooltipDowntimeLabel": "Downtime",
"uptimeOngoing": "в процессе", "uptimeOngoing": "ongoing",
"uptimeNoMonitoringData": "Отсутствуют данные мониторинга", "uptimeNoMonitoringData": "No monitoring data",
"uptimeNoData": "Нет данных", "uptimeNoData": "No data",
"uptimeMiniBarDown": "Не работает", "uptimeMiniBarDown": "Down",
"uptimeSectionTitle": "Время работы", "uptimeSectionTitle": "Uptime",
"uptimeSectionDescription": "Доступность за последние {days} дней", "uptimeSectionDescription": "Site availability over the last {days} days.",
"uptimeAddAlert": "Добавить предупреждение", "uptimeAddAlert": "Add Alert",
"uptimeViewAlerts": "Просмотр предупреждений", "uptimeViewAlerts": "View Alerts",
"uptimeCreateEmailAlert": "Создать оповещение по электронной почте", "uptimeCreateEmailAlert": "Create Email Alert",
"uptimeAlertDescriptionSite": "Получайте уведомления по электронной почте, когда этот сайт выходит из сети или снова подключается.", "uptimeAlertDescriptionSite": "Get notified by email when this site goes offline or comes back online.",
"uptimeAlertDescriptionResource": "Получайте уведомления по электронной почте, когда этот ресурс выходит из сети или снова подключается.", "uptimeAlertDescriptionResource": "Get notified by email when this resource goes offline or comes back online.",
"uptimeAlertNamePlaceholder": "Название предупреждения", "uptimeAlertNamePlaceholder": "Alert name",
"uptimeAdditionalEmails": "Дополнительные адреса электронной почты", "uptimeAdditionalEmails": "Additional Emails",
"uptimeCreateAlert": "Создать предупреждение", "uptimeCreateAlert": "Create Alert",
"uptimeAlertNoRecipients": "Нет получателей", "uptimeAlertNoRecipients": "No recipients",
"uptimeAlertNoRecipientsDescription": "Пожалуйста, добавьте хотя бы одного пользователя, роль или email для уведомления.", "uptimeAlertNoRecipientsDescription": "Please add at least one user, role, or email to notify.",
"uptimeAlertCreated": "Предупреждение создано", "uptimeAlertCreated": "Alert created",
"uptimeAlertCreatedDescription": "Вы будете уведомлены, когда статус изменится.", "uptimeAlertCreatedDescription": "You will be notified when this changes status.",
"uptimeAlertCreateFailed": "Не удалось создать предупреждение", "uptimeAlertCreateFailed": "Failed to create alert",
"webhookUrlLabel": "URL", "webhookUrlLabel": "URL",
"webhookHeaderKeyPlaceholder": "Ключ", "webhookHeaderKeyPlaceholder": "Key",
"webhookHeaderValuePlaceholder": "Значение", "webhookHeaderValuePlaceholder": "Value",
"alertLabel": "Предупреждение", "alertLabel": "Alert"
"domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.",
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
"domainPickerWildcardCertWarningLink": "Узнать больше",
"health": "Состояние",
"domainPendingErrorTitle": "Проблема с подтверждением",
"memberPortalTitle": "Ресурсы",
"memberPortalDescription": "Ресурсы, к которым у вас есть доступ в этой организации",
"memberPortalSortBy": "Сортировать по...",
"memberPortalSortNameAsc": "Имя A-Я",
"memberPortalSortNameDesc": "Имя Я-A",
"memberPortalSortDomainAsc": "Домен A-Я",
"memberPortalSortDomainDesc": "Домен Я-A",
"memberPortalSortEnabledFirst": "Включённые сначала",
"memberPortalSortDisabledFirst": "Отключённые сначала",
"memberPortalRefresh": "Обновить",
"memberPortalRefreshResources": "Обновить ресурсы",
"memberPortalFailedToLoad": "Не удалось загрузить ресурсы",
"memberPortalFailedToLoadDescription": "Не удалось загрузить ресурсы. Пожалуйста, проверьте подключение и попробуйте снова.",
"memberPortalUnableToLoad": "Не удалось загрузить ресурсы",
"memberPortalTryAgain": "Попробуйте снова",
"memberPortalNoResourcesFound": "Ресурсы не найдены",
"memberPortalNoResourcesAvailable": "Нет доступных ресурсов",
"memberPortalNoResourcesMatchSearch": "Нет ресурсов, соответствующих \"{query}\". Попробуйте изменить условия поиска или очистить поиск, чтобы увидеть все ресурсы.",
"memberPortalNoResourcesAccess": "У вас пока нет доступа к ресурсам. Свяжитесь с администратором, чтобы получить доступ к нужным вам ресурсам.",
"memberPortalClearSearch": "Очистить поиск",
"memberPortalPublicResources": "Публичные ресурсы",
"memberPortalPublicResourcesDescription": "Веб-приложения и сервисы, доступные через браузер",
"memberPortalCopiedToClipboard": "Скопировано в буфер обмена",
"memberPortalCopiedUrlDescription": "URL ресурса был скопирован в ваш буфер обмена.",
"memberPortalOpenResource": "Открыть ресурс",
"memberPortalPrivateResources": "Приватные ресурсы",
"memberPortalPrivateResourcesDescription": "Ресурсы внутренней сети, доступные через клиент",
"memberPortalResourceDetails": "Детали ресурса",
"memberPortalMode": "Режим",
"memberPortalDestination": "Назначение",
"memberPortalAlias": "Псевдоним",
"memberPortalCopiedAliasDescription": "Псевдоним ресурса был скопирован в ваш буфер обмена.",
"memberPortalCopiedDestinationDescription": "Назначение ресурса было скопировано в ваш буфер обмена.",
"memberPortalRequiresClientConnection": "Требуется подключение клиента",
"memberPortalAuthMethods": "Методы аутентификации",
"memberPortalSso": "Единый вход (SSO)",
"memberPortalPasswordProtected": "Защищено паролем",
"memberPortalPinCode": "PIN-код",
"memberPortalEmailWhitelist": "Белый список email",
"memberPortalResourceDisabled": "Ресурс отключён",
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
"memberPortalPrevious": "Предыдущий",
"memberPortalNext": "Следующий",
"httpSettings": "Настройки HTTP",
"tcpSettings": "Настройки TCP",
"udpSettings": "Настройки UDP",
"sshTitle": "SSH",
"sshConnectingDescription": "Установление защищенного соединения…",
"sshConnecting": "Подключение…",
"sshInitializing": "Инициализация…",
"sshSignInTitle": "Вход в SSH",
"sshSignInDescription": "Введите свои учетные данные SSH для подключения",
"sshPasswordTab": "Пароль",
"sshPrivateKeyTab": "Закрытый ключ",
"sshPrivateKeyField": "Закрытый ключ",
"sshPrivateKeyDisclaimer": "Ваш закрытый ключ не хранится и не виден для Pangolin. Вместо этого вы можете использовать краткосрочные сертификаты для бесшовной аутентификации с использованием вашей текущей идентификации Pangolin.",
"sshLearnMore": "Узнать больше",
"sshPrivateKeyFile": "Файл закрытого ключа",
"sshAuthenticate": "Подключиться",
"sshTerminate": "Завершить",
"sshPoweredBy": "Разработано",
"sshErrorNoTarget": "Цель не указана",
"sshErrorWebSocket": "Подключение WebSocket не удалось",
"sshErrorAuthFailed": "Ошибка аутентификации",
"sshErrorConnectionClosed": "Подключение закрыто до завершения аутентификации",
"sitePangolinSshDescription": "Разрешить доступ по SSH к ресурсам на этом сайте. Это можно изменить позже.",
"browserGatewayNoResourceForDomain": "Ресурс для этого домена не найден",
"browserGatewayNoTarget": "Нет цели",
"browserGatewayConnect": "Подключиться",
"browserGatewayCtrlAltDel": "Ctrl+Alt+Del",
"sshErrorSignKeyFailed": "Не удалось подписать ключ SSH для аутентификации через PAM push. Проверьте, вошли ли вы как пользователь?",
"sshTerminalError": "Ошибка: {error}",
"sshConnectionClosedCode": "Соединение закрыто (код {code})",
"sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----",
"sshPrivateKeyRequired": "Требуется личный ключ",
"vncTitle": "VNC",
"vncSignInDescription": "Введите пароль VNC для подключения",
"vncPasswordOptional": "Пароль (необязательно)",
"vncNoResourceTarget": "Отсутствует целевой ресурс",
"vncFailedToLoadNovnc": "Не удалось загрузить noVNC",
"vncAuthFailedStatus": "Статус {status}",
"vncPasteClipboard": "Вставить из буфера обмена",
"rdpTitle": "RDP",
"rdpSignInTitle": "Вход в удаленный рабочий стол",
"rdpSignInDescription": "Введите учетные данные Windows для подключения",
"rdpLoadingModule": "Загрузка модуля...",
"rdpFailedToLoadModule": "Не удалось загрузить модуль RDP",
"rdpNotReady": "Не готово",
"rdpModuleInitializing": "Модуль RDP все еще инициализируется",
"rdpDownloadingFiles": "Загрузка {count} файлов с удалённого сервера…",
"rdpDownloadFailed": "Ошибка загрузки: {fileName}",
"rdpUploaded": "Загружено: {fileName}",
"rdpNoConnectionTarget": "Доступная цель подключения отсутствует",
"rdpConnectionFailed": "Ошибка соединения",
"rdpFit": "Подгонка",
"rdpFull": "Полный",
"rdpReal": "Настоящий",
"rdpMeta": "Метаданные",
"rdpUploadFiles": "Загрузить файлы",
"rdpFilesReadyToPaste": "Файлы готовы к вставке",
"rdpFilesReadyToPasteDescription": "{count, plural, one {# файл скопирован в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} few {# файла скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} many {# файлов скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} other {# файла скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.}}",
"rdpUploadFailed": "Ошибка загрузки",
"rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode",
"sessionToolbarShow": "Показать панель инструментов",
"sessionToolbarHide": "Скрыть панель инструментов"
} }
+80 -512
View File
File diff suppressed because it is too large Load Diff
+128 -560
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -152,8 +152,8 @@
"shareErrorSelectResource": "請選擇一個資源", "shareErrorSelectResource": "請選擇一個資源",
"proxyResourceTitle": "管理公開資源", "proxyResourceTitle": "管理公開資源",
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
"publicResourcesBannerTitle": "基於網頁的公開存取", "proxyResourcesBannerTitle": "基於網頁的公開存取",
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"clientResourceTitle": "管理私有資源", "clientResourceTitle": "管理私有資源",
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
"privateResourcesBannerTitle": "零信任私有存取", "privateResourcesBannerTitle": "零信任私有存取",
@@ -489,7 +489,7 @@
"createdAt": "創建於", "createdAt": "創建於",
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。",
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
"proxyEnableSSL": "啟用 TLS", "proxyEnableSSL": "啟用 SSL",
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
"target": "目標", "target": "目標",
"configureTarget": "配置目標", "configureTarget": "配置目標",
@@ -1763,7 +1763,7 @@
"description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能",
"introTitle": "託管式自架 Pangolin", "introTitle": "託管式自架 Pangolin",
"introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"benefitSimplerOperations": { "benefitSimplerOperations": {
"title": "簡單的操作", "title": "簡單的操作",
"description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"
+7 -32
View File
@@ -1,42 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import fs from "fs";
import path from "path";
const withNextIntl = createNextIntlPlugin(); const withNextIntl = createNextIntlPlugin();
// read allowedDevOrigins.json if it exists
let allowedDevOrigins: string[] = [];
const allowedDevOriginsPath = path.join(
process.cwd(),
"allowedDevOrigins.json"
);
if (fs.existsSync(allowedDevOriginsPath)) {
try {
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
allowedDevOrigins = JSON.parse(data);
} catch {}
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
reactCompiler: true, eslint: {
transpilePackages: ["@novnc/novnc"], ignoreDuringBuilds: true
output: "standalone", },
allowedDevOrigins, experimental: {
async redirects() { reactCompiler: true
return [ },
{ output: "standalone"
source: "/:orgId/settings/resources/proxy/:path*",
destination: "/:orgId/settings/resources/public/:path*",
permanent: true
},
{
source: "/:orgId/settings/resources/client/:path*",
destination: "/:orgId/settings/resources/private/:path*",
permanent: true
}
];
}
}; };
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);
+3621 -2283
View File
File diff suppressed because it is too large Load Diff
+58 -64
View File
@@ -32,15 +32,13 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0", "@asteasolutions/zod-to-openapi": "8.4.1",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", "@faker-js/faker": "10.3.0",
"@aws-sdk/client-s3": "3.1056.0", "@headlessui/react": "2.2.9",
"@headlessui/react": "2.2.10", "@hookform/resolvers": "5.2.2",
"@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
@@ -61,20 +59,16 @@
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@react-email/body": "0.3.0", "@react-email/components": "1.0.8",
"@react-email/components": "1.0.12", "@react-email/render": "2.0.4",
"@react-email/render": "2.0.8", "@react-email/tailwind": "2.0.5",
"@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.0",
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14", "@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.16.1", "axios": "1.15.0",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4", "canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -86,76 +80,77 @@
"d3": "7.9.0", "d3": "7.9.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"express": "5.2.1", "express": "5.2.1",
"express-rate-limit": "8.5.2", "express-rate-limit": "8.3.0",
"glob": "13.0.6", "glob": "13.0.6",
"helmet": "8.2.0", "helmet": "8.1.0",
"http-errors": "2.0.1", "http-errors": "2.0.1",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.11.0", "ioredis": "5.10.0",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.2.0", "js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0", "lucide-react": "0.577.0",
"maxmind": "5.0.6", "maxmind": "5.0.5",
"moment": "2.30.1", "moment": "2.30.1",
"next": "16.2.6", "next": "15.5.15",
"next-intl": "4.13.0", "next-intl": "4.8.3",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"nodemailer": "9.0.1", "nodemailer": "8.0.5",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.21.0", "pg": "8.20.0",
"posthog-node": "5.35.6", "posthog-node": "5.28.0",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.6", "react-dom": "19.2.4",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.76.1", "react-hook-form": "7.71.2",
"react-icons": "5.6.0", "react-icons": "5.6.0",
"recharts": "3.8.1", "recharts": "2.15.4",
"reodotdev": "1.1.0", "reodotdev": "1.1.0",
"semver": "7.8.1", "resend": "6.9.2",
"semver": "7.7.4",
"sshpk": "1.18.0", "sshpk": "1.18.0",
"stripe": "22.2.0", "stripe": "20.4.1",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.5.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"use-debounce": "10.1.1", "use-debounce": "10.1.0",
"uuid": "14.0.0", "uuid": "13.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0", "winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.21.0", "ws": "8.19.0",
"yaml": "2.9.0", "yaml": "2.8.3",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "4.4.3", "zod": "4.3.6",
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.69.1", "@dotenvx/dotenvx": "1.54.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0", "@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.3.0", "@tailwindcss/postcss": "4.2.2",
"@tanstack/react-query-devtools": "5.100.14", "@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2", "@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3", "@types/d3": "7.4.3",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/express-session": "1.19.0", "@types/express-session": "1.18.2",
"@types/jmespath": "0.15.2", "@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "9.0.10",
"@types/node": "25.9.1", "@types/node": "25.3.5",
"@types/nodemailer": "8.0.0", "@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.20.0", "@types/pg": "8.18.0",
"@types/react": "19.2.15", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/sshpk": "1.17.4", "@types/sshpk": "1.17.4",
@@ -165,22 +160,21 @@
"@types/yargs": "17.0.35", "@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.28.1", "esbuild": "0.27.4",
"esbuild-node-externals": "1.22.0", "esbuild-node-externals": "1.20.1",
"eslint": "10.4.0", "eslint": "10.0.3",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.1.7",
"postcss": "8.5.15", "postcss": "8.5.8",
"prettier": "3.8.3", "prettier": "3.8.1",
"react-email": "6.5.0", "react-email": "5.2.10",
"tailwindcss": "4.3.0", "tailwindcss": "4.2.2",
"tsc-alias": "1.8.17", "tsc-alias": "1.8.16",
"tsx": "4.22.3", "tsx": "4.21.0",
"typescript": "6.0.3", "typescript": "5.9.3",
"typescript-eslint": "8.60.0" "typescript-eslint": "8.56.1"
}, },
"overrides": { "overrides": {
"esbuild": "0.28.1", "esbuild": "0.27.4",
"dompurify": "3.4.0", "dompurify": "3.3.2"
"postcss": "8.5.15"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 274 KiB

+19 -44
View File
@@ -5,7 +5,6 @@ import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
export enum ActionsEnum { export enum ActionsEnum {
createOrgUser = "createOrgUser", createOrgUser = "createOrgUser",
@@ -123,6 +122,8 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain", createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain", deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain", restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode", createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode", getRemoteExitNode = "getRemoteExitNode",
@@ -149,36 +150,14 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule", updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule", deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules", listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule", getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck", createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck", updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck", deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks", listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget", triggerSiteAlert = "triggerSiteAlert",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget", triggerResourceAlert = "triggerResourceAlert",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget", triggerHealthCheckAlert = "triggerHealthCheckAlert"
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets",
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( export async function checkUserActionPermission(
@@ -211,23 +190,6 @@ 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 // Check if the user has direct permission for the action in the current org
const userActionPermission = await db const userActionPermission = await db
.select() .select()
@@ -245,7 +207,20 @@ export async function checkUserActionPermission(
return true; return true;
} }
return false; // 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;
} catch (error) { } catch (error) {
console.error("Error checking user action permission:", error); console.error("Error checking user action permission:", error);
throw createHttpError( throw createHttpError(
+25 -97
View File
@@ -1,12 +1,6 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { import { roleResources, userResources } from "@server/db";
rolePolicies,
roleResources,
resources,
userPolicies,
userResources
} from "@server/db";
export async function canUserAccessResource({ export async function canUserAccessResource({
userId, userId,
@@ -17,14 +11,9 @@ export async function canUserAccessResource({
resourceId: number; resourceId: number;
roleIds: number[]; roleIds: number[];
}): Promise<boolean> { }): Promise<boolean> {
const [ const roleResourceAccess =
roleResourceAccess,
rolePolicyAccess,
userResourceAccess,
userPolicyAccess
] = await Promise.all([
roleIds.length > 0 roleIds.length > 0
? db ? await db
.select() .select()
.from(roleResources) .from(roleResources)
.where( .where(
@@ -34,87 +23,26 @@ export async function canUserAccessResource({
) )
) )
.limit(1) .limit(1)
: [], : [];
roleIds.length > 0
? db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
rolePolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
.limit(1)
: [],
db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1),
db
.select({
userId: userPolicies.userId,
resourcePolicyId: userPolicies.resourcePolicyId
})
.from(userPolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
userPolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
userPolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
eq(userPolicies.userId, userId)
)
)
.limit(1)
]);
return ( if (roleResourceAccess.length > 0) {
roleResourceAccess.length > 0 || return true;
rolePolicyAccess.length > 0 || }
userResourceAccess.length > 0 ||
userPolicyAccess.length > 0 const userResourceAccess = await db
); .select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) {
return true;
}
return false;
} }
+1 -40
View File
@@ -12,7 +12,7 @@ import {
users users
} from "@server/db"; } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random"; import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random";
@@ -136,45 +136,6 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
} }
} }
export async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export function serializeSessionCookie( export function serializeSessionCookie(
token: string, token: string,
isSecure: boolean, isSecure: boolean,
+1 -10
View File
@@ -19,9 +19,6 @@ export async function createResourceSession(opts: {
userSessionId?: string | null; userSessionId?: string | null;
whitelistId?: number | null; whitelistId?: number | null;
accessTokenId?: string | null; accessTokenId?: string | null;
policyPasswordId?: number | null;
policyPincodeId?: number | null;
policyWhitelistId?: number | null;
doNotExtend?: boolean; doNotExtend?: boolean;
expiresAt?: number | null; expiresAt?: number | null;
sessionLength?: number | null; sessionLength?: number | null;
@@ -31,10 +28,7 @@ export async function createResourceSession(opts: {
!opts.pincodeId && !opts.pincodeId &&
!opts.whitelistId && !opts.whitelistId &&
!opts.accessTokenId && !opts.accessTokenId &&
!opts.userSessionId && !opts.userSessionId
!opts.policyPasswordId &&
!opts.policyPincodeId &&
!opts.policyWhitelistId
) { ) {
throw new Error("Auth method must be provided"); throw new Error("Auth method must be provided");
} }
@@ -55,9 +49,6 @@ export async function createResourceSession(opts: {
whitelistId: opts.whitelistId || null, whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false, doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null, accessTokenId: opts.accessTokenId || null,
policyPasswordId: opts.policyPasswordId || null,
policyPincodeId: opts.policyPincodeId || null,
policyWhitelistId: opts.policyWhitelistId || null,
isRequestToken: opts.isRequestToken || false, isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null, userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime() issuedAt: new Date().getTime()
+4 -7
View File
@@ -795,13 +795,10 @@ export const COUNTRIES = [
name: "Serbia", name: "Serbia",
code: "RS" code: "RS"
}, },
// Removed as this is a deprecated ISO country code, not supported anymore {
// Also the individual flags for Serbia & Montenegro are already included in the list name: "Serbia and Montenegro",
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS code: "CS"
// { },
// name: "Serbia and Montenegro",
// code: "CS"
// },
{ {
name: "Seychelles", name: "Seychelles",
code: "SC" code: "SC"
+128 -162
View File
@@ -1,53 +1,94 @@
{ {
"PowerMac4,4": "eMac",
"PowerMac6,4": "eMac",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook6,3": "iBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"iMac,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,5": "iMac",
"PowerMac6,1": "iMac",
"PowerMac6,3*": "iMac",
"PowerMac6,3": "iMac",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac12,1": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,2": "iMac",
"iMac5,1": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,3": "iMac",
"iMac14,2": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,2": "iMac",
"iMac19,1": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,2": "iMac",
"iMac21,1": "iMac",
"iMacPro1,1": "iMac Pro",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"ADP3,2": "Mac mini", "ADP3,2": "Mac mini",
"Macmini9,1": "Mac mini",
"Mac14,3": "Mac mini",
"Mac14,12": "Mac mini",
"MacPro1,1*": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"N/A*": "Power Macintosh",
"PowerMac1,1": "Power Macintosh",
"PowerMac3,1": "Power Macintosh",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"Mac13,1": "Mac Studio", "Mac13,1": "Mac Studio",
"Mac13,2": "Mac Studio", "Mac13,2": "Mac Studio",
"Mac14,10": "MacBook Pro",
"Mac14,12": "Mac mini",
"Mac14,13": "Mac Studio",
"Mac14,14": "Mac Studio",
"Mac14,15": "MacBook Air",
"Mac14,2": "MacBook Air",
"Mac14,3": "Mac mini",
"Mac14,5": "MacBook Pro",
"Mac14,6": "MacBook Pro",
"Mac14,7": "MacBook Pro",
"Mac14,8": "Mac Pro",
"Mac14,9": "MacBook Pro",
"Mac15,10": "MacBook Pro",
"Mac15,11": "MacBook Pro",
"Mac15,12": "MacBook Air",
"Mac15,13": "MacBook Air",
"Mac15,14": "Mac Studio",
"Mac15,3": "MacBook Pro",
"Mac15,4": "iMac",
"Mac15,5": "iMac",
"Mac15,6": "MacBook Pro",
"Mac15,7": "MacBook Pro",
"Mac15,8": "MacBook Pro",
"Mac15,9": "MacBook Pro",
"Mac16,1": "MacBook Pro",
"Mac16,10": "Mac mini",
"Mac16,11": "Mac mini",
"Mac16,12": "MacBook Air",
"Mac16,13": "MacBook Air",
"Mac16,2": "iMac",
"Mac16,3": "iMac",
"Mac16,5": "MacBook Pro",
"Mac16,6": "MacBook Pro",
"Mac16,7": "MacBook Pro",
"Mac16,8": "MacBook Pro",
"Mac16,9": "Mac Studio",
"Mac17,2": "MacBook Pro",
"Mac17,3": "MacBook Air",
"Mac17,4": "MacBook Air",
"Mac17,5": "MacBook Neo",
"Mac17,6": "MacBook Pro",
"Mac17,7": "MacBook Pro",
"Mac17,8": "MacBook Pro",
"Mac17,9": "MacBook Pro",
"MacBook1,1": "MacBook", "MacBook1,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBook2,1": "MacBook", "MacBook2,1": "MacBook",
"MacBook3,1": "MacBook", "MacBook3,1": "MacBook",
"MacBook4,1": "MacBook", "MacBook4,1": "MacBook",
@@ -57,8 +98,8 @@
"MacBook7,1": "MacBook", "MacBook7,1": "MacBook",
"MacBook8,1": "MacBook", "MacBook8,1": "MacBook",
"MacBook9,1": "MacBook", "MacBook9,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBookAir1,1": "MacBook Air", "MacBookAir1,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"MacBookAir2,1": "MacBook Air", "MacBookAir2,1": "MacBook Air",
"MacBookAir3,1": "MacBook Air", "MacBookAir3,1": "MacBook Air",
"MacBookAir3,2": "MacBook Air", "MacBookAir3,2": "MacBook Air",
@@ -73,163 +114,88 @@
"MacBookAir8,1": "MacBook Air", "MacBookAir8,1": "MacBook Air",
"MacBookAir8,2": "MacBook Air", "MacBookAir8,2": "MacBook Air",
"MacBookAir9,1": "MacBook Air", "MacBookAir9,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"Mac14,2": "MacBook Air",
"MacBookPro1,1": "MacBook Pro", "MacBookPro1,1": "MacBook Pro",
"MacBookPro1,2": "MacBook Pro", "MacBookPro1,2": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro",
"MacBookPro5,5": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro",
"MacBookPro7,1": "MacBook Pro",
"MacBookPro6,2": "MacBook Pro",
"MacBookPro6,1": "MacBook Pro",
"MacBookPro8,1": "MacBook Pro",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro10,1": "MacBook Pro", "MacBookPro10,1": "MacBook Pro",
"MacBookPro10,2": "MacBook Pro", "MacBookPro10,2": "MacBook Pro",
"MacBookPro11,1": "MacBook Pro", "MacBookPro11,1": "MacBook Pro",
"MacBookPro11,2": "MacBook Pro", "MacBookPro11,2": "MacBook Pro",
"MacBookPro11,3": "MacBook Pro", "MacBookPro11,3": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro11,4": "MacBook Pro", "MacBookPro11,4": "MacBook Pro",
"MacBookPro11,5": "MacBook Pro", "MacBookPro11,5": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro13,1": "MacBook Pro", "MacBookPro13,1": "MacBook Pro",
"MacBookPro13,2": "MacBook Pro", "MacBookPro13,2": "MacBook Pro",
"MacBookPro13,3": "MacBook Pro", "MacBookPro13,3": "MacBook Pro",
"MacBookPro14,1": "MacBook Pro", "MacBookPro14,1": "MacBook Pro",
"MacBookPro14,2": "MacBook Pro", "MacBookPro14,2": "MacBook Pro",
"MacBookPro14,3": "MacBook Pro", "MacBookPro14,3": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro", "MacBookPro15,2": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
"MacBookPro15,3": "MacBook Pro", "MacBookPro15,3": "MacBook Pro",
"MacBookPro15,4": "MacBook Pro", "MacBookPro15,4": "MacBook Pro",
"MacBookPro16,1": "MacBook Pro", "MacBookPro16,1": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro", "MacBookPro16,3": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
"MacBookPro16,4": "MacBook Pro", "MacBookPro16,4": "MacBook Pro",
"MacBookPro17,1": "MacBook Pro", "MacBookPro17,1": "MacBook Pro",
"MacBookPro18,1": "MacBook Pro",
"MacBookPro18,2": "MacBook Pro",
"MacBookPro18,3": "MacBook Pro", "MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro", "MacBookPro18,4": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro", "MacBookPro18,1": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro", "MacBookPro18,2": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro", "Mac14,7": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro", "Mac14,9": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro", "Mac14,5": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro", "Mac14,10": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro", "Mac14,6": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro", "PowerMac1,2": "Power Macintosh",
"MacBookPro5,5": "MacBook Pro", "PowerMac5,1": "Power Macintosh",
"MacBookPro6,1": "MacBook Pro", "PowerMac7,2": "Power Macintosh",
"MacBookPro6,2": "MacBook Pro", "PowerMac7,3": "Power Macintosh",
"MacBookPro7,1": "MacBook Pro", "PowerMac9,1": "Power Macintosh",
"MacBookPro8,1": "MacBook Pro", "PowerMac11,2": "Power Macintosh",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacPro1,1": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"Macmini9,1": "Mac mini",
"PowerBook1,1": "PowerBook", "PowerBook1,1": "PowerBook",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook3,1": "PowerBook", "PowerBook3,1": "PowerBook",
"PowerBook3,2": "PowerBook", "PowerBook3,2": "PowerBook",
"PowerBook3,3": "PowerBook", "PowerBook3,3": "PowerBook",
"PowerBook3,4": "PowerBook", "PowerBook3,4": "PowerBook",
"PowerBook3,5": "PowerBook", "PowerBook3,5": "PowerBook",
"PowerBook4,1": "iBook", "PowerBook6,1": "PowerBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook5,1": "PowerBook", "PowerBook5,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook5,2": "PowerBook", "PowerBook5,2": "PowerBook",
"PowerBook5,3": "PowerBook", "PowerBook5,3": "PowerBook",
"PowerBook6,4": "PowerBook",
"PowerBook5,4": "PowerBook", "PowerBook5,4": "PowerBook",
"PowerBook5,5": "PowerBook", "PowerBook5,5": "PowerBook",
"PowerBook6,8": "PowerBook",
"PowerBook5,6": "PowerBook", "PowerBook5,6": "PowerBook",
"PowerBook5,7": "PowerBook", "PowerBook5,7": "PowerBook",
"PowerBook5,8": "PowerBook", "PowerBook5,8": "PowerBook",
"PowerBook5,9": "PowerBook", "PowerBook5,9": "PowerBook",
"PowerBook6,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook6,3": "iBook",
"PowerBook6,4": "PowerBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"PowerBook6,8": "PowerBook",
"PowerMac1,1": "Power Macintosh",
"PowerMac1,2": "Power Macintosh",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"PowerMac11,2": "Power Macintosh",
"PowerMac12,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac3,1": "Mac Server",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,4": "eMac",
"PowerMac4,5": "iMac",
"PowerMac5,1": "Power Macintosh",
"PowerMac6,1": "iMac",
"PowerMac6,3": "iMac",
"PowerMac6,4": "eMac",
"PowerMac7,2": "Power Macintosh",
"PowerMac7,3": "Power Macintosh",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac9,1": "Power Macintosh",
"RackMac1,1": "Xserve", "RackMac1,1": "Xserve",
"RackMac1,2": "Xserve", "RackMac1,2": "Xserve",
"RackMac3,1": "Xserve", "RackMac3,1": "Xserve",
"Xserve1,1": "Xserve", "Xserve1,1": "Xserve",
"Xserve2,1": "Xserve", "Xserve2,1": "Xserve",
"Xserve3,1": "Xserve", "Xserve3,1": "Xserve"
"iMac,1": "iMac", }
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,2": "iMac",
"iMac14,3": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,1": "iMac",
"iMac19,2": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,1": "iMac",
"iMac21,2": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,1": "iMac",
"iMac5,2": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMacPro1,1": "iMac Pro"
}
+1 -36
View File
@@ -1,12 +1,6 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { import { clients, db, resources, siteResources } from "@server/db";
clients,
db,
resourcePolicies,
resources,
siteResources
} from "@server/db";
import { randomInt } from "crypto"; import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@@ -113,35 +107,6 @@ 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( export async function getUniqueSiteResourceName(
orgId: string orgId: string
): Promise<string> { ): Promise<string> {
+1 -1
View File
@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types export const primaryDb = db.$primary;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];
-1
View File
@@ -4,4 +4,3 @@ export * from "./safeRead";
export * from "./schema/schema"; export * from "./schema/schema";
export * from "./schema/privateSchema"; export * from "./schema/privateSchema";
export * from "./migrate"; export * from "./migrate";
export { alias } from "drizzle-orm/pg-core";
+4 -3
View File
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { readConfigFile } from "@server/lib/readConfigFile"; import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core"; import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build"; import { build } from "@server/build";
import { db as mainDb } from "./driver"; import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
import { createPool } from "./poolConfig"; import { createPool } from "./poolConfig";
function createLogsDb() { function createLogsDb() {
@@ -63,7 +63,8 @@ function createLogsDb() {
}) })
); );
} else { } else {
const maxReplicaConnections = poolConfig?.max_replica_connections || 20; const maxReplicaConnections =
poolConfig?.max_replica_connections || 20;
for (const conn of replicaConnections) { for (const conn of replicaConnections) {
const replicaPool = createPool( const replicaPool = createPool(
conn.connection_string, conn.connection_string,
@@ -90,4 +91,4 @@ function createLogsDb() {
export const logsDb = createLogsDb(); export const logsDb = createLogsDb();
export default logsDb; export default logsDb;
export const primaryLogsDb = logsDb.$primary; export const primaryLogsDb = logsDb.$primary;
+4 -3
View File
@@ -1,4 +1,5 @@
import { Pool, PoolConfig } from "pg"; import { Pool, PoolConfig } from "pg";
import logger from "@server/logger";
export function createPoolConfig( export function createPoolConfig(
connectionString: string, connectionString: string,
@@ -26,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
pool.on("error", (err) => { pool.on("error", (err) => {
// This catches errors on idle clients in the pool. Without this // This catches errors on idle clients in the pool. Without this
// handler an unexpected disconnect would crash the process. // handler an unexpected disconnect would crash the process.
console.error( logger.error(
`Unexpected error on idle ${label} database client: ${err.message}` `Unexpected error on idle ${label} database client: ${err.message}`
); );
}); });
@@ -35,7 +36,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
// Set a statement timeout on every new connection so a single slow // Set a statement timeout on every new connection so a single slow
// query can't block the pool forever // query can't block the pool forever
client.query("SET statement_timeout = '30s'").catch((err: Error) => { client.query("SET statement_timeout = '30s'").catch((err: Error) => {
console.warn( logger.warn(
`Failed to set statement_timeout on ${label} client: ${err.message}` `Failed to set statement_timeout on ${label} client: ${err.message}`
); );
}); });
@@ -59,4 +60,4 @@ export function createPool(
); );
attachPoolErrorHandlers(pool, label); attachPoolErrorHandlers(pool, label);
return pool; return pool;
} }
+12 -47
View File
@@ -11,7 +11,7 @@ import {
primaryKey, primaryKey,
uniqueIndex uniqueIndex
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { InferSelectModel, sql } from "drizzle-orm"; import { InferSelectModel } from "drizzle-orm";
import { import {
domains, domains,
orgs, orgs,
@@ -207,28 +207,17 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
}); });
export const loginPage = pgTable( export const loginPage = pgTable("loginPage", {
"loginPage", loginPageId: serial("loginPageId").primaryKey(),
{ subdomain: varchar("subdomain"),
loginPageId: serial("loginPageId").primaryKey(), fullDomain: varchar("fullDomain"),
subdomain: varchar("subdomain"), exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
fullDomain: varchar("fullDomain"), onDelete: "set null"
exitNodeId: integer("exitNodeId").references( }),
() => exitNodes.exitNodeId, domainId: varchar("domainId").references(() => domains.domainId, {
{ onDelete: "set null"
onDelete: "set null" })
} });
),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
},
(t) => [
index("idx_loginpage_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
]
);
export const loginPageOrg = pgTable("loginPageOrg", { export const loginPageOrg = pgTable("loginPageOrg", {
loginPageId: integer("loginPageId") loginPageId: integer("loginPageId")
@@ -343,7 +332,6 @@ export const connectionAuditLog = pgTable(
clientId: integer("clientId").references(() => clients.clientId, { clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
@@ -451,8 +439,6 @@ export const eventStreamingDestinations = pgTable(
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc. type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: bigint("lastErrorAt", { mode: "number" }), // epoch ms of last error, null if healthy
createdAt: bigint("createdAt", { mode: "number" }).notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull() updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
} }
@@ -498,7 +484,6 @@ export const alertRules = pgTable("alertRules", {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle" | "resource_toggle"
>() >()
.notNull(), .notNull(),
@@ -580,17 +565,6 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
}); });
export const trialNotifications = pgTable("trialNotifications", {
notificationId: serial("notificationId").primaryKey(),
subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -629,12 +603,3 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRules = InferSelectModel<typeof alertRules>;
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipients = InferSelectModel<
typeof alertEmailRecipients
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
File diff suppressed because it is too large Load Diff
+41 -312
View File
@@ -17,37 +17,22 @@ import {
resourceHeaderAuth, resourceHeaderAuth,
ResourceHeaderAuth, ResourceHeaderAuth,
resourceRules, resourceRules,
resourcePolicyRules,
resources, resources,
roleResources, roleResources,
rolePolicies,
sessions, sessions,
userResources, userResources,
userPolicies,
users, users,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility
resourcePolicies,
resourcePolicyPincode,
ResourcePolicyPincode,
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
} from "@server/db"; } from "@server/db";
import { alias } from "@server/db"; import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import logger from "@server/logger";
export type ResourceWithAuth = { export type ResourceWithAuth = {
resource: Resource | null; resource: Resource | null;
pincode: ResourcePincode | ResourcePolicyPincode | null; pincode: ResourcePincode | null;
password: ResourcePassword | ResourcePolicyPassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org; org: Org;
}; };
@@ -62,44 +47,7 @@ export type UserSessionWithUser = {
export async function getResourceByDomain( export async function getResourceByDomain(
domain: string domain: string
): Promise<ResourceWithAuth | null> { ): Promise<ResourceWithAuth | null> {
// Build wildcard domain variants to match against. const [result] = await db
// For a domain like "me.example.test.com", we want to match:
// - "*.example.test.com" (subdomain wildcard)
// - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent)
const parts = domain.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < parts.length; i++) {
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
const sharedPolicyPincode = alias(
resourcePolicyPincode,
"sharedPolicyPincode"
);
const defaultPolicyPincode = alias(
resourcePolicyPincode,
"defaultPolicyPincode"
);
const sharedPolicyPassword = alias(
resourcePolicyPassword,
"sharedPolicyPassword"
);
const defaultPolicyPassword = alias(
resourcePolicyPassword,
"defaultPolicyPassword"
);
const sharedPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"sharedPolicyHeaderAuth"
);
const defaultPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"defaultPolicyHeaderAuth"
);
const potentialResults = await db
.select() .select()
.from(resources) .from(resources)
.leftJoin( .leftJoin(
@@ -121,133 +69,21 @@ export async function getResourceByDomain(
resources.resourceId resources.resourceId
) )
) )
.leftJoin(
sharedPolicy,
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
sharedPolicyPincode,
eq(
sharedPolicyPincode.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
sharedPolicyPassword,
eq(
sharedPolicyPassword.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
sharedPolicyHeaderAuth,
eq(
sharedPolicyHeaderAuth.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicy,
eq(
defaultPolicy.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
.leftJoin(
defaultPolicyPincode,
eq(
defaultPolicyPincode.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyPassword,
eq(
defaultPolicyPassword.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyHeaderAuth,
eq(
defaultPolicyHeaderAuth.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where( .where(eq(resources.fullDomain, domain))
or( .limit(1);
// Exact match
eq(resources.fullDomain, domain),
// Wildcard match: resource fullDomain is one of the wildcard candidates
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
if (!potentialResults.length) {
return null;
}
// Prefer exact match over wildcard match
const exactMatch = potentialResults.find(
(r) => r.resources?.fullDomain === domain
);
const result = exactMatch ?? potentialResults[0];
if (!result) { if (!result) {
return null; return null;
} }
// If a shared (custom) policy is assigned to the resource, use ONLY
// its values — do not fall back to the default policy. The default
// policy is only consulted when no shared policy is assigned at all.
const hasSharedPolicy = result.sharedPolicy !== null;
const effectivePolicyPincode = hasSharedPolicy
? result.sharedPolicyPincode
: (result.defaultPolicyPincode ?? null);
const effectivePolicyPassword = hasSharedPolicy
? result.sharedPolicyPassword
: (result.defaultPolicyPassword ?? null);
const effectivePolicyHeaderAuth = hasSharedPolicy
? result.sharedPolicyHeaderAuth
: (result.defaultPolicyHeaderAuth ?? null);
const selectedPolicy = hasSharedPolicy
? result.sharedPolicy
: result.defaultPolicy;
const effectiveApplyRules =
selectedPolicy?.applyRules ?? result.resources.applyRules;
const effectiveSSO = selectedPolicy?.sso ?? result.resources.sso;
const effectiveEmailWhitelistEnabled =
selectedPolicy?.emailWhitelistEnabled ??
result.resources.emailWhitelistEnabled;
return { return {
resource: { resource: result.resources,
...result.resources, pincode: result.resourcePincode,
applyRules: effectiveApplyRules, password: result.resourcePassword,
sso: effectiveSSO, headerAuth: result.resourceHeaderAuth,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled headerAuthExtendedCompatibility:
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct result.resourceHeaderAuthExtendedCompatibility,
pincode: effectivePolicyPincode ?? result.resourcePincode,
password: effectivePolicyPassword ?? result.resourcePassword,
headerAuth: effectivePolicyHeaderAuth ?? result.resourceHeaderAuth,
headerAuthExtendedCompatibility: effectivePolicyHeaderAuth
? ({
headerAuthExtendedCompatibilityId: 0,
resourceId: result.resources.resourceId,
extendedCompatibilityIsActivated:
effectivePolicyHeaderAuth.extendedCompatibility
} as ResourceHeaderAuthExtendedCompatibility)
: result.resourceHeaderAuthExtendedCompatibility,
applyRules: effectiveApplyRules,
sso: effectiveSSO,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled,
org: result.orgs org: result.orgs
}; };
} }
@@ -287,165 +123,58 @@ export async function getRoleName(roleId: number): Promise<string | null> {
} }
/** /**
* Check if role has access to resource (direct or via resource policy) * Check if role has access to resource
*/ */
export async function getRoleResourceAccess( export async function getRoleResourceAccess(
resourceId: number, resourceId: number,
roleIds: number[] roleIds: number[]
) { ) {
const [direct, viaPolicies] = await Promise.all([ const roleResourceAccess = await db
db .select()
.select() .from(roleResources)
.from(roleResources) .where(
.where( and(
and( eq(roleResources.resourceId, resourceId),
eq(roleResources.resourceId, resourceId), inArray(roleResources.roleId, roleIds)
inArray(roleResources.roleId, roleIds)
)
),
db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
rolePolicies.resourcePolicyId
)
)
)
) )
.where( );
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
]);
const combined = [...direct, ...viaPolicies]; return roleResourceAccess.length > 0 ? roleResourceAccess : null;
return combined.length > 0 ? combined : null;
} }
/** /**
* Check if user has access to resource (direct or via resource policy) * Check if user has direct access to resource
*/ */
export async function getUserResourceAccess( export async function getUserResourceAccess(
userId: string, userId: string,
resourceId: number resourceId: number
) { ) {
const [direct, viaPolicies] = await Promise.all([ const userResourceAccess = await db
db .select()
.select() .from(userResources)
.from(userResources) .where(
.where( and(
and( eq(userResources.userId, userId),
eq(userResources.userId, userId), eq(userResources.resourceId, resourceId)
eq(userResources.resourceId, resourceId)
)
) )
.limit(1), )
db .limit(1);
.select({
userId: userPolicies.userId,
resourcePolicyId: userPolicies.resourcePolicyId
})
.from(userPolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
userPolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
userPolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
eq(userPolicies.userId, userId)
)
)
.limit(1)
]);
return direct[0] ?? viaPolicies[0] ?? null; return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
} }
/** /**
* Get resource rules for a given resource (direct and via resource policy) * Get resource rules for a given resource
*/ */
export async function getResourceRules( export async function getResourceRules(
resourceId: number resourceId: number
): Promise<ResourceRule[]> { ): Promise<ResourceRule[]> {
const [directRules, policyRules] = await Promise.all([ const rules = await db
db .select()
.select() .from(resourceRules)
.from(resourceRules) .where(eq(resourceRules.resourceId, resourceId));
.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,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
resourcePolicyRules.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
resourcePolicyRules.resourcePolicyId
)
)
)
)
.where(eq(resources.resourceId, resourceId))
]);
const maxDirectPriority = directRules.reduce( return rules;
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
} }
/** /**
+1 -25
View File
@@ -13,30 +13,6 @@ bootstrapVolume();
function createDb() { function createDb() {
const sqlite = new Database(location); const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL");
}
// No busy_timeout pragma: better-sqlite3 already arms
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
// (lib/database.js), so an explicit pragma is redundant.
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
// fresh statement per query (no statement cache), so statements cannot
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
return DrizzleSqlite(sqlite, { return DrizzleSqlite(sqlite, {
schema schema
}); });
@@ -47,7 +23,7 @@ export default db;
export const primaryDb = db; export const primaryDb = db;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];
export const DB_TYPE: "pg" | "sqlite" = "sqlite"; export const DB_TYPE: "pg" | "sqlite" = "sqlite";
function checkFileExists(filePath: string): boolean { function checkFileExists(filePath: string): boolean {
-1
View File
@@ -4,4 +4,3 @@ export * from "./safeRead";
export * from "./schema/schema"; export * from "./schema/schema";
export * from "./schema/privateSchema"; export * from "./schema/privateSchema";
export * from "./migrate"; export * from "./migrate";
export { alias } from "drizzle-orm/sqlite-core";
+11 -58
View File
@@ -21,9 +21,6 @@ import {
targetHealthCheck, targetHealthCheck,
users users
} from "./schema"; } from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", { export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }), certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -332,7 +329,6 @@ export const connectionAuditLog = sqliteTable(
clientId: integer("clientId").references(() => clients.clientId, { clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
@@ -429,25 +425,15 @@ export const eventStreamingDestinations = sqliteTable(
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }) sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false),
.notNull() sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false),
.default(false), sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false),
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }) sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false),
.notNull()
.default(false),
sendActionLogs: integer("sendActionLogs", { mode: "boolean" })
.notNull()
.default(false),
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" })
.notNull()
.default(false),
type: text("type").notNull(), // e.g. "http", "kafka", etc. type: text("type").notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: integer("lastErrorAt"), // epoch ms of last error, null if healthy
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull() updatedAt: integer("updatedAt").notNull()
} }
@@ -490,19 +476,14 @@ export const alertRules = sqliteTable("alertRules", {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle" | "resource_toggle"
>() >()
.notNull(), .notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300), cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), allSites: integer("allSites", { mode: "boolean" }).notNull().default(false),
allHealthChecks: integer("allHealthChecks", { mode: "boolean" }) allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false),
.notNull() allResources: integer("allResources", { mode: "boolean" }).notNull().default(false),
.default(false),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
lastTriggeredAt: integer("lastTriggeredAt"), lastTriggeredAt: integer("lastTriggeredAt"),
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull() updatedAt: integer("updatedAt").notNull()
@@ -550,44 +531,23 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
emailActionId: integer("emailActionId") emailActionId: integer("emailActionId")
.notNull() .notNull()
.references(() => alertEmailActions.emailActionId, { .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
onDelete: "cascade" userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
}), roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
email: text("email") email: text("email")
}); });
export const alertWebhookActions = sqliteTable("alertWebhookActions", { export const alertWebhookActions = sqliteTable("alertWebhookActions", {
webhookActionId: integer("webhookActionId").primaryKey({ webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }),
autoIncrement: true
}),
alertRuleId: integer("alertRuleId") alertRuleId: integer("alertRuleId")
.notNull() .notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }), .references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
webhookUrl: text("webhookUrl").notNull(), webhookUrl: text("webhookUrl").notNull(),
config: text("config"), // encrypted JSON with auth config (authType, credentials) config: text("config"), // encrypted JSON with auth config (authType, credentials)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSentAt: integer("lastSentAt") lastSentAt: integer("lastSentAt")
}); });
export const trialNotifications = sqliteTable("trialNotifications", {
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: integer("sentAt").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -620,10 +580,3 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRule = InferSelectModel<typeof alertRules>;
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
+32 -317
View File
@@ -62,13 +62,7 @@ export const orgs = sqliteTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId"), billingOrgId: text("billingOrgId")
settingsEnableGlobalNewtAutoUpdate: integer(
"settingsEnableGlobalNewtAutoUpdate",
{ mode: "boolean" }
)
.notNull()
.default(false)
}); });
export const userDomains = sqliteTable("userDomains", { export const userDomains = sqliteTable("userDomains", {
@@ -122,29 +116,11 @@ export const sites = sqliteTable("sites", {
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
})
.notNull()
.default(false),
status: text("status").$type<"pending" | "approved">().default("approved") status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const resources = sqliteTable("resources", { export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), 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 }) resourceGuid: text("resourceGuid", { length: 36 })
.unique() .unique()
.notNull() .notNull()
@@ -165,12 +141,16 @@ export const resources = sqliteTable("resources", {
blockAccess: integer("blockAccess", { mode: "boolean" }) blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
sso: integer("sso", { mode: "boolean" }), emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
emailWhitelistEnabled: integer("emailWhitelistEnabled", { .notNull()
mode: "boolean" .default(false),
}), applyRules: integer("applyRules", { mode: "boolean" })
applyRules: integer("applyRules", { mode: "boolean" }), .notNull()
.default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" }) stickySession: integer("stickySession", { mode: "boolean" })
.notNull() .notNull()
@@ -186,6 +166,7 @@ export const resources = sqliteTable("resources", {
.notNull() .notNull()
.default(false), .default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", { maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean" mode: "boolean"
}) })
@@ -197,108 +178,9 @@ export const resources = sqliteTable("resources", {
maintenanceTitle: text("maintenanceTitle"), maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"), maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath")
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
}); });
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }), targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -320,12 +202,7 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100), priority: integer("priority").notNull().default(100)
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
}); });
export const targetHealthCheck = sqliteTable("targetHealthCheck", { export const targetHealthCheck = sqliteTable("targetHealthCheck", {
@@ -340,11 +217,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId") siteId: integer("siteId").references(() => sites.siteId, {
.references(() => sites.siteId, { onDelete: "cascade"
onDelete: "cascade" }).notNull(),
})
.notNull(),
name: text("name"), name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" }) hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull() .notNull()
@@ -404,11 +279,11 @@ export const siteResources = sqliteTable("siteResources", {
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination"), // ip, cidr, hostname destination: text("destination").notNull(), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"), alias: text("alias"),
aliasAddress: text("aliasAddress"), aliasAddress: text("aliasAddress"),
@@ -418,11 +293,8 @@ export const siteResources = sqliteTable("siteResources", {
.notNull() .notNull()
.default(false), .default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">() .$type<"site" | "remote">()
.default("site"), .default("site"),
domainId: text("domainId").references(() => domains.domainId, { domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"
@@ -1035,47 +907,6 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull() 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( export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
"resourceHeaderAuthExtendedCompatibility", "resourceHeaderAuthExtendedCompatibility",
{ {
@@ -1104,7 +935,6 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId") resourceId: integer("resourceId")
.notNull() .notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
path: text("path"),
tokenHash: text("tokenHash").notNull(), tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(), sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"), expiresAt: integer("expiresAt"),
@@ -1151,24 +981,6 @@ export const resourceSessions = sqliteTable("resourceSessions", {
onDelete: "cascade" onDelete: "cascade"
} }
), ),
policyPasswordId: integer("policyPasswordId").references(
() => resourcePolicyPassword.passwordId,
{
onDelete: "cascade"
}
),
policyPincodeId: integer("policyPincodeId").references(
() => resourcePolicyPincode.pincodeId,
{
onDelete: "cascade"
}
),
policyWhitelistId: integer("policyWhitelistId").references(
() => resourcePolicyWhiteList.whitelistId,
{
onDelete: "cascade"
}
),
issuedAt: integer("issuedAt") issuedAt: integer("issuedAt")
}); });
@@ -1209,79 +1021,6 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull() 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" | "COUNTRY" | "ASN" | "REGION">()
.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", { export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }), keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(), key: text("key").notNull(),
@@ -1455,30 +1194,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false) complete: integer("complete", { mode: "boolean" }).notNull().default(false)
}); });
export const statusHistory = sqliteTable( export const statusHistory = sqliteTable("statusHistory", {
"statusHistory", id: integer("id").primaryKey({ autoIncrement: true }),
{ entityType: text("entityType").notNull(), // "site" | "healthCheck"
id: integer("id").primaryKey({ autoIncrement: true }), entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
entityType: text("entityType").notNull(), // "site" | "healthCheck" orgId: text("orgId")
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId .notNull()
orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }),
.notNull() status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
.references(() => orgs.orgId, { onDelete: "cascade" }), timestamp: integer("timestamp").notNull(), // unix epoch seconds
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks }, (table) => [
timestamp: integer("timestamp").notNull() // unix epoch seconds index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
}, index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
(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 Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1548,16 +1276,3 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
export type ResourcePolicyPassword = InferSelectModel<
typeof resourcePolicyPassword
>;
export type ResourcePolicyHeaderAuth = InferSelectModel<
typeof resourcePolicyHeaderAuth
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
+17 -67
View File
@@ -23,7 +23,6 @@ export type AlertEventType =
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
export type AlertNotificationProps = { export type AlertNotificationProps = {
@@ -37,8 +36,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: string; heading: string;
previewText: string; previewText: string;
summary: string; summary: string;
statusLabel: string | null; statusLabel: string;
statusColor: string | null; statusColor: string;
} { } {
switch (eventType) { switch (eventType) {
case "site_online": case "site_online":
@@ -64,8 +63,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Site Status Changed", heading: "Site Status Changed",
previewText: "A site in your organization has changed status.", previewText: "A site in your organization has changed status.",
summary: "A site in your organization has changed status.", summary: "A site in your organization has changed status.",
statusLabel: null, statusLabel: "Status Changed",
statusColor: null statusColor: "#f59e0b"
}; };
case "health_check_healthy": case "health_check_healthy":
return { return {
@@ -94,8 +93,8 @@ function getEventMeta(eventType: AlertEventType): {
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
summary: summary:
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
statusLabel: null, statusLabel: "Status Changed",
statusColor: null statusColor: "#f59e0b"
}; };
case "resource_healthy": case "resource_healthy":
return { return {
@@ -115,23 +114,14 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Unhealthy", statusLabel: "Unhealthy",
statusColor: "#dc2626" statusColor: "#dc2626"
}; };
case "resource_degraded":
return {
heading: "Resource Degraded",
previewText: "A resource in your organization is degraded.",
summary:
"A resource in your organization is currently degraded.",
statusLabel: "Degraded",
statusColor: "#dc2626"
};
case "resource_toggle": case "resource_toggle":
return { return {
heading: "Resource Status Changed", heading: "Resource Status Changed",
previewText: previewText:
"A resource in your organization has changed status.", "A resource in your organization has changed status.",
summary: "A resource in your organization has changed status.", summary: "A resource in your organization has changed status.",
statusLabel: null, statusLabel: "Status Changed",
statusColor: null statusColor: "#f59e0b"
}; };
default: default:
return { return {
@@ -145,31 +135,11 @@ function getEventMeta(eventType: AlertEventType): {
} }
} }
function resolveToggleStatus(status: unknown): {
label: string;
color: string;
} {
switch (String(status).toLowerCase()) {
case "online":
return { label: "Online", color: "#16a34a" };
case "offline":
return { label: "Offline", color: "#dc2626" };
case "healthy":
return { label: "Healthy", color: "#16a34a" };
case "unhealthy":
return { label: "Unhealthy", color: "#dc2626" };
case "degraded":
return { label: "Degraded", color: "#dc2626" };
default:
return { label: String(status ?? "Unknown"), color: "#f59e0b" };
}
}
function formatDataItems( function formatDataItems(
data: Record<string, unknown> data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] { ): { label: string; value: React.ReactNode }[] {
return Object.entries(data) return Object.entries(data)
.filter(([key]) => key !== "orgId" && key !== "status") .filter(([key]) => key !== "orgId")
.map(([key, value]) => ({ .map(([key, value]) => ({
label: key label: key
.replace(/([A-Z])/g, " $1") .replace(/([A-Z])/g, " $1")
@@ -184,36 +154,16 @@ export const AlertNotification = (props: AlertNotificationProps) => {
const meta = getEventMeta(eventType); const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data); const dataItems = formatDataItems(data);
const isToggle =
eventType === "site_toggle" ||
eventType === "health_check_toggle" ||
eventType === "resource_toggle";
const resolvedStatus = isToggle
? resolveToggleStatus(data.status)
: meta.statusLabel != null
? { label: meta.statusLabel, color: meta.statusColor! }
: null;
const allItems: { label: string; value: React.ReactNode }[] = [ const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId }, { label: "Organization", value: orgId },
...(resolvedStatus != null {
? [ label: "Status",
{ value: (
label: "Status", <span style={{ color: meta.statusColor, fontWeight: 600 }}>
value: ( {meta.statusLabel}
<span </span>
style={{ )
color: resolvedStatus.color, },
fontWeight: 600
}}
>
{resolvedStatus.label}
</span>
)
}
]
: []),
{ label: "Time", value: new Date().toUTCString() }, { label: "Time", value: new Date().toUTCString() },
...dataItems ...dataItems
]; ];
@@ -64,7 +64,7 @@ export const NotifyTrialExpiring = ({
<EmailText> <EmailText>
Some features and resources may now be Some features and resources may now be
restricted. To restore full restricted or disconnected. To restore full
access and continue using all the features access and continue using all the features
you had during your trial, please upgrade to you had during your trial, please upgrade to
a paid plan. a paid plan.
@@ -85,7 +85,7 @@ export const NotifyTrialExpiring = ({
<strong>{orgName}</strong> will end on{" "} <strong>{orgName}</strong> will end on{" "}
<strong>{trialEndsAt}</strong> <strong>{trialEndsAt}</strong>
{isLastDay {isLastDay
? " - that's tomorrow!" ? " that's tomorrow!"
: `, in ${daysRemaining} days`} : `, in ${daysRemaining} days`}
. .
</EmailText> </EmailText>
@@ -93,7 +93,8 @@ export const NotifyTrialExpiring = ({
<EmailText> <EmailText>
After your trial ends, your account will be After your trial ends, your account will be
moved to the free plan and some moved to the free plan and some
functionality may be restricted. functionality may be restricted or your
sites may disconnect.
</EmailText> </EmailText>
<EmailText> <EmailText>
+1 -3
View File
@@ -1,5 +1,5 @@
#! /usr/bin/env node #! /usr/bin/env node
import "./extendZod"; import "./extendZod.ts";
import { runSetupFunctions } from "./setup"; import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";
@@ -24,7 +24,6 @@ import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService"; import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
async function startServers() { async function startServers() {
await setHostMeta(); await setHostMeta();
@@ -42,7 +41,6 @@ async function startServers() {
initLogCleanupInterval(); initLogCleanupInterval();
initAcmeCertSync(); initAcmeCertSync();
startRebuildQueueProcessor();
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();
+3 -11
View File
@@ -152,19 +152,11 @@ function getOpenApiDocumentation() {
if (!hasExistingResponses) { if (!hasExistingResponses) {
def.route.responses = { def.route.responses = {
"200": { "*": {
description: "Successful response", description: "",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({})
data: z
.record(z.string(), z.any())
.nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
} }
} }
} }
+7 -289
View File
@@ -1,301 +1,19 @@
import logger from "@server/logger"; // stub
import { processAlerts } from "#dynamic/lib/alerts";
import {
db,
statusHistory,
targetHealthCheck,
targets,
resources,
Transaction,
logsDb
} from "@server/db";
import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import {
fireResourceDegradedAlert,
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceUnknownAlert
} from "./resourceEvents";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `health_check_healthy` alert for the given health check.
*
* Call this after a previously-failing health check has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckHealthyAlert( export async function fireHealthCheckHealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, healthCheckId: number,
healthCheckName?: string | null, healthCheckName?: string,
healthCheckTargetId?: number | null, extra?: Record<string, unknown>
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { return;
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_healthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "healthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
} }
/**
* Fire a `health_check_unhealthy` alert for the given health check.
*
* Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the health check.
* @param healthCheckId - Numeric primary key of the health check.
* @param healthCheckName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireHealthCheckUnhealthyAlert( export async function fireHealthCheckUnhealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, healthCheckId: number,
healthCheckName?: string | null, healthCheckName?: string,
healthCheckTargetId?: number | null, extra?: Record<string, unknown>
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { return;
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_unhealthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
} catch (err) {
logger.error(
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
async function handleResource(
orgId: string,
healthCheckTargetId?: number | null,
send: boolean = true,
trx: Transaction | typeof db = db
) {
if (!healthCheckTargetId) {
return;
}
// we have targets lets get them
const [target] = await trx
.select()
.from(targets)
.where(eq(targets.targetId, healthCheckTargetId))
.limit(1);
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resource.resourceId));
const monitoredTargets = otherTargets.filter(
(t) => t.hcHealth !== "unknown"
);
let health = "healthy";
const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every(
(t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
if (allUnknown) {
logger.debug(
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
);
health = "unknown";
} else if (allHealthy) {
health = "healthy";
} else if (allUnhealthy) {
logger.debug(
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
);
health = "unhealthy";
} else {
logger.debug(
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
);
health = "degraded";
}
if (health != resource.health) {
// it changed
await trx
.update(resources)
.set({ health })
.where(eq(resources.resourceId, resource.resourceId));
if (health === "unknown") {
await fireResourceUnknownAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "healthy") {
await fireResourceHealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "degraded") {
await fireResourceDegradedAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
}
}
} }
+7 -230
View File
@@ -1,243 +1,20 @@
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import { db, logsDb, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `resource_healthy` alert for the given resource.
*
* Call this after a previously-unhealthy resource has recovered so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceHealthyAlert( export async function fireResourceHealthyAlert(
orgId: string, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>
send: boolean = true, ): Promise<void> {}
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_healthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "healthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unhealthy` alert for the given resource.
*
* Call this after a resource has been detected as unhealthy so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnhealthyAlert( export async function fireResourceUnhealthyAlert(
orgId: string, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>
send: boolean = true, ): Promise<void> {}
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) { export async function fireResourceToggleAlert(
return;
}
await processAlerts({
eventType: "resource_unhealthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_degraded` alert for the given resource.
*
* Call this after a resource has been detected as degraded so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceDegradedAlert(
orgId: string, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>
send: boolean = true, ): Promise<void> {}
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_degraded",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "degraded",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unknown` alert for the given resource.
*
* Call this when all health checks on a resource are disabled so that the
* resource status transitions to unknown.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnknownAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unknown",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
+5 -142
View File
@@ -1,156 +1,19 @@
import logger from "@server/logger"; // stub
import { processAlerts } from "#dynamic/lib/alerts";
import {
db,
logsDb,
statusHistory,
targetHealthCheck,
Transaction
} from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Fire a `site_online` alert for the given site.
*
* Call this after the site has been confirmed reachable / connected so that
* any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOnlineAlert( export async function fireSiteOnlineAlert(
orgId: string, orgId: string,
siteId: number, siteId: number,
siteName?: string, siteName?: string,
extra?: Record<string, unknown>, extra?: Record<string, unknown>
trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { return;
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
await processAlerts({
eventType: "site_online",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "online",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
err
);
}
} }
/**
* Fire a `site_offline` alert for the given site.
*
* Call this after the site has been detected as unreachable / disconnected so
* that any matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the site.
* @param siteId - Numeric primary key of the site.
* @param siteName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireSiteOfflineAlert( export async function fireSiteOfflineAlert(
orgId: string, orgId: string,
siteId: number, siteId: number,
siteName?: string, siteName?: string,
extra?: Record<string, unknown>, extra?: Record<string, unknown>
trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { return;
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)
.set({ hcHealth: "unhealthy" })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
eq(targetHealthCheck.siteId, siteId),
eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
)
)
.returning();
for (const healthCheck of unhealthyHealthChecks) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
);
await fireHealthCheckUnhealthyAlert(
healthCheck.orgId,
healthCheck.targetHealthCheckId,
healthCheck.name,
healthCheck.targetId, // for the resource if we have one
undefined,
true,
trx
);
}
await processAlerts({
eventType: "site_offline",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "offline",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
err
);
}
} }
-1
View File
@@ -1,4 +1,3 @@
export * from "./events/siteEvents"; export * from "./events/siteEvents";
export * from "./events/healthCheckEvents"; export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents"; export * from "./events/resourceEvents";
export * from "./processAlerts";
-5
View File
@@ -1,5 +0,0 @@
import { AlertContext } from "@server/routers/alertRule/types";
export async function processAlerts(context: AlertContext): Promise<void> {
return;
}
+2 -3
View File
@@ -1,9 +1,8 @@
export async function getOrgTierData( export async function getOrgTierData(
orgId: string orgId: string
): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> { ): Promise<{ tier: string | null; active: boolean }> {
const tier = null; const tier = null;
const active = false; const active = false;
const isTrial = false;
return { tier, active, isTrial }; return { tier, active };
} }
+2 -2
View File
@@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = {
export const tier2LimitSet: LimitSet = { export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: { [FeatureId.USERS]: {
value: 50, value: 100,
description: "Team limit" description: "Team limit"
}, },
[FeatureId.SITES]: { [FeatureId.SITES]: {
@@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = {
export const tier3LimitSet: LimitSet = { export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: { [FeatureId.USERS]: {
value: 250, value: 500,
description: "Business limit" description: "Business limit"
}, },
[FeatureId.SITES]: { [FeatureId.SITES]: {
+7 -15
View File
@@ -16,22 +16,17 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam",
FullRbac = "fullRbac", FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem", // handle downgrade by disabling SIEM integrations SIEM = "siem", // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules"
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
@@ -62,15 +57,12 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise" "enterprise"
], ],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"], [TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"]
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
}; };
+3 -5
View File
@@ -12,7 +12,7 @@ import {
import { FeatureId, getFeatureMeterId } from "./features"; import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger"; import logger from "@server/logger";
import { build } from "@server/build"; import { build } from "@server/build";
import { regionalCache as cache } from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache";
export function noop() { export function noop() {
if (build !== "saas") { if (build !== "saas") {
@@ -22,6 +22,7 @@ export function noop() {
} }
export class UsageService { export class UsageService {
constructor() { constructor() {
if (noop()) { if (noop()) {
return; return;
@@ -56,10 +57,7 @@ export class UsageService {
try { try {
let usage; let usage;
if (transaction) { if (transaction) {
const orgIdToUse = await this.getBillingOrg( const orgIdToUse = await this.getBillingOrg(orgId, transaction);
orgId,
transaction
);
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgIdToUse, orgIdToUse,
featureId, featureId,
+191 -80
View File
@@ -3,37 +3,29 @@ import {
newts, newts,
blueprints, blueprints,
Blueprint, Blueprint,
Site,
siteResources, siteResources,
roleSiteResources, roleSiteResources,
userSiteResources, userSiteResources,
clientSiteResources clientSiteResources
} from "@server/db"; } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema } from "./types";
import { import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
PublicResourcesResults,
updatePublicResources
} from "./publicResources";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, isNotNull } from "drizzle-orm"; import { eq, and, isNotNull } from "drizzle-orm";
import { import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
addTargets as addProxyTargets, import { addTargets as addClientTargets } from "@server/routers/client/targets";
sendBrowserGatewayTargets
} from "@server/routers/newt/targets";
import { import {
ClientResourcesResults, ClientResourcesResults,
updatePrivateResources updateClientResources
} from "./privateResources"; } from "./clientResources";
import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types"; import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { generateName } from "@server/db/names"; import { faker } from "@faker-js/faker";
import { import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
handleMessagingForUpdatedSiteResource, import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
@@ -50,38 +42,40 @@ export async function applyBlueprint({
name, name,
source = "API" source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> { }: ApplyBlueprintArgs): Promise<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false; let blueprintSucceeded: boolean = false;
let blueprintMessage = ""; let blueprintMessage: string;
let error: any | null = null; let error: any | null = null;
try { try {
const validationResult = ConfigSchema.safeParse(configData); let proxyResourcesResults: ProxyResourcesResults = [];
if (!validationResult.success) { let clientResourcesResults: ClientResourcesResults = [];
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx); proxyResourcesResults = await updateProxyResources(
publicResourcesResults = await updatePublicResources(
orgId, orgId,
config, config,
trx, trx,
siteId siteId
); );
privateResourcesResults = await updatePrivateResources( clientResourcesResults = await updateClientResources(
orgId, orgId,
config, config,
trx, trx,
siteId siteId
); );
logger.debug(
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information // We need to update the targets on the newts from the successfully updated information
for (const result of publicResourcesResults) { for (const result of proxyResourcesResults) {
for (const target of result.targetsToUpdate) { for (const target of result.targetsToUpdate) {
const [site] = await trx const [site] = await trx
.select() .select()
@@ -108,63 +102,178 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId (hc) => hc.targetId === target.targetId
); );
if (["http", "tcp", "udp"].includes(target.mode)) { await addProxyTargets(
await addProxyTargets( site.newt.newtId,
site.newt.newtId, [target],
[target], matchingHealthcheck ? [matchingHealthcheck] : [],
matchingHealthcheck result.proxyResource.protocol,
? [matchingHealthcheck] site.newt.version
: [], );
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
} }
} }
} }
logger.debug( logger.debug(
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}` `Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
); );
// We need to update the targets on the newts from the successfully updated information // We need to update the targets on the newts from the successfully updated information
for (const result of privateResourcesResults) { for (const result of clientResourcesResults) {
rebuildClientAssociationsFromSiteResource( if (
result.newSiteResource result.oldSiteResource &&
) JSON.stringify(result.newSites?.sort()) !==
.then(() => JSON.stringify(result.oldSites?.sort())
waitForSiteResourceRebuildIdle( ) {
result.newSiteResource.siteResourceId // query existing associations
const existingRoleIds = await trx
.select()
.from(roleSiteResources)
.where(
eq(
roleSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
) )
) .then((rows) => rows.map((row) => row.roleId));
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
logger.debug( const existingUserIds = await trx
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}` .select()
); .from(userSiteResources)
.where(
eq(
userSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
.then((rows) => rows.map((row) => row.userId));
const existingClientIds = await trx
.select()
.from(clientSiteResources)
.where(
eq(
clientSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
.then((rows) => rows.map((row) => row.clientId));
// delete the existing site resource
await trx
.delete(siteResources)
.where(
and(
eq(
siteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
);
await rebuildClientAssociationsFromSiteResource(
result.oldSiteResource,
trx
);
const [insertedSiteResource] = await trx
.insert(siteResources)
.values({
...result.newSiteResource
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
//////////////////// update the associations ////////////////////
if (existingRoleIds.length > 0) {
await trx.insert(roleSiteResources).values(
existingRoleIds.map((roleId) => ({
roleId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingUserIds.length > 0) {
await trx.insert(userSiteResources).values(
existingUserIds.map((userId) => ({
userId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingClientIds.length > 0) {
await trx.insert(clientSiteResources).values(
existingClientIds.map((clientId) => ({
clientId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
insertedSiteResource,
trx
);
} else {
let good = true;
for (const newSite of result.newSites) {
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, newSite.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (!site) {
logger.debug(
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
good = false;
break;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
);
}
if (!good) {
continue;
}
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.newSites.map((site) => ({
siteId: site.siteId,
orgId: result.newSiteResource.orgId
})),
trx
);
}
// await addClientTargets(
// site.newt.newtId,
// result.resource.destination,
// result.resource.destinationPort,
// result.resource.protocol,
// result.resource.proxyPort
// );
}
}); });
blueprintSucceeded = true; blueprintSucceeded = true;
@@ -182,7 +291,9 @@ export async function applyBlueprint({
.insert(blueprints) .insert(blueprints)
.values({ .values({
orgId, orgId,
name: name ?? generateName(), name:
name ??
`${faker.word.adjective()} ${faker.word.adjective()} ${faker.word.noun()}`,
contents: stringifyYaml(configData), contents: stringifyYaml(configData),
createdAt: Math.floor(Date.now() / 1000), createdAt: Math.floor(Date.now() / 1000),
succeeded: blueprintSucceeded, succeeded: blueprintSucceeded,
@@ -1,56 +1,10 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { processContainerLabels } from "./parseDockerContainers"; import { processContainerLabels } from "./parseDockerContainers";
import { applyBlueprint } from "./applyBlueprint"; import { applyBlueprint } from "./applyBlueprint";
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
import { db, sites } from "@server/db"; import { db, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
type BlueprintResult = ReturnType<typeof processContainerLabels>;
function filterInvalidResources(blueprint: BlueprintResult): {
skippedCount: number;
skippedKeys: string[];
} {
const skippedKeys: string[] = [];
for (const section of ["proxy-resources", "public-resources"] as const) {
const resources = blueprint[section];
for (const [key, value] of Object.entries(resources)) {
const result = PublicResourceSchema.safeParse(value);
if (!result.success) {
const errors = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
logger.warn(
`Skipping invalid Docker ${section} "${key}": ${errors}`
);
delete resources[key];
skippedKeys.push(`${section}.${key}`);
}
}
}
for (const section of ["client-resources", "private-resources"] as const) {
const resources = blueprint[section];
for (const [key, value] of Object.entries(resources)) {
const result = PrivateResourceSchema.safeParse(value);
if (!result.success) {
const errors = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
logger.warn(
`Skipping invalid Docker ${section} "${key}": ${errors}`
);
delete resources[key];
skippedKeys.push(`${section}.${key}`);
}
}
}
return { skippedCount: skippedKeys.length, skippedKeys };
}
export async function applyNewtDockerBlueprint( export async function applyNewtDockerBlueprint(
siteId: number, siteId: number,
newtId: string, newtId: string,
@@ -67,27 +21,17 @@ export async function applyNewtDockerBlueprint(
return; return;
} }
let skippedCount = 0; // logger.debug(`Applying Docker blueprint to site: ${siteId}`);
let skippedKeys: string[] = []; // logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
try { try {
// Some Newt clients can report null/undefined containers when Docker const blueprint = processContainerLabels(containers);
// labels are unavailable. Treat that as an empty blueprint payload.
const safeContainers = Array.isArray(containers) ? containers : [];
const blueprint = processContainerLabels(safeContainers);
logger.debug( logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
);
const filterResult = filterInvalidResources(blueprint); // make sure this is not an empty object
skippedCount = filterResult.skippedCount; if (isEmptyObject(blueprint)) {
skippedKeys = filterResult.skippedKeys; return;
if (skippedCount > 0) {
logger.warn(
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
);
} }
if ( if (
@@ -96,15 +40,6 @@ export async function applyNewtDockerBlueprint(
isEmptyObject(blueprint["public-resources"]) && isEmptyObject(blueprint["public-resources"]) &&
isEmptyObject(blueprint["private-resources"]) isEmptyObject(blueprint["private-resources"])
) { ) {
if (skippedCount > 0) {
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
success: false,
message: `All resources were invalid and skipped: ${skippedKeys.join(", ")}`
}
});
}
return; return;
} }
@@ -131,10 +66,7 @@ export async function applyNewtDockerBlueprint(
type: "newt/blueprint/results", type: "newt/blueprint/results",
data: { data: {
success: true, success: true,
message: message: "Config updated successfully"
skippedCount > 0
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
: "Config updated successfully"
} }
}); });
} }
@@ -3,7 +3,6 @@ import {
clientSiteResources, clientSiteResources,
domains, domains,
orgDomains, orgDomains,
roleActions,
roles, roles,
roleSiteResources, roleSiteResources,
Site, Site,
@@ -20,11 +19,8 @@ import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
async function getDomainForSiteResource( async function getDomainForSiteResource(
siteResourceId: number | undefined, siteResourceId: number | undefined,
@@ -105,7 +101,7 @@ export type ClientResourcesResults = {
oldSites: { siteId: number }[]; oldSites: { siteId: number }[];
}[]; }[];
export async function updatePrivateResources( export async function updateClientResources(
orgId: string, orgId: string,
config: Config, config: Config,
trx: Transaction, trx: Transaction,
@@ -116,30 +112,6 @@ export async function updatePrivateResources(
for (const [resourceNiceId, resourceData] of Object.entries( for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"] config["client-resources"]
)) { )) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx const [existingResource] = await trx
.select() .select()
.from(siteResources) .from(siteResources)
@@ -153,28 +125,47 @@ export async function updatePrivateResources(
const existingSiteIds = existingResource?.networkId const existingSiteIds = existingResource?.networkId
? await trx ? await trx
.select({ siteId: siteNetworks.siteId }) .select({ siteId: sites.siteId })
.from(siteNetworks) .from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId)) .where(eq(siteNetworks.networkId, existingResource.networkId))
: []; : [];
const allSites: { siteId: number }[] = []; let allSites: { siteId: number }[] = [];
if (resourceData.site) { if (resourceData.site) {
// Look up site by niceId let siteSingle;
const [siteSingle] = await trx const resourceSiteId = resourceData.site;
.select({ siteId: sites.siteId })
.from(sites) if (resourceSiteId) {
.where( // Look up site by niceId
and( [siteSingle] = await trx
eq(sites.niceId, resourceData.site), .select({ siteId: sites.siteId })
eq(sites.orgId, orgId) .from(sites)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
) )
) .limit(1);
.limit(1); } else if (siteId) {
if (siteSingle) { // Use the provided siteId directly, but verify it belongs to the org
allSites.push(siteSingle); [siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else {
throw new Error(`Target site is required`);
} }
if (!siteSingle) {
throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}`
);
}
allSites.push(siteSingle);
} }
if (resourceData.sites) { if (resourceData.sites) {
@@ -189,31 +180,15 @@ export async function updatePrivateResources(
) )
) )
.limit(1); .limit(1);
if (site) { if (!site) {
allSites.push(site); throw new Error(
`Site not found: ${siteId} in org ${orgId}`
);
} }
allSites.push(site);
} }
} }
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
}
if (allSites.length === 0) {
throw new Error(
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
);
}
if (existingResource) { if (existingResource) {
let domainInfo: let domainInfo:
| { subdomain: string | null; domainId: string } | { subdomain: string | null; domainId: string }
@@ -240,24 +215,12 @@ export async function updatePrivateResources(
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: disableIcmp: resourceData["disable-icmp"],
resourceData["disable-icmp"] || tcpPortRangeString: resourceData["tcp-ports"],
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false udpPortRangeString: resourceData["udp-ports"],
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null, fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null, subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null, domainId: domainInfo ? domainInfo.domainId : null
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
}) })
.where( .where(
eq( eq(
@@ -364,7 +327,8 @@ export async function updatePrivateResources(
} }
if (resourceData.roles.length > 0) { if (resourceData.roles.length > 0) {
const existingRoles = await trx // Re-add specified roles but we need to get the roleIds from the role name in the array
const rolesToUpdate = await trx
.select() .select()
.from(roles) .from(roles)
.where( .where(
@@ -374,30 +338,7 @@ export async function updatePrivateResources(
) )
); );
const foundNames = new Set(existingRoles.map((r) => r.name)); const roleIds = rolesToUpdate.map((role) => role.roleId);
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(
`Auto-created role "${name}" in org ${orgId} from blueprint`
);
}
const roleIds = existingRoles.map((role) => role.roleId);
await trx await trx
.insert(roleSiteResources) .insert(roleSiteResources)
@@ -414,18 +355,8 @@ export async function updatePrivateResources(
}); });
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null; if (resourceData.mode === "host" || resourceData.mode === "http") {
if ( aliasAddress = await getNextAvailableAliasAddress(orgId);
resourceData.mode === "host" ||
resourceData.mode === "http" ||
resourceData.mode === "ssh"
) {
const { value, release } = await getNextAvailableAliasAddress(
orgId,
trx
);
aliasAddress = value;
releaseAliasLock = release;
} }
let domainInfo: let domainInfo:
@@ -466,29 +397,15 @@ export async function updatePrivateResources(
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: disableIcmp: resourceData["disable-icmp"],
resourceData["disable-icmp"] || tcpPortRangeString: resourceData["tcp-ports"],
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false udpPortRangeString: resourceData["udp-ports"],
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null, fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null, subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null, domainId: domainInfo ? domainInfo.domainId : null
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
}) })
.returning(); .returning();
await releaseAliasLock?.();
const siteResourceId = newResource.siteResourceId; const siteResourceId = newResource.siteResourceId;
for (const site of allSites) { for (const site of allSites) {
@@ -514,7 +431,8 @@ export async function updatePrivateResources(
}); });
if (resourceData.roles.length > 0) { if (resourceData.roles.length > 0) {
const existingRoles = await trx // get roleIds from role names
const rolesToUpdate = await trx
.select() .select()
.from(roles) .from(roles)
.where( .where(
@@ -524,30 +442,7 @@ export async function updatePrivateResources(
) )
); );
const foundNames = new Set(existingRoles.map((r) => r.name)); const roleIds = rolesToUpdate.map((role) => role.roleId);
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(
`Auto-created role "${name}" in org ${orgId} from blueprint`
);
}
const roleIds = existingRoles.map((role) => role.roleId);
await trx await trx
.insert(roleSiteResources) .insert(roleSiteResources)
File diff suppressed because it is too large Load Diff

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