mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-13 17:21:48 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd2b301729 |
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
|
||||||
@@ -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.
|
|
||||||
@@ -34,5 +34,3 @@ build.ts
|
|||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
allowedDevOrigins.json
|
|
||||||
scratch/
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [fosrl]
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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,7 +407,7 @@ 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 }}
|
||||||
@@ -416,8 +416,6 @@ jobs:
|
|||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
# cosign is used to sign container images using keyless (OIDC) signing
|
# cosign is used to sign container images using keyless (OIDC) signing
|
||||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
with:
|
|
||||||
cosign-release: v3.0.6
|
|
||||||
|
|
||||||
- name: Sign (GHCR, keyless)
|
- name: Sign (GHCR, keyless)
|
||||||
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
Vendored
+1
-4
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -10,8 +10,6 @@ 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 { 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")
|
||||||
@@ -23,7 +21,5 @@ yargs(hideBin(process.argv))
|
|||||||
.command(deleteClient)
|
.command(deleteClient)
|
||||||
.command(generateOrgCaKeys)
|
.command(generateOrgCaKeys)
|
||||||
.command(clearCertificates)
|
.command(clearCertificates)
|
||||||
.command(disableUser2fa)
|
|
||||||
.command(setServerAdmin)
|
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{{if .IsRedis}}redis:
|
|
||||||
host: "redis"
|
|
||||||
port: 6379
|
|
||||||
password: "{{.IsRedisPass}}"{{end}}
|
|
||||||
+2
-2
@@ -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
@@ -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=
|
||||||
|
|||||||
+20
-57
@@ -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) {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-567
File diff suppressed because it is too large
Load Diff
+43
-566
File diff suppressed because it is too large
Load Diff
-3805
File diff suppressed because it is too large
Load Diff
+80
-603
File diff suppressed because it is too large
Load Diff
+45
-569
File diff suppressed because it is too large
Load Diff
+43
-566
File diff suppressed because it is too large
Load Diff
+37
-560
File diff suppressed because it is too large
Load Diff
+42
-565
File diff suppressed because it is too large
Load Diff
+42
-565
File diff suppressed because it is too large
Load Diff
+43
-566
File diff suppressed because it is too large
Load Diff
+42
-565
File diff suppressed because it is too large
Load Diff
+41
-564
File diff suppressed because it is too large
Load Diff
+43
-566
File diff suppressed because it is too large
Load Diff
+38
-561
File diff suppressed because it is too large
Load Diff
+43
-566
File diff suppressed because it is too large
Load Diff
+94
-617
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -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": "配置目標",
|
||||||
@@ -1099,7 +1099,6 @@
|
|||||||
"actionGenerateAccessToken": "生成訪問令牌",
|
"actionGenerateAccessToken": "生成訪問令牌",
|
||||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||||
"actionListAccessTokens": "訪問令牌",
|
"actionListAccessTokens": "訪問令牌",
|
||||||
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
|
|
||||||
"actionCreateResourceRule": "創建資源規則",
|
"actionCreateResourceRule": "創建資源規則",
|
||||||
"actionDeleteResourceRule": "刪除資源規則",
|
"actionDeleteResourceRule": "刪除資源規則",
|
||||||
"actionListResourceRules": "列出資源規則",
|
"actionListResourceRules": "列出資源規則",
|
||||||
@@ -1764,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
@@ -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);
|
||||||
|
|||||||
Generated
+3621
-2283
File diff suppressed because it is too large
Load Diff
+58
-64
@@ -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.1.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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-48
@@ -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",
|
||||||
@@ -21,7 +20,6 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
restartSite = "restartSite",
|
|
||||||
resetSiteBandwidth = "resetSiteBandwidth",
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
createResource = "createResource",
|
createResource = "createResource",
|
||||||
@@ -71,7 +69,6 @@ export enum ActionsEnum {
|
|||||||
setResourceWhitelist = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
getResourceWhitelist = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
generateAccessToken = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
createResourceSessionToken = "createResourceSessionToken",
|
|
||||||
deleteAcessToken = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
listAccessTokens = "listAccessTokens",
|
listAccessTokens = "listAccessTokens",
|
||||||
createResourceRule = "createResourceRule",
|
createResourceRule = "createResourceRule",
|
||||||
@@ -151,37 +148,11 @@ 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",
|
|
||||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
|
||||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
|
||||||
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",
|
|
||||||
createOrgWideLauncherView = "createOrgWideLauncherView"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
@@ -214,23 +185,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()
|
||||||
@@ -248,7 +202,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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+1
-36
@@ -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> {
|
||||||
|
|||||||
@@ -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 as typeof db; // is this typeof a problem - techincally they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import config from "@server/lib/config";
|
|
||||||
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,
|
||||||
@@ -27,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}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -36,32 +36,10 @@ 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}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disable JIT compilation for this connection. Our hot-path queries
|
|
||||||
// (e.g. resource-by-domain lookups) join many tables but only ever
|
|
||||||
// return a handful of rows. When planner row estimates drift (e.g.
|
|
||||||
// due to autovacuum lag under write-heavy load), Postgres decides
|
|
||||||
// these plans are expensive enough to JIT-compile, which can add
|
|
||||||
// multiple seconds of pure compilation overhead per query and
|
|
||||||
// saturate the connection pool. JIT never pays off for these
|
|
||||||
// short-lived OLTP queries, so it's disabled outright rather than
|
|
||||||
// relying on statistics staying fresh.
|
|
||||||
//
|
|
||||||
// Set via a runtime SET command rather than the `options: "-c
|
|
||||||
// jit=off"` startup parameter: connections in SaaS mode go through
|
|
||||||
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
|
|
||||||
// options with a protocol_violation (08P01) error.
|
|
||||||
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
|
|
||||||
client.query("SET jit = off").catch((err: Error) => {
|
|
||||||
console.warn(
|
|
||||||
`Failed to set jit=off on ${label} client: ${err.message}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +60,4 @@ export function createPool(
|
|||||||
);
|
);
|
||||||
attachPoolErrorHandlers(pool, label);
|
attachPoolErrorHandlers(pool, label);
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
unique,
|
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -12,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,
|
||||||
@@ -20,13 +19,12 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
sessions,
|
||||||
|
clients,
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites,
|
sites
|
||||||
clients,
|
|
||||||
sessions,
|
|
||||||
labels
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -199,42 +197,6 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: varchar("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = pgTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: serial(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
unique("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
@@ -245,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")
|
||||||
|
|||||||
+322
-756
File diff suppressed because it is too large
Load Diff
@@ -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, or, sql } 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,33 +57,6 @@ export async function getResourceByDomain(
|
|||||||
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
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
|
const potentialResults = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -121,59 +79,6 @@ 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(
|
||||||
or(
|
or(
|
||||||
@@ -203,51 +108,13 @@ export async function getResourceByDomain(
|
|||||||
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 +154,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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+50
-12
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -11,31 +12,68 @@ export const exists = checkFileExists(location);
|
|||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
||||||
|
* execution, freeing native sqlite3_stmt memory deterministically instead
|
||||||
|
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
||||||
|
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
||||||
|
* reusable .prepare() builders. No such usage exists in this codebase.
|
||||||
|
*/
|
||||||
|
function autoFinalizeStatement(
|
||||||
|
stmt: BetterSqlite3.Statement
|
||||||
|
): BetterSqlite3.Statement {
|
||||||
|
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
try {
|
||||||
|
return fn.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// finalize() exists on the native Statement at runtime but
|
||||||
|
// is missing from @types/better-sqlite3.
|
||||||
|
(stmt as any).finalize();
|
||||||
|
} catch {
|
||||||
|
// Already finalized — harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.run = wrapExec(stmt.run);
|
||||||
|
stmt.get = wrapExec(stmt.get);
|
||||||
|
stmt.all = wrapExec(stmt.all);
|
||||||
|
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
|
|
||||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
// 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");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// No busy_timeout pragma: better-sqlite3 already arms
|
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||||
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
// retry loops that accumulate memory.
|
||||||
// (lib/database.js), so an explicit pragma is redundant.
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
|
||||||
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||||
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
// TraefikConfigManager JOINs that block the event loop.
|
||||||
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
sqlite.pragma("cache_size = -65536");
|
||||||
|
|
||||||
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||||
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
// reducing event-loop blocking.
|
||||||
// fresh statement per query (no statement cache), so statements cannot
|
sqlite.pragma("mmap_size = 268435456");
|
||||||
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
|
||||||
|
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
||||||
|
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
||||||
|
const originalPrepare = sqlite.prepare.bind(sqlite);
|
||||||
|
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
||||||
|
return autoFinalizeStatement(originalPrepare(source));
|
||||||
|
};
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
labels,
|
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -22,6 +21,9 @@ 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 }),
|
||||||
@@ -193,44 +195,6 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: text("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = sqliteTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: integer(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey({ autoIncrement: true }),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ export const domains = sqliteTable("domains", {
|
|||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
customCertResolver: text("customCertResolver"),
|
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
errorMessage: text("errorMessage"),
|
errorMessage: text("errorMessage")
|
||||||
lastCheckedAt: integer("lastCheckedAt")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -64,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", {
|
||||||
@@ -124,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()
|
||||||
@@ -167,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()
|
||||||
@@ -188,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"
|
||||||
})
|
})
|
||||||
@@ -201,123 +180,9 @@ export const resources = sqliteTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
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 launcherViews = sqliteTable("launcherViews", {
|
|
||||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
config: text("config").notNull(),
|
|
||||||
isDefault: integer("isDefault", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
createdAt: text("createdAt").notNull(),
|
|
||||||
updatedAt: text("updatedAt").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")
|
||||||
@@ -339,12 +204,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", {
|
||||||
@@ -359,11 +219,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()
|
||||||
@@ -423,11 +281,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"),
|
||||||
@@ -437,11 +295,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"
|
||||||
@@ -1054,47 +909,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",
|
||||||
{
|
{
|
||||||
@@ -1123,7 +937,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"),
|
||||||
@@ -1170,24 +983,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")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1224,101 +1019,10 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: text("match")
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
.$type<
|
|
||||||
| "CIDR"
|
|
||||||
| "PATH"
|
|
||||||
| "IP"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
>()
|
|
||||||
.notNull(), // CIDR, PATH, IP
|
|
||||||
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"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "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(),
|
||||||
@@ -1492,30 +1196,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>;
|
||||||
@@ -1585,17 +1278,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 LauncherView = InferSelectModel<typeof launcherViews>;
|
|
||||||
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>;
|
|
||||||
|
|||||||
+1
-3
@@ -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();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import swaggerUi from "swagger-ui-express";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
import { registry, openApiTags } from "./openApi";
|
import { registry } from "./openApi";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { APP_PATH } from "./lib/consts";
|
import { APP_PATH } from "./lib/consts";
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,8 +173,7 @@ function getOpenApiDocumentation() {
|
|||||||
version: "v1",
|
version: "v1",
|
||||||
title: "Pangolin Integration API"
|
title: "Pangolin Integration API"
|
||||||
},
|
},
|
||||||
servers: [{ url: "/v1" }],
|
servers: [{ url: "/v1" }]
|
||||||
tags: openApiTags
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!process.env.DISABLE_GEN_OPENAPI) {
|
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||||
|
|||||||
@@ -221,18 +221,10 @@ async function handleResource(
|
|||||||
)
|
)
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
.where(eq(targets.resourceId, resource.resourceId));
|
||||||
|
|
||||||
const monitoredTargets = otherTargets.filter(
|
|
||||||
(t) => t.hcHealth !== "unknown"
|
|
||||||
);
|
|
||||||
|
|
||||||
let health = "healthy";
|
let health = "healthy";
|
||||||
const allUnknown = monitoredTargets.length === 0;
|
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
||||||
const allHealthy = monitoredTargets.every(
|
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
||||||
(t) => t.hcHealth === "healthy"
|
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
||||||
);
|
|
||||||
const allUnhealthy = monitoredTargets.every(
|
|
||||||
(t) => t.hcHealth === "unhealthy"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allUnknown) {
|
if (allUnknown) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -1,39 +1,28 @@
|
|||||||
export enum LimitId {
|
export enum FeatureId {
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
SITES = "sites",
|
SITES = "sites",
|
||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
ORGANIZATIONS = "organizations",
|
ORGINIZATIONS = "organizations",
|
||||||
PUBLIC_RESOURCES = "publicResources",
|
|
||||||
PRIVATE_RESOURCES = "privateResources",
|
|
||||||
MACHINE_CLIENTS = "machineClients",
|
|
||||||
TIER1 = "tier1"
|
TIER1 = "tier1"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFeatureDisplayName(
|
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
|
||||||
featureId: LimitId
|
|
||||||
): Promise<string> {
|
|
||||||
switch (featureId) {
|
switch (featureId) {
|
||||||
case LimitId.USERS:
|
case FeatureId.USERS:
|
||||||
return "Users";
|
return "Users";
|
||||||
case LimitId.SITES:
|
case FeatureId.SITES:
|
||||||
return "Sites";
|
return "Sites";
|
||||||
case LimitId.EGRESS_DATA_MB:
|
case FeatureId.EGRESS_DATA_MB:
|
||||||
return "Egress Data (MB)";
|
return "Egress Data (MB)";
|
||||||
case LimitId.DOMAINS:
|
case FeatureId.DOMAINS:
|
||||||
return "Domains";
|
return "Domains";
|
||||||
case LimitId.REMOTE_EXIT_NODES:
|
case FeatureId.REMOTE_EXIT_NODES:
|
||||||
return "Remote Exit Nodes";
|
return "Remote Exit Nodes";
|
||||||
case LimitId.ORGANIZATIONS:
|
case FeatureId.ORGINIZATIONS:
|
||||||
return "Organizations";
|
return "Organizations";
|
||||||
case LimitId.PUBLIC_RESOURCES:
|
case FeatureId.TIER1:
|
||||||
return "Public Resources";
|
|
||||||
case LimitId.PRIVATE_RESOURCES:
|
|
||||||
return "Private Resources";
|
|
||||||
case LimitId.MACHINE_CLIENTS:
|
|
||||||
return "Machine Clients";
|
|
||||||
case LimitId.TIER1:
|
|
||||||
return "Home Lab";
|
return "Home Lab";
|
||||||
default:
|
default:
|
||||||
return featureId;
|
return featureId;
|
||||||
@@ -41,16 +30,15 @@ export async function getFeatureDisplayName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is from the old system
|
// this is from the old system
|
||||||
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
|
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
|
||||||
// right now we are not charging for any data
|
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
|
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
@@ -61,20 +49,22 @@ export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
|
export function getFeatureIdByMetricId(
|
||||||
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
|
metricId: string
|
||||||
|
): FeatureId | undefined {
|
||||||
|
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
|
||||||
([_, v]) => v === metricId
|
([_, v]) => v === metricId
|
||||||
)?.[0];
|
)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
|
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
||||||
|
|
||||||
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -89,11 +79,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -108,11 +98,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -126,7 +116,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
|
||||||
// Check all feature price sets
|
// Check all feature price sets
|
||||||
const allPriceSets = [
|
const allPriceSets = [
|
||||||
getTier1FeaturePriceSet(),
|
getTier1FeaturePriceSet(),
|
||||||
@@ -135,7 +125,7 @@ export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const priceSet of allPriceSets) {
|
for (const priceSet of allPriceSets) {
|
||||||
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
|
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
|
||||||
([_, price]) => price === priceId
|
([_, price]) => price === priceId
|
||||||
);
|
);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { LimitId, FeaturePriceSet } from "./features";
|
import { FeatureId, FeaturePriceSet } from "./features";
|
||||||
import { usageService } from "./usageService";
|
import { usageService } from "./usageService";
|
||||||
|
|
||||||
export async function getLineItems(
|
export async function getLineItems(
|
||||||
featurePriceSet: FeaturePriceSet,
|
featurePriceSet: FeaturePriceSet,
|
||||||
orgId: string
|
orgId: string,
|
||||||
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
||||||
const users = await usageService.getUsage(orgId, LimitId.USERS);
|
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||||
|
|
||||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
||||||
let quantity: number | undefined;
|
let quantity: number | undefined;
|
||||||
|
|
||||||
if (featureId === LimitId.USERS) {
|
if (featureId === FeatureId.USERS) {
|
||||||
quantity = users?.instantaneousValue || 1;
|
quantity = users?.instantaneousValue || 1;
|
||||||
} else if (featureId === LimitId.TIER1) {
|
} else if (featureId === FeatureId.TIER1) {
|
||||||
quantity = 1;
|
quantity = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,70 @@
|
|||||||
import { LimitId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
|
|
||||||
export type LimitSet = Partial<{
|
export type LimitSet = Partial<{
|
||||||
[key in LimitId]: {
|
[key in FeatureId]: {
|
||||||
value: number | null; // null indicates no limit
|
value: number | null; // null indicates no limit
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[LimitId.SITES]: { value: 5, description: "Basic limit" },
|
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.USERS]: { value: 5, description: "Basic limit" },
|
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
|
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||||
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
|
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1LimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: { value: 7, description: "Home limit" },
|
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
||||||
[LimitId.SITES]: { value: 10, description: "Home limit" },
|
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||||
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
|
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
|
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2LimitSet: LimitSet = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 3,
|
value: 3,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.ORGANIZATIONS]: {
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
value: 1,
|
value: 1,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
}
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3LimitSet: LimitSet = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 100,
|
value: 100,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 20,
|
value: 20,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.ORGANIZATIONS]: {
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
value: 5,
|
value: 5,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db, limits } from "@server/db";
|
import { db, limits } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { LimitSet } from "./limitSet";
|
import { LimitSet } from "./limitSet";
|
||||||
import { LimitId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
class LimitService {
|
class LimitService {
|
||||||
@@ -38,7 +38,7 @@ class LimitService {
|
|||||||
|
|
||||||
async getOrgLimit(
|
async getOrgLimit(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId
|
featureId: FeatureId
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const limitId = `${orgId}-${featureId}`;
|
const limitId = `${orgId}-${featureId}`;
|
||||||
const [limit] = await db
|
const [limit] = await db
|
||||||
|
|||||||
@@ -16,17 +16,15 @@ 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",
|
WildcardSubdomain = "wildcardSubdomain"
|
||||||
NewtAutoUpdate = "newtAutoUpdate",
|
|
||||||
ResourcePolicies = "resourcePolicies",
|
|
||||||
AdvancedPublicResources = "advancedPublicResources",
|
|
||||||
AdvancedPrivateResources = "advancedPrivateResources"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -60,15 +58,13 @@ 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]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "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"]
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
Transaction,
|
Transaction,
|
||||||
orgs
|
orgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { LimitId, 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;
|
||||||
@@ -37,7 +38,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async add(
|
public async add(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
transaction: any = null
|
transaction: any = null
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
@@ -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,
|
||||||
@@ -114,7 +112,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async internalAddUsage(
|
private async internalAddUsage(
|
||||||
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<Usage> {
|
): Promise<Usage> {
|
||||||
@@ -163,7 +161,7 @@ export class UsageService {
|
|||||||
|
|
||||||
async updateCount(
|
async updateCount(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value?: number,
|
value?: number,
|
||||||
customerId?: string
|
customerId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -227,7 +225,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async getCustomerId(
|
private async getCustomerId(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId
|
featureId: FeatureId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
@@ -269,19 +267,18 @@ export class UsageService {
|
|||||||
|
|
||||||
public async getUsage(
|
public async getUsage(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = orgId;
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
|
||||||
|
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(usage)
|
.from(usage)
|
||||||
@@ -341,12 +338,8 @@ export class UsageService {
|
|||||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
if (process.env.NODE_ENV !== "development") {
|
throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBillingOrg(
|
public async getBillingOrg(
|
||||||
@@ -381,7 +374,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId?: LimitId,
|
featureId?: FeatureId,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -389,13 +382,13 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
let orgIdToUse = orgId;
|
|
||||||
try {
|
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
|
try {
|
||||||
let orgLimits: Limit[] = [];
|
let orgLimits: Limit[] = [];
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
// Get all limits set for this organization
|
// Get all limits set for this organization
|
||||||
@@ -429,7 +422,7 @@ export class UsageService {
|
|||||||
} else {
|
} else {
|
||||||
currentUsage = await this.getUsage(
|
currentUsage = await this.getUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
limit.featureId as LimitId,
|
limit.featureId as FeatureId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,43 +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, isTargetsOnlyResource } 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";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import next from "next";
|
|
||||||
import { LimitId } from "../billing";
|
|
||||||
import { usageService } from "../billing/usageService";
|
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -56,39 +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()
|
||||||
@@ -115,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;
|
||||||
@@ -179,9 +281,7 @@ export async function applyBlueprint({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
blueprintSucceeded = false;
|
blueprintSucceeded = false;
|
||||||
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
||||||
logger.debug(
|
logger.error(blueprintMessage);
|
||||||
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
|
|
||||||
);
|
|
||||||
error = err;
|
error = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +51,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-139
@@ -3,7 +3,6 @@ import {
|
|||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
roleActions,
|
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
Site,
|
Site,
|
||||||
@@ -20,17 +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";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import next from "next";
|
|
||||||
import { LimitId } from "../billing";
|
|
||||||
import { usageService } from "../billing/usageService";
|
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
@@ -111,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,
|
||||||
@@ -122,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)
|
||||||
@@ -259,11 +225,7 @@ export async function updatePrivateResources(
|
|||||||
: resourceData["udp-ports"],
|
: 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(
|
||||||
@@ -370,7 +332,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(
|
||||||
@@ -380,30 +343,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)
|
||||||
@@ -419,47 +359,9 @@ export async function updatePrivateResources(
|
|||||||
oldSites: existingSiteIds
|
oldSites: existingSiteIds
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// create a brand new resource
|
|
||||||
|
|
||||||
if (build == "saas") {
|
|
||||||
const usage = await usageService.getUsage(
|
|
||||||
orgId,
|
|
||||||
LimitId.PRIVATE_RESOURCES
|
|
||||||
);
|
|
||||||
if (!usage) {
|
|
||||||
throw new Error(
|
|
||||||
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rejectResource = await usageService.checkLimitSet(
|
|
||||||
orgId,
|
|
||||||
|
|
||||||
LimitId.PRIVATE_RESOURCES,
|
|
||||||
{
|
|
||||||
...usage,
|
|
||||||
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
|
||||||
} // We need to add one to know if we are violating the limit
|
|
||||||
);
|
|
||||||
if (rejectResource) {
|
|
||||||
throw new Error(
|
|
||||||
"Private resource limit exceeded. Please upgrade your plan."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, trx);
|
||||||
resourceData.mode === "host" ||
|
|
||||||
resourceData.mode === "http" ||
|
|
||||||
resourceData.mode === "ssh"
|
|
||||||
) {
|
|
||||||
const { value, release } = await getNextAvailableAliasAddress(
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
aliasAddress = value;
|
|
||||||
releaseAliasLock = release;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
@@ -513,16 +415,10 @@ export async function updatePrivateResources(
|
|||||||
: resourceData["udp-ports"],
|
: 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) {
|
||||||
@@ -548,7 +444,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(
|
||||||
@@ -558,30 +455,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)
|
||||||
@@ -643,8 +517,6 @@ export async function updatePrivateResources(
|
|||||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
|
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
newSiteResource: newResource,
|
newSiteResource: newResource,
|
||||||
newSites: allSites,
|
newSites: allSites,
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,654 +0,0 @@
|
|||||||
import {
|
|
||||||
db,
|
|
||||||
idp,
|
|
||||||
idpOrg,
|
|
||||||
resourcePolicies,
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
resourcePolicyPassword,
|
|
||||||
resourcePolicyPincode,
|
|
||||||
resourcePolicyRules,
|
|
||||||
resourcePolicyWhiteList,
|
|
||||||
rolePolicies,
|
|
||||||
roles,
|
|
||||||
Transaction,
|
|
||||||
userOrgs,
|
|
||||||
userPolicies,
|
|
||||||
users
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and, or } from "drizzle-orm";
|
|
||||||
import { Config, ResourcePolicyData } from "./types";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
|
||||||
|
|
||||||
export type ResourcePoliciesResults = {
|
|
||||||
resourcePolicyId: number;
|
|
||||||
niceId: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export async function updateResourcePolicies(
|
|
||||||
orgId: string,
|
|
||||||
config: Config,
|
|
||||||
trx: Transaction
|
|
||||||
): Promise<ResourcePoliciesResults> {
|
|
||||||
const results: ResourcePoliciesResults = [];
|
|
||||||
|
|
||||||
for (const [policyNiceId, policyData] of Object.entries(
|
|
||||||
config["public-policies"]
|
|
||||||
)) {
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.resourcePolicies
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
|
||||||
throw new Error(
|
|
||||||
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate rules
|
|
||||||
for (const rule of policyData.rules) {
|
|
||||||
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
|
|
||||||
);
|
|
||||||
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
rule.match === "path" &&
|
|
||||||
!isValidUrlGlobPattern(rule.value)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate auto-login-idp if provided
|
|
||||||
if (policyData["auto-login-idp"]) {
|
|
||||||
const [provider] = await trx
|
|
||||||
.select()
|
|
||||||
.from(idp)
|
|
||||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(idp.idpId, policyData["auto-login-idp"]),
|
|
||||||
eq(idpOrg.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
throw new Error(
|
|
||||||
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the admin role
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
throw new Error("Admin role not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find existing policy by niceId and orgId
|
|
||||||
const [existingPolicy] = await trx
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourcePolicies.niceId, policyNiceId),
|
|
||||||
eq(resourcePolicies.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let resourcePolicyId: number;
|
|
||||||
|
|
||||||
if (existingPolicy) {
|
|
||||||
// Update the existing policy
|
|
||||||
await trx
|
|
||||||
.update(resourcePolicies)
|
|
||||||
.set({
|
|
||||||
name: policyData.name,
|
|
||||||
sso: policyData.sso ?? true,
|
|
||||||
idpId: policyData["auto-login-idp"] ?? null,
|
|
||||||
emailWhitelistEnabled:
|
|
||||||
policyData["email-whitelist-enabled"] ??
|
|
||||||
policyData["whitelist-users"].length > 0,
|
|
||||||
applyRules:
|
|
||||||
policyData["apply-rules"] || policyData.rules.length > 0
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicies.resourcePolicyId,
|
|
||||||
existingPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
resourcePolicyId = existingPolicy.resourcePolicyId;
|
|
||||||
|
|
||||||
// Sync password
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyPassword)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicyPassword.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (policyData.password) {
|
|
||||||
const passwordHash = await hashPassword(policyData.password);
|
|
||||||
await trx.insert(resourcePolicyPassword).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
passwordHash
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync pincode
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyPincode)
|
|
||||||
.where(
|
|
||||||
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
|
||||||
);
|
|
||||||
if (policyData.pincode) {
|
|
||||||
const pincodeHash = await hashPassword(policyData.pincode);
|
|
||||||
await trx.insert(resourcePolicyPincode).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
pincodeHash,
|
|
||||||
digitLength: 6
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync header auth
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyHeaderAuth)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (policyData["basic-auth"]) {
|
|
||||||
const basicAuth = policyData["basic-auth"];
|
|
||||||
const headerAuthHash = await hashPassword(
|
|
||||||
Buffer.from(
|
|
||||||
`${basicAuth.user}:${basicAuth.password}`
|
|
||||||
).toString("base64")
|
|
||||||
);
|
|
||||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
headerAuthHash,
|
|
||||||
extendedCompatibility:
|
|
||||||
basicAuth["extended-compatibility"] ?? true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync SSO roles
|
|
||||||
await syncRolePolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-roles"],
|
|
||||||
orgId,
|
|
||||||
adminRole.roleId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync SSO users
|
|
||||||
await syncUserPolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-users"],
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync whitelist users
|
|
||||||
await syncWhitelistPolicyUsers(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["whitelist-users"],
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync rules
|
|
||||||
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Create a new policy
|
|
||||||
const [newPolicy] = await trx
|
|
||||||
.insert(resourcePolicies)
|
|
||||||
.values({
|
|
||||||
niceId: policyNiceId,
|
|
||||||
orgId,
|
|
||||||
name: policyData.name,
|
|
||||||
sso: policyData.sso ?? true,
|
|
||||||
idpId: policyData["auto-login-idp"] ?? null,
|
|
||||||
emailWhitelistEnabled:
|
|
||||||
policyData["email-whitelist-enabled"] ??
|
|
||||||
policyData["whitelist-users"].length > 0,
|
|
||||||
applyRules:
|
|
||||||
policyData["apply-rules"] ||
|
|
||||||
policyData.rules.length > 0,
|
|
||||||
scope: "global"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
resourcePolicyId = newPolicy.resourcePolicyId;
|
|
||||||
|
|
||||||
// Always add admin role
|
|
||||||
await trx.insert(rolePolicies).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
resourcePolicyId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add SSO roles
|
|
||||||
await addRolePolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-roles"],
|
|
||||||
orgId,
|
|
||||||
adminRole.roleId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add SSO users
|
|
||||||
await addUserPolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-users"],
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add password
|
|
||||||
if (policyData.password) {
|
|
||||||
const passwordHash = await hashPassword(policyData.password);
|
|
||||||
await trx.insert(resourcePolicyPassword).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
passwordHash
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add pincode
|
|
||||||
if (policyData.pincode) {
|
|
||||||
const pincodeHash = await hashPassword(policyData.pincode);
|
|
||||||
await trx.insert(resourcePolicyPincode).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
pincodeHash,
|
|
||||||
digitLength: 6
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add header auth
|
|
||||||
if (policyData["basic-auth"]) {
|
|
||||||
const basicAuth = policyData["basic-auth"];
|
|
||||||
const headerAuthHash = await hashPassword(
|
|
||||||
Buffer.from(
|
|
||||||
`${basicAuth.user}:${basicAuth.password}`
|
|
||||||
).toString("base64")
|
|
||||||
);
|
|
||||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
headerAuthHash,
|
|
||||||
extendedCompatibility:
|
|
||||||
basicAuth["extended-compatibility"] ?? true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add whitelist users
|
|
||||||
if (policyData["whitelist-users"].length > 0) {
|
|
||||||
await trx.insert(resourcePolicyWhiteList).values(
|
|
||||||
policyData["whitelist-users"].map((email) => ({
|
|
||||||
email,
|
|
||||||
resourcePolicyId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rules
|
|
||||||
if (policyData.rules.length > 0) {
|
|
||||||
await trx.insert(resourcePolicyRules).values(
|
|
||||||
policyData.rules.map((rule, index) => ({
|
|
||||||
resourcePolicyId,
|
|
||||||
action: getRuleAction(rule.action),
|
|
||||||
match: getRuleMatch(rule.match),
|
|
||||||
value: rule.value,
|
|
||||||
priority: rule.priority ?? index + 1,
|
|
||||||
enabled: rule.enabled ?? true
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({ resourcePolicyId, niceId: policyNiceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
|
||||||
if (input === "allow") return "ACCEPT";
|
|
||||||
if (input === "deny") return "DROP";
|
|
||||||
return "PASS";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuleMatch(
|
|
||||||
input: string
|
|
||||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
|
|
||||||
return input.toUpperCase() as
|
|
||||||
| "CIDR"
|
|
||||||
| "IP"
|
|
||||||
| "PATH"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncRolePolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoRoles: string[],
|
|
||||||
orgId: string,
|
|
||||||
adminRoleId: number,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingRolePolicies = await trx
|
|
||||||
.select()
|
|
||||||
.from(rolePolicies)
|
|
||||||
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
for (const roleName of ssoRoles) {
|
|
||||||
const [role] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
logger.warn(
|
|
||||||
`Role '${roleName}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role.isAdmin) {
|
|
||||||
continue; // admin role is always included, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyExists = existingRolePolicies.some(
|
|
||||||
(rp) => rp.roleId === role.roleId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
await trx.insert(rolePolicies).values({
|
|
||||||
roleId: role.roleId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove roles no longer in the list (except admin)
|
|
||||||
for (const existingRolePolicy of existingRolePolicies) {
|
|
||||||
if (existingRolePolicy.roleId === adminRoleId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [role] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(eq(roles.roleId, existingRolePolicy.roleId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (role?.isAdmin) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role && !ssoRoles.includes(role.name)) {
|
|
||||||
await trx
|
|
||||||
.delete(rolePolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(rolePolicies.resourcePolicyId, policyId),
|
|
||||||
eq(rolePolicies.roleId, existingRolePolicy.roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRolePolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoRoles: string[],
|
|
||||||
orgId: string,
|
|
||||||
adminRoleId: number,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
for (const roleName of ssoRoles) {
|
|
||||||
const [role] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
logger.warn(
|
|
||||||
`Role '${roleName}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role.isAdmin) {
|
|
||||||
continue; // admin already added
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(rolePolicies).values({
|
|
||||||
roleId: role.roleId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncUserPolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoUsers: string[],
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingUserPolicies = await trx
|
|
||||||
.select()
|
|
||||||
.from(userPolicies)
|
|
||||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
for (const username of ssoUsers) {
|
|
||||||
const [user] = await trx
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(eq(users.username, username), eq(users.email, username)),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
logger.warn(
|
|
||||||
`User '${username}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyExists = existingUserPolicies.some(
|
|
||||||
(up) => up.userId === user.user.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
await trx.insert(userPolicies).values({
|
|
||||||
userId: user.user.userId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove users no longer in the list
|
|
||||||
for (const existingUserPolicy of existingUserPolicies) {
|
|
||||||
const [user] = await trx
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(users.userId, existingUserPolicy.userId),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (
|
|
||||||
user &&
|
|
||||||
user.user.username &&
|
|
||||||
!ssoUsers.includes(user.user.username) &&
|
|
||||||
!ssoUsers.includes(user.user.email ?? "")
|
|
||||||
) {
|
|
||||||
await trx
|
|
||||||
.delete(userPolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userPolicies.resourcePolicyId, policyId),
|
|
||||||
eq(userPolicies.userId, existingUserPolicy.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addUserPolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoUsers: string[],
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
for (const username of ssoUsers) {
|
|
||||||
const [user] = await trx
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(eq(users.username, username), eq(users.email, username)),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
logger.warn(
|
|
||||||
`User '${username}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(userPolicies).values({
|
|
||||||
userId: user.user.userId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncWhitelistPolicyUsers(
|
|
||||||
policyId: number,
|
|
||||||
whitelistUsers: string[],
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingWhitelist = await trx
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicyWhiteList)
|
|
||||||
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
for (const email of whitelistUsers) {
|
|
||||||
const alreadyExists = existingWhitelist.some((w) => w.email === email);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
await trx.insert(resourcePolicyWhiteList).values({
|
|
||||||
email,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const existingEntry of existingWhitelist) {
|
|
||||||
if (!whitelistUsers.includes(existingEntry.email)) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyWhiteList)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
|
|
||||||
eq(resourcePolicyWhiteList.email, existingEntry.email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncPolicyRules(
|
|
||||||
policyId: number,
|
|
||||||
rules: ResourcePolicyData["rules"],
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingRules = await trx
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicyRules)
|
|
||||||
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
|
|
||||||
.orderBy(resourcePolicyRules.priority);
|
|
||||||
|
|
||||||
for (const [index, rule] of rules.entries()) {
|
|
||||||
const intendedPriority = rule.priority ?? index + 1;
|
|
||||||
const existingRule = existingRules[index];
|
|
||||||
|
|
||||||
if (existingRule) {
|
|
||||||
await trx
|
|
||||||
.update(resourcePolicyRules)
|
|
||||||
.set({
|
|
||||||
action: getRuleAction(rule.action),
|
|
||||||
match: getRuleMatch(rule.match),
|
|
||||||
value: rule.value,
|
|
||||||
priority: intendedPriority,
|
|
||||||
enabled: rule.enabled ?? true
|
|
||||||
})
|
|
||||||
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
|
|
||||||
} else {
|
|
||||||
await trx.insert(resourcePolicyRules).values({
|
|
||||||
resourcePolicyId: policyId,
|
|
||||||
action: getRuleAction(rule.action),
|
|
||||||
match: getRuleMatch(rule.match),
|
|
||||||
value: rule.value,
|
|
||||||
priority: intendedPriority,
|
|
||||||
enabled: rule.enabled ?? true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove extra rules
|
|
||||||
if (existingRules.length > rules.length) {
|
|
||||||
const rulesToDelete = existingRules.slice(rules.length);
|
|
||||||
for (const rule of rulesToDelete) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyRules)
|
|
||||||
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+34
-256
@@ -1,23 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { portRangeStringSchema } from "@server/lib/ip";
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
|
|
||||||
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
|
|
||||||
|
|
||||||
const hasMaxmindCountryDb =
|
|
||||||
typeof maxmindDbPath === "string" &&
|
|
||||||
maxmindDbPath.length > 0 &&
|
|
||||||
existsSync(maxmindDbPath);
|
|
||||||
|
|
||||||
const hasMaxmindAsnDb =
|
|
||||||
typeof maxmindAsnPath === "string" &&
|
|
||||||
maxmindAsnPath.length > 0 &&
|
|
||||||
existsSync(maxmindAsnPath);
|
|
||||||
|
|
||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -97,9 +82,8 @@ export const RuleSchema = z
|
|||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.coerce.string(),
|
value: z.string(),
|
||||||
priority: z.int().optional(),
|
priority: z.int().optional()
|
||||||
enabled: z.boolean().optional().default(true)
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
@@ -132,9 +116,6 @@ export const RuleSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "country") {
|
if (rule.match === "country") {
|
||||||
if (!hasMaxmindCountryDb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if it's a valid 2-letter country code or "ALL"
|
// Check if it's a valid 2-letter country code or "ALL"
|
||||||
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
||||||
}
|
}
|
||||||
@@ -143,15 +124,12 @@ export const RuleSchema = z
|
|||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
|
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "asn") {
|
if (rule.match === "asn") {
|
||||||
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if it's either AS<number> format or "ALL"
|
// Check if it's either AS<number> format or "ALL"
|
||||||
const asNumberPattern = /^AS\d+$/i;
|
const asNumberPattern = /^AS\d+$/i;
|
||||||
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
||||||
@@ -161,7 +139,7 @@ export const RuleSchema = z
|
|||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
|
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -183,34 +161,11 @@ export const HeaderSchema = z.object({
|
|||||||
value: z.string().min(1)
|
value: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AuthDaemonSchema = z
|
|
||||||
.object({
|
|
||||||
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
|
||||||
mode: z.enum(["site", "remote", "native"]).optional().default("site"),
|
|
||||||
port: z.int().min(1).max(65535).optional()
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.mode === "remote") {
|
|
||||||
return data.port !== undefined;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["port"],
|
|
||||||
message: "port is required when auth-daemon mode is 'remote'"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Schema for individual resource
|
// Schema for individual resource
|
||||||
export const PublicResourceSchema = z
|
export const ResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
protocol: z
|
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
|
||||||
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
|
||||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
|
||||||
policy: z.string().optional(),
|
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
"full-domain": z.string().optional(),
|
"full-domain": z.string().optional(),
|
||||||
@@ -222,10 +177,7 @@ export const PublicResourceSchema = z
|
|||||||
"tls-server-name": z.string().optional(),
|
"tls-server-name": z.string().optional(),
|
||||||
headers: z.array(HeaderSchema).optional(),
|
headers: z.array(HeaderSchema).optional(),
|
||||||
rules: z.array(RuleSchema).optional(),
|
rules: z.array(RuleSchema).optional(),
|
||||||
maintenance: MaintenanceSchema.optional(),
|
maintenance: MaintenanceSchema.optional()
|
||||||
"auth-daemon": AuthDaemonSchema.optional(),
|
|
||||||
"proxy-protocol": z.boolean().optional(),
|
|
||||||
"proxy-protocol-version": z.int().min(1).optional()
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(resource) => {
|
(resource) => {
|
||||||
@@ -233,10 +185,9 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, require name and protocol/mode for full resource definition
|
// Otherwise, require name and protocol for full resource definition
|
||||||
return (
|
return (
|
||||||
resource.name !== undefined &&
|
resource.name !== undefined && resource.protocol !== undefined
|
||||||
(resource.mode !== undefined || resource.protocol !== undefined)
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -250,8 +201,8 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is http, all targets must have method field
|
// If protocol is http, all targets must have method field
|
||||||
if ((resource.mode ?? resource.protocol) === "http") {
|
if (resource.protocol === "http") {
|
||||||
return resource.targets.every(
|
return resource.targets.every(
|
||||||
(target) => target == null || target.method !== undefined
|
(target) => target == null || target.method !== undefined
|
||||||
);
|
);
|
||||||
@@ -269,9 +220,8 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is tcp or udp, no target should have method field
|
// If protocol is tcp or udp, no target should have method field
|
||||||
const effectiveProtocol1 = resource.mode ?? resource.protocol;
|
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||||
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
|
|
||||||
return resource.targets.every(
|
return resource.targets.every(
|
||||||
(target) => target == null || target.method === undefined
|
(target) => target == null || target.method === undefined
|
||||||
);
|
);
|
||||||
@@ -289,37 +239,8 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
// If protocol is http, it must have a full-domain
|
||||||
if (effectiveProtocol !== "ssh") {
|
if (resource.protocol === "http") {
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authDaemonMode = resource["auth-daemon"]?.mode;
|
|
||||||
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
resource.targets.filter((target) => target != null).length <= 1
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["targets"],
|
|
||||||
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(resource) => {
|
|
||||||
if (isTargetsOnlyResource(resource)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
|
||||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
|
||||||
if (
|
|
||||||
effectiveProtocol !== undefined &&
|
|
||||||
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
resource["full-domain"].length > 0
|
resource["full-domain"].length > 0
|
||||||
@@ -329,7 +250,7 @@ export const PublicResourceSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["full-domain"],
|
path: ["full-domain"],
|
||||||
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
error: "When protocol is 'http', a 'full-domain' must be provided"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -338,9 +259,8 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is tcp or udp, it must have both proxy-port
|
// If protocol is tcp or udp, it must have both proxy-port
|
||||||
const effectiveProtocol2 = resource.mode ?? resource.protocol;
|
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||||
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
|
|
||||||
return resource["proxy-port"] !== undefined;
|
return resource["proxy-port"] !== undefined;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -357,9 +277,8 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is tcp or udp, it must not have auth
|
// If protocol is tcp or udp, it must not have auth
|
||||||
const effectiveProtocol3 = resource.mode ?? resource.protocol;
|
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||||
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
|
|
||||||
return resource.auth === undefined;
|
return resource.auth === undefined;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -421,8 +340,7 @@ export const PublicResourceSchema = z
|
|||||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||||
if (parts.length < 3) return false; // need at least *.label.tld
|
if (parts.length < 3) return false; // need at least *.label.tld
|
||||||
|
|
||||||
const labelRegex =
|
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
|
||||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -430,46 +348,22 @@ export const PublicResourceSchema = z
|
|||||||
message:
|
message:
|
||||||
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.refine(
|
|
||||||
(resource) => {
|
|
||||||
const effectiveMode = resource.mode ?? resource.protocol;
|
|
||||||
if (effectiveMode !== "tcp") {
|
|
||||||
return (
|
|
||||||
resource["proxy-protocol"] === undefined &&
|
|
||||||
resource["proxy-protocol-version"] === undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["proxy-protocol"],
|
|
||||||
message:
|
|
||||||
"'proxy-protocol' and 'proxy-protocol-version' can only be set when mode is 'tcp'"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.transform((resource) => {
|
|
||||||
// Normalize: prefer mode, fall back to protocol for backwards compatibility
|
|
||||||
if (resource.mode === undefined && resource.protocol !== undefined) {
|
|
||||||
resource.mode = resource.protocol;
|
|
||||||
}
|
|
||||||
return resource;
|
|
||||||
});
|
|
||||||
|
|
||||||
export function isTargetsOnlyResource(resource: any): boolean {
|
export function isTargetsOnlyResource(resource: any): boolean {
|
||||||
return Object.keys(resource).length === 1 && resource.targets;
|
return Object.keys(resource).length === 1 && resource.targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PrivateResourceSchema = z
|
export const ClientResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||||
sites: z.array(z.string()).optional().default([]),
|
sites: z.array(z.string()).optional().default([]),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
"destination-port": z.int().positive().optional(),
|
"destination-port": z.int().positive().optional(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1),
|
||||||
// enabled: z.boolean().default(true),
|
// enabled: z.boolean().default(true),
|
||||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
@@ -492,31 +386,11 @@ export const PrivateResourceSchema = z
|
|||||||
error: "Admin role cannot be included in roles"
|
error: "Admin role cannot be included in roles"
|
||||||
}),
|
}),
|
||||||
users: z.array(z.string()).optional().default([]),
|
users: z.array(z.string()).optional().default([]),
|
||||||
machines: z.array(z.string()).optional().default([]),
|
machines: z.array(z.string()).optional().default([])
|
||||||
"auth-daemon": AuthDaemonSchema.optional()
|
|
||||||
})
|
})
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// destination is optional only for ssh+native; required for everything else
|
|
||||||
const isNativeSSH =
|
|
||||||
data.mode === "ssh" &&
|
|
||||||
(data["auth-daemon"] === undefined ||
|
|
||||||
data["auth-daemon"].mode === "native");
|
|
||||||
if (!isNativeSSH && !data.destination) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["destination"],
|
|
||||||
message:
|
|
||||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host") {
|
if (data.mode === "host") {
|
||||||
if (!data.destination) return true; // caught by the destination-required refine
|
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
const isValidIP = z
|
const isValidIP = z
|
||||||
.union([z.ipv4(), z.ipv6()])
|
.union([z.ipv4(), z.ipv6()])
|
||||||
@@ -544,7 +418,6 @@ export const PrivateResourceSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "cidr") {
|
if (data.mode === "cidr") {
|
||||||
if (!data.destination) return true; // caught by the destination-required refine
|
|
||||||
// Check if it's a valid CIDR (v4 or v6)
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
const isValidCIDR = z
|
const isValidCIDR = z
|
||||||
.union([z.cidrv4(), z.cidrv6()])
|
.union([z.cidrv4(), z.cidrv6()])
|
||||||
@@ -556,112 +429,25 @@ export const PrivateResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.mode !== "ssh") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authDaemonMode = data["auth-daemon"]?.mode;
|
|
||||||
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueSites = new Set<string>();
|
|
||||||
if (data.site) {
|
|
||||||
uniqueSites.add(data.site);
|
|
||||||
}
|
|
||||||
for (const site of data.sites) {
|
|
||||||
uniqueSites.add(site);
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqueSites.size <= 1;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["sites"],
|
|
||||||
message:
|
|
||||||
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.transform((data) => {
|
|
||||||
if (
|
|
||||||
data.mode === "ssh" &&
|
|
||||||
data.destination !== undefined &&
|
|
||||||
data["destination-port"] === undefined
|
|
||||||
) {
|
|
||||||
data["destination-port"] = 22;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ResourcePolicyRuleSchema = RuleSchema;
|
|
||||||
|
|
||||||
export const ResourcePolicySchema = z.object({
|
|
||||||
name: z.string().min(1).max(255),
|
|
||||||
sso: z.boolean().optional().default(true),
|
|
||||||
"auto-login-idp": z.int().positive().optional().nullable(),
|
|
||||||
"sso-roles": z
|
|
||||||
.array(z.string())
|
|
||||||
.optional()
|
|
||||||
.default([])
|
|
||||||
.refine((roles) => !roles.includes("Admin"), {
|
|
||||||
error: "Admin role cannot be included in sso-roles"
|
|
||||||
}),
|
|
||||||
"sso-users": z.array(z.string()).optional().default([]),
|
|
||||||
password: z.string().min(4).max(100).optional().nullable(),
|
|
||||||
pincode: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{6}$/)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
"basic-auth": z
|
|
||||||
.object({
|
|
||||||
user: z.string().min(4).max(100),
|
|
||||||
password: z.string().min(4).max(100),
|
|
||||||
"extended-compatibility": z.boolean().default(true)
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
"email-whitelist-enabled": z.boolean().optional().default(false),
|
|
||||||
"whitelist-users": z
|
|
||||||
.array(
|
|
||||||
z.email().or(
|
|
||||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
|
||||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.max(50)
|
|
||||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
|
||||||
.optional()
|
|
||||||
.default([]),
|
|
||||||
"apply-rules": z.boolean().optional().default(false),
|
|
||||||
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
|
|
||||||
});
|
|
||||||
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
|
|
||||||
|
|
||||||
// Schema for the entire configuration object
|
// Schema for the entire configuration object
|
||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
"proxy-resources": z
|
"proxy-resources": z
|
||||||
.record(z.string(), PublicResourceSchema)
|
.record(z.string(), ResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"public-resources": z
|
"public-resources": z
|
||||||
.record(z.string(), PublicResourceSchema)
|
.record(z.string(), ResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"client-resources": z
|
"client-resources": z
|
||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), ClientResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"private-resources": z
|
"private-resources": z
|
||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), ClientResourceSchema)
|
||||||
.optional()
|
|
||||||
.prefault({}),
|
|
||||||
"public-policies": z
|
|
||||||
.record(z.string(), ResourcePolicySchema)
|
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
@@ -686,17 +472,10 @@ export const ConfigSchema = z
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data as {
|
return data as {
|
||||||
"proxy-resources": Record<
|
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
|
||||||
string,
|
|
||||||
z.infer<typeof PublicResourceSchema>
|
|
||||||
>;
|
|
||||||
"client-resources": Record<
|
"client-resources": Record<
|
||||||
string,
|
string,
|
||||||
z.infer<typeof PrivateResourceSchema>
|
z.infer<typeof ClientResourceSchema>
|
||||||
>;
|
|
||||||
"public-policies": Record<
|
|
||||||
string,
|
|
||||||
z.infer<typeof ResourcePolicySchema>
|
|
||||||
>;
|
>;
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
@@ -835,6 +614,5 @@ export const ConfigSchema = z
|
|||||||
// Type inference from the schema
|
// Type inference from the schema
|
||||||
export type Site = z.infer<typeof SiteSchema>;
|
export type Site = z.infer<typeof SiteSchema>;
|
||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
export type Resource = z.infer<typeof ResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
|
||||||
|
|||||||
@@ -154,19 +154,8 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get keys with a specific prefix
|
|
||||||
* @param prefix - Key prefix to match
|
|
||||||
* @returns Array of matching keys
|
|
||||||
*/
|
|
||||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
|
||||||
const allKeys = localCache.keys();
|
|
||||||
return allKeys.filter((key) => key.startsWith(prefix));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export const regionalCache = cache; // Alias for compatability with the private version
|
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
db,
|
db,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
primaryDb,
|
|
||||||
roleClients,
|
roleClients,
|
||||||
roles,
|
roles,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -24,427 +23,422 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { tierMatrix } from "./billing/tierMatrix";
|
import { tierMatrix } from "./billing/tierMatrix";
|
||||||
|
|
||||||
type ClientRow = typeof clients.$inferSelect;
|
|
||||||
|
|
||||||
function runQueuedClientAssociationRebuilds(
|
|
||||||
userId: string,
|
|
||||||
queuedClients: ClientRow[]
|
|
||||||
) {
|
|
||||||
if (queuedClients.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueClientsById = new Map<number, ClientRow>();
|
|
||||||
for (const client of queuedClients) {
|
|
||||||
uniqueClientsById.set(client.clientId, client);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const client of uniqueClientsById.values()) {
|
|
||||||
rebuildClientAssociationsFromClient(client).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
|
|
||||||
error
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string
|
userId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trx = primaryDb;
|
const execute = async (transaction: Transaction | typeof db) => {
|
||||||
|
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||||
|
const adminRoleCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof roles.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const exitNodesCache = new Map<
|
||||||
|
string,
|
||||||
|
Awaited<ReturnType<typeof listExitNodes>>
|
||||||
|
>();
|
||||||
|
const isOrgLicensedCache = new Map<string, boolean>();
|
||||||
|
const existingClientCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof clients.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const roleClientAccessCache = new Map<string, boolean>();
|
||||||
|
const userClientAccessCache = new Map<string, boolean>();
|
||||||
|
|
||||||
const queuedAssociationRebuilds: ClientRow[] = [];
|
const getOrgOlmKey = (orgId: string, olmId: string) =>
|
||||||
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
`${orgId}:${olmId}`;
|
||||||
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
|
const getRoleClientKey = (roleId: number, clientId: number) =>
|
||||||
const exitNodesCache = new Map<
|
`${roleId}:${clientId}`;
|
||||||
string,
|
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
||||||
Awaited<ReturnType<typeof listExitNodes>>
|
`${cachedUserId}:${clientId}`;
|
||||||
>();
|
|
||||||
const isOrgLicensedCache = new Map<string, boolean>();
|
|
||||||
const existingClientCache = new Map<
|
|
||||||
string,
|
|
||||||
typeof clients.$inferSelect | null
|
|
||||||
>();
|
|
||||||
const roleClientAccessCache = new Map<string, boolean>();
|
|
||||||
const userClientAccessCache = new Map<string, boolean>();
|
|
||||||
|
|
||||||
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
|
const getOrg = async (orgId: string) => {
|
||||||
const getRoleClientKey = (roleId: number, clientId: number) =>
|
if (orgCache.has(orgId)) {
|
||||||
`${roleId}:${clientId}`;
|
return orgCache.get(orgId) ?? null;
|
||||||
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
|
||||||
`${cachedUserId}:${clientId}`;
|
|
||||||
|
|
||||||
const getOrg = async (orgId: string) => {
|
|
||||||
if (orgCache.has(orgId)) {
|
|
||||||
return orgCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [org] = await trx
|
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
orgCache.set(orgId, org ?? null);
|
|
||||||
|
|
||||||
return org ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAdminRole = async (orgId: string) => {
|
|
||||||
if (adminRoleCache.has(orgId)) {
|
|
||||||
return adminRoleCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
adminRoleCache.set(orgId, adminRole ?? null);
|
|
||||||
|
|
||||||
return adminRole ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExitNodes = async (orgId: string) => {
|
|
||||||
if (exitNodesCache.has(orgId)) {
|
|
||||||
return exitNodesCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodes = await listExitNodes(orgId);
|
|
||||||
exitNodesCache.set(orgId, exitNodes);
|
|
||||||
|
|
||||||
return exitNodes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIsOrgLicensed = async (orgId: string) => {
|
|
||||||
if (isOrgLicensedCache.has(orgId)) {
|
|
||||||
return isOrgLicensedCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
);
|
|
||||||
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
|
||||||
|
|
||||||
return isOrgLicensed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExistingClient = async (orgId: string, olmId: string) => {
|
|
||||||
const key = getOrgOlmKey(orgId, olmId);
|
|
||||||
if (existingClientCache.has(key)) {
|
|
||||||
return existingClientCache.get(key) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clients.userId, userId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
eq(clients.olmId, olmId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
existingClientCache.set(key, existingClient ?? null);
|
|
||||||
|
|
||||||
return existingClient ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
|
|
||||||
const key = getRoleClientKey(roleId, clientId);
|
|
||||||
if (roleClientAccessCache.has(key)) {
|
|
||||||
return roleClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingRoleClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roleClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleClients.roleId, roleId),
|
|
||||||
eq(roleClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingRoleClient);
|
|
||||||
roleClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUserClientAccess = async (
|
|
||||||
cachedUserId: string,
|
|
||||||
clientId: number
|
|
||||||
) => {
|
|
||||||
const key = getUserClientKey(cachedUserId, clientId);
|
|
||||||
if (userClientAccessCache.has(key)) {
|
|
||||||
return userClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingUserClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(userClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userClients.userId, cachedUserId),
|
|
||||||
eq(userClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingUserClient);
|
|
||||||
userClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all OLMs for this user
|
|
||||||
const userOlms = await trx
|
|
||||||
.select()
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.userId, userId));
|
|
||||||
|
|
||||||
if (userOlms.length === 0) {
|
|
||||||
// No OLMs for this user, but we should still clean up any orphaned clients
|
|
||||||
await cleanupOrphanedClients(
|
|
||||||
userId,
|
|
||||||
trx,
|
|
||||||
[],
|
|
||||||
queuedAssociationRebuilds
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all user orgs with all roles (for org list and role-based logic)
|
|
||||||
const userOrgRoleRows = await trx
|
|
||||||
.select()
|
|
||||||
.from(userOrgs)
|
|
||||||
.innerJoin(
|
|
||||||
userOrgRoles,
|
|
||||||
and(
|
|
||||||
eq(userOrgs.userId, userOrgRoles.userId),
|
|
||||||
eq(userOrgs.orgId, userOrgRoles.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
|
||||||
.where(eq(userOrgs.userId, userId));
|
|
||||||
|
|
||||||
const userOrgIds = [
|
|
||||||
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
|
||||||
];
|
|
||||||
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
|
|
||||||
for (const r of userOrgRoleRows) {
|
|
||||||
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
|
||||||
list.push(r);
|
|
||||||
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
|
||||||
}
|
|
||||||
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
|
||||||
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
|
||||||
orgRequiresDeviceApprovalRole.set(
|
|
||||||
orgId,
|
|
||||||
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each OLM, ensure there's a client in each org the user is in
|
|
||||||
for (const olm of userOlms) {
|
|
||||||
for (const orgId of orgIdToRoleRows.keys()) {
|
|
||||||
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
|
||||||
const userOrg = roleRowsForOrg[0].userOrgs;
|
|
||||||
|
|
||||||
const org = await getOrg(orgId);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org.subnet) {
|
const [org] = await transaction
|
||||||
logger.warn(
|
.select()
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
.from(orgs)
|
||||||
);
|
.where(eq(orgs.orgId, orgId));
|
||||||
continue;
|
orgCache.set(orgId, org ?? null);
|
||||||
|
|
||||||
|
return org ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminRole = async (orgId: string) => {
|
||||||
|
if (adminRoleCache.has(orgId)) {
|
||||||
|
return adminRoleCache.get(orgId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin role for this org (needed for access grants)
|
const [adminRole] = await transaction
|
||||||
const adminRole = await getAdminRole(orgId);
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
adminRoleCache.set(orgId, adminRole ?? null);
|
||||||
|
|
||||||
if (!adminRole) {
|
return adminRole ?? null;
|
||||||
logger.warn(
|
};
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
|
||||||
);
|
const getExitNodes = async (orgId: string) => {
|
||||||
continue;
|
if (exitNodesCache.has(orgId)) {
|
||||||
|
return exitNodesCache.get(orgId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a client already exists for this OLM+user+org combination
|
const exitNodes = await listExitNodes(orgId);
|
||||||
const existingClient = await getExistingClient(orgId, olm.olmId);
|
exitNodesCache.set(orgId, exitNodes);
|
||||||
|
|
||||||
if (existingClient) {
|
return exitNodes;
|
||||||
// Ensure admin role has access to the client
|
};
|
||||||
const hasRoleAccess = await hasRoleClientAccess(
|
|
||||||
adminRole.roleId,
|
|
||||||
existingClient.clientId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasRoleAccess) {
|
const getIsOrgLicensed = async (orgId: string) => {
|
||||||
await trx.insert(roleClients).values({
|
if (isOrgLicensedCache.has(orgId)) {
|
||||||
roleId: adminRole.roleId,
|
return isOrgLicensedCache.get(orgId)!;
|
||||||
clientId: existingClient.clientId
|
}
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
getRoleClientKey(
|
orgId,
|
||||||
adminRole.roleId,
|
tierMatrix.deviceApprovals
|
||||||
existingClient.clientId
|
);
|
||||||
),
|
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
||||||
true
|
|
||||||
);
|
return isOrgLicensed;
|
||||||
logger.debug(
|
};
|
||||||
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
|
||||||
|
const getExistingClient = async (orgId: string, olmId: string) => {
|
||||||
|
const key = getOrgOlmKey(orgId, olmId);
|
||||||
|
if (existingClientCache.has(key)) {
|
||||||
|
return existingClientCache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
eq(clients.olmId, olmId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
existingClientCache.set(key, existingClient ?? null);
|
||||||
|
|
||||||
|
return existingClient ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRoleClientAccess = async (
|
||||||
|
roleId: number,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getRoleClientKey(roleId, clientId);
|
||||||
|
if (roleClientAccessCache.has(key)) {
|
||||||
|
return roleClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingRoleClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(roleClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleClients.roleId, roleId),
|
||||||
|
eq(roleClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingRoleClient);
|
||||||
|
roleClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserClientAccess = async (
|
||||||
|
cachedUserId: string,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getUserClientKey(cachedUserId, clientId);
|
||||||
|
if (userClientAccessCache.has(key)) {
|
||||||
|
return userClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUserClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userClients.userId, cachedUserId),
|
||||||
|
eq(userClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingUserClient);
|
||||||
|
userClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all OLMs for this user
|
||||||
|
const userOlms = await transaction
|
||||||
|
.select()
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.userId, userId));
|
||||||
|
|
||||||
|
if (userOlms.length === 0) {
|
||||||
|
// No OLMs for this user, but we should still clean up any orphaned clients
|
||||||
|
await cleanupOrphanedClients(userId, transaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user orgs with all roles (for org list and role-based logic)
|
||||||
|
const userOrgRoleRows = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.innerJoin(
|
||||||
|
userOrgRoles,
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userOrgRoles.userId),
|
||||||
|
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
|
const userOrgIds = [
|
||||||
|
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
||||||
|
];
|
||||||
|
const orgIdToRoleRows = new Map<
|
||||||
|
string,
|
||||||
|
(typeof userOrgRoleRows)[0][]
|
||||||
|
>();
|
||||||
|
for (const r of userOrgRoleRows) {
|
||||||
|
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
||||||
|
}
|
||||||
|
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
||||||
|
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
||||||
|
orgRequiresDeviceApprovalRole.set(
|
||||||
|
orgId,
|
||||||
|
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each OLM, ensure there's a client in each org the user is in
|
||||||
|
for (const olm of userOlms) {
|
||||||
|
for (const orgId of orgIdToRoleRows.keys()) {
|
||||||
|
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
||||||
|
const userOrg = roleRowsForOrg[0].userOrgs;
|
||||||
|
|
||||||
|
const org = await getOrg(orgId);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user has access to the client
|
if (!org.subnet) {
|
||||||
const hasUserAccess = await hasUserClientAccess(
|
logger.warn(
|
||||||
userId,
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
||||||
existingClient.clientId
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role for this org (needed for access grants)
|
||||||
|
const adminRole = await getAdminRole(orgId);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a client already exists for this OLM+user+org combination
|
||||||
|
const existingClient = await getExistingClient(
|
||||||
|
orgId,
|
||||||
|
olm.olmId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasUserAccess) {
|
if (existingClient) {
|
||||||
await trx.insert(userClients).values({
|
// Ensure admin role has access to the client
|
||||||
|
const hasRoleAccess = await hasRoleClientAccess(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRoleAccess) {
|
||||||
|
await transaction.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user has access to the client
|
||||||
|
const hasUserAccess = await hasUserClientAccess(
|
||||||
userId,
|
userId,
|
||||||
clientId: existingClient.clientId
|
existingClient.clientId
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, existingClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasUserAccess) {
|
||||||
|
await transaction.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, existingClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get exit nodes for this org
|
||||||
|
const exitNodesList = await getExitNodes(orgId);
|
||||||
|
|
||||||
|
if (exitNodesList.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomExitNode =
|
||||||
|
exitNodesList[
|
||||||
|
Math.floor(Math.random() * exitNodesList.length)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get next available subnet
|
||||||
|
const newSubnet = await getNextAvailableClientSubnet(
|
||||||
|
orgId,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
if (!newSubnet) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnet = newSubnet.split("/")[0];
|
||||||
|
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
||||||
|
|
||||||
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
|
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
||||||
|
const requireApproval =
|
||||||
|
build !== "oss" &&
|
||||||
|
isOrgLicensed &&
|
||||||
|
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
||||||
|
|
||||||
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
|
userId,
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
exitNodeId: randomExitNode.exitNodeId,
|
||||||
|
name: olm.name || "User Client",
|
||||||
|
subnet: updatedSubnet,
|
||||||
|
olmId: olm.olmId,
|
||||||
|
type: "olm",
|
||||||
|
niceId,
|
||||||
|
approvalState: requireApproval ? "pending" : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the client
|
||||||
|
const [newClient] = await transaction
|
||||||
|
.insert(clients)
|
||||||
|
.values(newClientData)
|
||||||
|
.returning();
|
||||||
|
existingClientCache.set(
|
||||||
|
getOrgOlmKey(orgId, olm.olmId),
|
||||||
|
newClient
|
||||||
|
);
|
||||||
|
|
||||||
|
// create approval request
|
||||||
|
if (requireApproval) {
|
||||||
|
await transaction
|
||||||
|
.insert(approvals)
|
||||||
|
.values({
|
||||||
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
clientId: newClient.clientId,
|
||||||
|
userId,
|
||||||
|
type: "user_device"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(
|
||||||
|
newClient,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant admin role access to the client
|
||||||
|
await transaction.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant user access to the client
|
||||||
|
await transaction.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get exit nodes for this org
|
|
||||||
const exitNodesList = await getExitNodes(orgId);
|
|
||||||
|
|
||||||
if (exitNodesList.length === 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
// Get next available subnet
|
|
||||||
const { value: newSubnet, release: releaseSubnetLock } =
|
|
||||||
await getNextAvailableClientSubnet(orgId, trx);
|
|
||||||
|
|
||||||
const subnet = newSubnet.split("/")[0];
|
|
||||||
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
|
||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
|
||||||
|
|
||||||
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
|
||||||
const requireApproval =
|
|
||||||
build !== "oss" &&
|
|
||||||
isOrgLicensed &&
|
|
||||||
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
|
||||||
|
|
||||||
const newClientData: InferInsertModel<typeof clients> = {
|
|
||||||
userId,
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
|
||||||
subnet: updatedSubnet,
|
|
||||||
olmId: olm.olmId,
|
|
||||||
type: "olm",
|
|
||||||
niceId,
|
|
||||||
approvalState: requireApproval ? "pending" : null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the client
|
|
||||||
const [newClient] = await trx
|
|
||||||
.insert(clients)
|
|
||||||
.values(newClientData)
|
|
||||||
.returning();
|
|
||||||
await releaseSubnetLock();
|
|
||||||
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
|
|
||||||
|
|
||||||
// create approval request
|
|
||||||
if (requireApproval) {
|
|
||||||
await trx
|
|
||||||
.insert(approvals)
|
|
||||||
.values({
|
|
||||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
clientId: newClient.clientId,
|
|
||||||
userId,
|
|
||||||
type: "user_device"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
queuedAssociationRebuilds.push(newClient);
|
|
||||||
|
|
||||||
// Grant admin role access to the client
|
|
||||||
await trx.insert(roleClients).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
|
||||||
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grant user access to the client
|
|
||||||
await trx.insert(userClients).values({
|
|
||||||
userId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up clients in orgs the user is no longer in
|
||||||
|
await cleanupOrphanedClients(userId, transaction, userOrgIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trx) {
|
||||||
|
// Use provided transaction
|
||||||
|
await execute(trx);
|
||||||
|
} else {
|
||||||
|
// Create new transaction
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
await execute(transaction);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up clients in orgs the user is no longer in
|
|
||||||
await cleanupOrphanedClients(
|
|
||||||
userId,
|
|
||||||
trx,
|
|
||||||
userOrgIds,
|
|
||||||
queuedAssociationRebuilds
|
|
||||||
);
|
|
||||||
|
|
||||||
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOrphanedClients(
|
async function cleanupOrphanedClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction | typeof db,
|
trx: Transaction | typeof db,
|
||||||
userOrgIds: string[] = [],
|
userOrgIds: string[] = []
|
||||||
queuedAssociationRebuilds: ClientRow[] = []
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Find all OLM clients for this user that should be deleted
|
// Find all OLM clients for this user that should be deleted
|
||||||
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
||||||
@@ -474,9 +468,9 @@ async function cleanupOrphanedClients(
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Queue deleted clients for post-trx association cleanup.
|
// Rebuild associations for each deleted client to clean up related data
|
||||||
for (const deletedClient of deletedClients) {
|
for (const deletedClient of deletedClients) {
|
||||||
queuedAssociationRebuilds.push(deletedClient);
|
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
||||||
|
|
||||||
if (deletedClient.olmId) {
|
if (deletedClient.olmId) {
|
||||||
await sendTerminateClient(
|
await sendTerminateClient(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.20.0";
|
export const APP_VERSION = "1.18.3";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
|
||||||
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
|
|
||||||
* retry: the database guarantees exactly one winner per deadlock pair, so the
|
|
||||||
* loser just needs to try again.
|
|
||||||
*/
|
|
||||||
export function isTransientError(error: any): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
|
||||||
const code = error.code || error.cause?.code || "";
|
|
||||||
|
|
||||||
// Connection timeout / terminated
|
|
||||||
if (
|
|
||||||
message.includes("connection timeout") ||
|
|
||||||
message.includes("connection terminated") ||
|
|
||||||
message.includes("timeout exceeded when trying to connect") ||
|
|
||||||
causeMessage.includes("connection terminated unexpectedly") ||
|
|
||||||
causeMessage.includes("connection timeout")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL serialization failure
|
|
||||||
if (code === "40001") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
|
||||||
if (
|
|
||||||
code === "ECONNRESET" ||
|
|
||||||
code === "ECONNREFUSED" ||
|
|
||||||
code === "EPIPE" ||
|
|
||||||
code === "ETIMEDOUT"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
|
||||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
|
||||||
*/
|
|
||||||
export async function withRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
context: string,
|
|
||||||
maxRetries: number = MAX_RETRIES,
|
|
||||||
baseDelayMs: number = BASE_DELAY_MS
|
|
||||||
): Promise<T> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isTransientError(error) && attempt < maxRetries) {
|
|
||||||
attempt++;
|
|
||||||
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
|
||||||
const jitter = Math.random() * baseDelay;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
logger.warn(
|
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||||
import { usageService } from "./billing/usageService";
|
import { usageService } from "./billing/usageService";
|
||||||
import { LimitId } from "./billing";
|
import { FeatureId } from "./billing";
|
||||||
|
|
||||||
export type DeleteOrgByIdResult = {
|
export type DeleteOrgByIdResult = {
|
||||||
deletedNewtIds: string[];
|
deletedNewtIds: string[];
|
||||||
@@ -140,9 +140,7 @@ export async function deleteOrgById(
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
logger.info(
|
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
|
||||||
`Found ${orgCount.count} orgs using domain ${domainId}`
|
|
||||||
);
|
|
||||||
if (orgCount.count === 1) {
|
if (orgCount.count === 1) {
|
||||||
domainIdsToDelete.push(domainId);
|
domainIdsToDelete.push(domainId);
|
||||||
}
|
}
|
||||||
@@ -154,7 +152,7 @@ export async function deleteOrgById(
|
|||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
}
|
}
|
||||||
|
|
||||||
await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||||
|
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
@@ -201,22 +199,22 @@ export async function deleteOrgById(
|
|||||||
if (org.billingOrgId) {
|
if (org.billingOrgId) {
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.DOMAINS,
|
FeatureId.DOMAINS,
|
||||||
domainCount ?? 0
|
domainCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.SITES,
|
FeatureId.SITES,
|
||||||
siteCount ?? 0
|
siteCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.USERS,
|
FeatureId.USERS,
|
||||||
userCount ?? 0
|
userCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.REMOTE_EXIT_NODES,
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
remoteExitNodeCount ?? 0
|
remoteExitNodeCount ?? 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
resourcePolicies,
|
|
||||||
resources,
|
|
||||||
sites,
|
|
||||||
targetHealthCheck,
|
|
||||||
targets,
|
|
||||||
type Resource,
|
|
||||||
type Target,
|
|
||||||
type TargetHealthCheck,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { removeTargets } from "@server/routers/newt/targets";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
|
|
||||||
export type DeleteResourceResult = {
|
|
||||||
deletedResource: Resource;
|
|
||||||
targetsToBeRemoved: Target[];
|
|
||||||
healthChecksToBeRemoved: TargetHealthCheck[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function performDeleteResources(
|
|
||||||
resourceIds: number[],
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteResourceResult[]> {
|
|
||||||
if (resourceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetsToBeRemoved = await trx
|
|
||||||
.select()
|
|
||||||
.from(targets)
|
|
||||||
.where(inArray(targets.resourceId, resourceIds));
|
|
||||||
|
|
||||||
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
|
|
||||||
const healthChecksToBeRemoved =
|
|
||||||
targetIds.length > 0
|
|
||||||
? await trx
|
|
||||||
.select()
|
|
||||||
.from(targetHealthCheck)
|
|
||||||
.where(inArray(targetHealthCheck.targetId, targetIds))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const deletedResources = await trx
|
|
||||||
.delete(resources)
|
|
||||||
.where(inArray(resources.resourceId, resourceIds))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const policyIds = deletedResources
|
|
||||||
.map((resource) => resource.defaultResourcePolicyId)
|
|
||||||
.filter((id): id is number => id != null);
|
|
||||||
|
|
||||||
if (policyIds.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicies)
|
|
||||||
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedResources.length > 0) {
|
|
||||||
logger.debug(`Deleted ${deletedResources.length} resources`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetsByResourceId = new Map<number, Target[]>();
|
|
||||||
for (const target of targetsToBeRemoved) {
|
|
||||||
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
|
||||||
existing.push(target);
|
|
||||||
targetsByResourceId.set(target.resourceId, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetIdToResourceId = new Map(
|
|
||||||
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
|
||||||
for (const healthCheck of healthChecksToBeRemoved) {
|
|
||||||
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
|
|
||||||
if (resourceId == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existing = healthChecksByResourceId.get(resourceId) ?? [];
|
|
||||||
existing.push(healthCheck);
|
|
||||||
healthChecksByResourceId.set(resourceId, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedResources.map((deletedResource) => ({
|
|
||||||
deletedResource,
|
|
||||||
targetsToBeRemoved:
|
|
||||||
targetsByResourceId.get(deletedResource.resourceId) ?? [],
|
|
||||||
healthChecksToBeRemoved:
|
|
||||||
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performDeleteResource(
|
|
||||||
resourceId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteResourceResult | null> {
|
|
||||||
const [result] = await performDeleteResources([resourceId], trx);
|
|
||||||
return result ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runResourceDeleteSideEffects(
|
|
||||||
result: DeleteResourceResult
|
|
||||||
): Promise<void> {
|
|
||||||
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
|
|
||||||
result;
|
|
||||||
|
|
||||||
for (const target of targetsToBeRemoved) {
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, target.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
throw createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with ID ${target.siteId} not found`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (site.pubKey && site.type === "newt") {
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
await removeTargets(
|
|
||||||
newt.newtId,
|
|
||||||
[],
|
|
||||||
healthChecksToBeRemoved,
|
|
||||||
deletedResource.mode === "udp" ? "udp" : "tcp",
|
|
||||||
newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
siteNetworks,
|
|
||||||
siteResources,
|
|
||||||
targets,
|
|
||||||
type SiteResource,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import {
|
|
||||||
performDeleteResources,
|
|
||||||
runResourceDeleteSideEffects,
|
|
||||||
type DeleteResourceResult
|
|
||||||
} from "@server/lib/deleteResource";
|
|
||||||
import {
|
|
||||||
performDeleteSiteResources,
|
|
||||||
runSiteResourceDeleteSideEffects
|
|
||||||
} from "@server/lib/deleteSiteResource";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
|
|
||||||
|
|
||||||
export type DeleteSiteAssociatedResourcesSideEffects = {
|
|
||||||
resources: DeleteResourceResult[];
|
|
||||||
siteResources: SiteResource[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getResourceIdsForSite(
|
|
||||||
siteId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number[]> {
|
|
||||||
const rows = await trx
|
|
||||||
.selectDistinct({ resourceId: targets.resourceId })
|
|
||||||
.from(targets)
|
|
||||||
.where(eq(targets.siteId, siteId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.resourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSiteResourceIdsForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number[]> {
|
|
||||||
const rows = await trx
|
|
||||||
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
|
||||||
.from(siteNetworks)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(siteResources.networkId, siteNetworks.networkId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => row.siteResourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAssociatedResourceCountForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number> {
|
|
||||||
const [publicCountResult, privateCountResult] = await Promise.all([
|
|
||||||
trx
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(distinct ${targets.resourceId})`
|
|
||||||
})
|
|
||||||
.from(targets)
|
|
||||||
.where(eq(targets.siteId, siteId)),
|
|
||||||
trx
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
|
|
||||||
})
|
|
||||||
.from(siteNetworks)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(siteResources.networkId, siteNetworks.networkId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteNetworks.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
Number(publicCountResult[0]?.count ?? 0) +
|
|
||||||
Number(privateCountResult[0]?.count ?? 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exceedsSiteAssociatedResourceDeleteLimit(
|
|
||||||
resourceCount: number
|
|
||||||
): boolean {
|
|
||||||
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAssociatedResourcesForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
|
||||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
|
||||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
|
||||||
|
|
||||||
const [resources, siteResourcesDeleted] = await Promise.all([
|
|
||||||
performDeleteResources(resourceIds, trx),
|
|
||||||
performDeleteSiteResources(siteResourceIds, trx)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { resources, siteResources: siteResourcesDeleted };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
|
||||||
sideEffects: DeleteSiteAssociatedResourcesSideEffects
|
|
||||||
): Promise<void> {
|
|
||||||
for (const result of sideEffects.resources) {
|
|
||||||
await runResourceDeleteSideEffects(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const removed of sideEffects.siteResources) {
|
|
||||||
runSiteResourceDeleteSideEffects(removed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { inArray } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
siteResources,
|
|
||||||
type SiteResource,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
export async function performDeleteSiteResources(
|
|
||||||
siteResourceIds: number[],
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<SiteResource[]> {
|
|
||||||
if (siteResourceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedSiteResources = await trx
|
|
||||||
.delete(siteResources)
|
|
||||||
.where(inArray(siteResources.siteResourceId, siteResourceIds))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (removedSiteResources.length > 0) {
|
|
||||||
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return removedSiteResources;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performDeleteSiteResource(
|
|
||||||
siteResourceId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<SiteResource | null> {
|
|
||||||
const [removedSiteResource] = await performDeleteSiteResources(
|
|
||||||
[siteResourceId],
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
return removedSiteResource ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runSiteResourceDeleteSideEffects(
|
|
||||||
removedSiteResource: SiteResource
|
|
||||||
): void {
|
|
||||||
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
|
|
||||||
(err) => {
|
|
||||||
logger.error(
|
|
||||||
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,11 +19,7 @@ export async function verifyExitNodeOrgAccess(
|
|||||||
export async function listExitNodes(
|
export async function listExitNodes(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
filterOnline = false,
|
filterOnline = false,
|
||||||
noCloud = false,
|
noCloud = false
|
||||||
// Accepted for parity with the enterprise implementation (used there for
|
|
||||||
// site-label filtering of remote exit nodes). The OSS build has no remote
|
|
||||||
// exit nodes, so it is unused here.
|
|
||||||
siteId?: number
|
|
||||||
) {
|
) {
|
||||||
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
||||||
const allExitNodes = await db
|
const allExitNodes = await db
|
||||||
|
|||||||
@@ -1,55 +1,30 @@
|
|||||||
import { db, exitNodes, Transaction } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
|
|
||||||
/**
|
export async function getNextAvailableSubnet(): Promise<string> {
|
||||||
* Reserves the next available exit node subnet.
|
// Get all existing subnets from routes table
|
||||||
*
|
const existingAddresses = await db
|
||||||
* Exit node subnets must never overlap with one another - regardless of
|
.select({
|
||||||
* which org(s) they belong to - since HA exit nodes can end up routing for
|
address: exitNodes.address
|
||||||
* the same org. This acquires a lock that the caller MUST release (via the
|
})
|
||||||
* returned `release`) only after the chosen address has been durably
|
.from(exitNodes);
|
||||||
* persisted (e.g. after the enclosing transaction commits), otherwise
|
|
||||||
* concurrent callers can race and pick the same subnet.
|
const addresses = existingAddresses.map((a) => a.address);
|
||||||
*/
|
let subnet = findNextAvailableCidr(
|
||||||
export async function getNextAvailableSubnet(
|
addresses,
|
||||||
trx: Transaction | typeof db = db
|
config.getRawConfig().gerbil.block_size,
|
||||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
config.getRawConfig().gerbil.subnet_group
|
||||||
const lockKey = "exit-node-subnet-allocation";
|
);
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
if (!subnet) {
|
||||||
if (!acquired) {
|
throw new Error("No available subnets remaining in space");
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
|
||||||
}
|
}
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
|
||||||
|
|
||||||
try {
|
// replace the last octet with 1
|
||||||
// Get all existing subnets from routes table
|
subnet =
|
||||||
const existingAddresses = await trx
|
subnet.split(".").slice(0, 3).join(".") +
|
||||||
.select({
|
".1" +
|
||||||
address: exitNodes.address
|
"/" +
|
||||||
})
|
subnet.split("/")[1];
|
||||||
.from(exitNodes);
|
return subnet;
|
||||||
|
|
||||||
const addresses = existingAddresses.map((a) => a.address);
|
|
||||||
let subnet = findNextAvailableCidr(
|
|
||||||
addresses,
|
|
||||||
config.getRawConfig().gerbil.block_size,
|
|
||||||
config.getRawConfig().gerbil.subnet_group
|
|
||||||
);
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace the last octet with 1
|
|
||||||
subnet =
|
|
||||||
subnet.split(".").slice(0, 3).join(".") +
|
|
||||||
".1" +
|
|
||||||
"/" +
|
|
||||||
subnet.split("/")[1];
|
|
||||||
return { value: subnet, release };
|
|
||||||
} catch (e) {
|
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-159
@@ -327,145 +327,127 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
|
|||||||
export async function getNextAvailableClientSubnet(
|
export async function getNextAvailableClientSubnet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
transaction: Transaction | typeof db = db
|
transaction: Transaction | typeof db = db
|
||||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
): Promise<string> {
|
||||||
const lockKey = `client-subnet-allocation:${orgId}`;
|
return await lockManager.withLock(
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
`client-subnet-allocation:${orgId}`,
|
||||||
if (!acquired) {
|
async () => {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
const [org] = await transaction
|
||||||
}
|
.select()
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
try {
|
if (!org) {
|
||||||
const [org] = await transaction
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
.select()
|
}
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
if (!org.subnet) {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAddressesSites = await transaction
|
||||||
|
.select({
|
||||||
|
address: sites.address
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
||||||
|
|
||||||
|
const existingAddressesClients = await transaction
|
||||||
|
.select({
|
||||||
|
address: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const addresses = [
|
||||||
|
...existingAddressesSites.map(
|
||||||
|
(site) => `${site.address?.split("/")[0]}/32`
|
||||||
|
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||||
|
...existingAddressesClients.map(
|
||||||
|
(client) => `${client.address.split("/")}/32`
|
||||||
|
)
|
||||||
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
|
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
return subnet;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (!org.subnet) {
|
|
||||||
throw new Error(
|
|
||||||
`Organization with ID ${orgId} has no subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAddressesSites = await transaction
|
|
||||||
.select({
|
|
||||||
address: sites.address
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
|
||||||
|
|
||||||
const existingAddressesClients = await transaction
|
|
||||||
.select({
|
|
||||||
address: clients.subnet
|
|
||||||
})
|
|
||||||
.from(clients)
|
|
||||||
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
|
||||||
|
|
||||||
const addresses = [
|
|
||||||
...existingAddressesSites.map(
|
|
||||||
(site) => `${site.address?.split("/")[0]}/32`
|
|
||||||
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
|
||||||
...existingAddressesClients.map(
|
|
||||||
(client) => `${client.address.split("/")[0]}/32`
|
|
||||||
)
|
|
||||||
].filter((address) => address !== null) as string[];
|
|
||||||
|
|
||||||
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { value: subnet, release };
|
|
||||||
} catch (e) {
|
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableAliasAddress(
|
export async function getNextAvailableAliasAddress(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
): Promise<string> {
|
||||||
const lockKey = `alias-address-allocation:${orgId}`;
|
return await lockManager.withLock(
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
`alias-address-allocation:${orgId}`,
|
||||||
if (!acquired) {
|
async () => {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
const [org] = await trx
|
||||||
}
|
.select()
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
try {
|
if (!org) {
|
||||||
const [org] = await trx
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
.select()
|
}
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
if (!org.subnet) {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
throw new Error(
|
||||||
}
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org.utilitySubnet) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Organization with ID ${orgId} has no subnet defined`
|
`Organization with ID ${orgId} has no utility subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAddresses = await trx
|
||||||
|
.select({
|
||||||
|
aliasAddress: siteResources.aliasAddress
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNotNull(siteResources.aliasAddress),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const addresses = [
|
||||||
|
...existingAddresses.map(
|
||||||
|
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
||||||
|
),
|
||||||
|
// reserve a /29 for the dns server and other stuff
|
||||||
|
`${org.utilitySubnet.split("/")[0]}/29`
|
||||||
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
|
let subnet = findNextAvailableCidr(
|
||||||
|
addresses,
|
||||||
|
32,
|
||||||
|
org.utilitySubnet
|
||||||
);
|
);
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the cidr
|
||||||
|
subnet = subnet.split("/")[0];
|
||||||
|
|
||||||
|
return subnet;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (!org.utilitySubnet) {
|
|
||||||
throw new Error(
|
|
||||||
`Organization with ID ${orgId} has no utility subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAddresses = await trx
|
|
||||||
.select({
|
|
||||||
aliasAddress: siteResources.aliasAddress
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNotNull(siteResources.aliasAddress),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const addresses = [
|
|
||||||
...existingAddresses.map(
|
|
||||||
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
|
||||||
),
|
|
||||||
// reserve a /29 for the dns server and other stuff
|
|
||||||
`${org.utilitySubnet.split("/")[0]}/29`
|
|
||||||
].filter((address) => address !== null) as string[];
|
|
||||||
|
|
||||||
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove the cidr
|
|
||||||
subnet = subnet.split("/")[0];
|
|
||||||
|
|
||||||
return { value: subnet, release };
|
|
||||||
} catch (e) {
|
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableOrgSubnet(): Promise<{
|
export async function getNextAvailableOrgSubnet(): Promise<string> {
|
||||||
value: string;
|
return await lockManager.withLock("org-subnet-allocation", async () => {
|
||||||
release: () => Promise<void>;
|
|
||||||
}> {
|
|
||||||
const lockKey = "org-subnet-allocation";
|
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
|
||||||
if (!acquired) {
|
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
|
||||||
}
|
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existingAddresses = await db
|
const existingAddresses = await db
|
||||||
.select({
|
.select({
|
||||||
subnet: orgs.subnet
|
subnet: orgs.subnet
|
||||||
@@ -484,11 +466,8 @@ export async function getNextAvailableOrgSubnet(): Promise<{
|
|||||||
throw new Error("No available subnets remaining in space");
|
throw new Error("No available subnets remaining in space");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { value: subnet, release };
|
return subnet;
|
||||||
} catch (e) {
|
});
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRemoteSubnets(
|
export function generateRemoteSubnets(
|
||||||
@@ -496,15 +475,13 @@ export function generateRemoteSubnets(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const remoteSubnets = allSiteResources
|
const remoteSubnets = allSiteResources
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
if (!sr.destination) return false;
|
|
||||||
|
|
||||||
if (sr.mode === "cidr") {
|
if (sr.mode === "cidr") {
|
||||||
// check if its a valid CIDR using zod
|
// check if its a valid CIDR using zod
|
||||||
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
||||||
const parseResult = cidrSchema.safeParse(sr.destination);
|
const parseResult = cidrSchema.safeParse(sr.destination);
|
||||||
return parseResult.success;
|
return parseResult.success;
|
||||||
}
|
}
|
||||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
if (sr.mode === "host") {
|
||||||
// check if its a valid IP using zod
|
// check if its a valid IP using zod
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
const parseResult = ipSchema.safeParse(sr.destination);
|
const parseResult = ipSchema.safeParse(sr.destination);
|
||||||
@@ -514,12 +491,12 @@ export function generateRemoteSubnets(
|
|||||||
})
|
})
|
||||||
.map((sr) => {
|
.map((sr) => {
|
||||||
if (sr.mode === "cidr") return sr.destination;
|
if (sr.mode === "cidr") return sr.destination;
|
||||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
if (sr.mode === "host") {
|
||||||
return `${sr.destination}/32`;
|
return `${sr.destination}/32`;
|
||||||
}
|
}
|
||||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||||
})
|
})
|
||||||
.filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
|
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe
|
||||||
// remove duplicates
|
// remove duplicates
|
||||||
return Array.from(new Set(remoteSubnets));
|
return Array.from(new Set(remoteSubnets));
|
||||||
}
|
}
|
||||||
@@ -531,7 +508,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
|||||||
.filter(
|
.filter(
|
||||||
(sr) =>
|
(sr) =>
|
||||||
sr.aliasAddress &&
|
sr.aliasAddress &&
|
||||||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
((sr.alias && sr.mode == "host") ||
|
||||||
(sr.fullDomain && sr.mode == "http"))
|
(sr.fullDomain && sr.mode == "http"))
|
||||||
)
|
)
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
@@ -577,10 +554,6 @@ export function generateSubnetProxyTargets(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResource.destination) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -588,7 +561,7 @@ export function generateSubnetProxyTargets(
|
|||||||
];
|
];
|
||||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
if (siteResource.mode == "host") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
// check if this is a valid ip
|
// check if this is a valid ip
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
@@ -608,7 +581,7 @@ export function generateSubnetProxyTargets(
|
|||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination!,
|
rewriteTo: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp
|
disableIcmp
|
||||||
});
|
});
|
||||||
@@ -616,7 +589,7 @@ export function generateSubnetProxyTargets(
|
|||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: siteResource.destination!,
|
destPrefix: siteResource.destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp
|
disableIcmp
|
||||||
});
|
});
|
||||||
@@ -669,12 +642,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResource.destination) {
|
let targets: SubnetProxyTargetV2[] = [];
|
||||||
// ssh can have no destination
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets: SubnetProxyTargetV2[] = [];
|
|
||||||
|
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -682,7 +650,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
];
|
];
|
||||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
if (siteResource.mode == "host") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
// check if this is a valid ip
|
// check if this is a valid ip
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
@@ -703,7 +671,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefixes: [],
|
sourcePrefixes: [],
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination!,
|
rewriteTo: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId
|
resourceId: siteResource.siteResourceId
|
||||||
@@ -712,7 +680,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefixes: [],
|
sourcePrefixes: [],
|
||||||
destPrefix: siteResource.destination!,
|
destPrefix: siteResource.destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId
|
resourceId: siteResource.siteResourceId
|
||||||
@@ -770,7 +738,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
protocol: siteResource.ssl ? "https" : "http",
|
protocol: siteResource.ssl ? "https" : "http",
|
||||||
httpTargets: [
|
httpTargets: [
|
||||||
{
|
{
|
||||||
destAddr: siteResource.destination!,
|
destAddr: siteResource.destination,
|
||||||
destPort: siteResource.destinationPort,
|
destPort: siteResource.destinationPort,
|
||||||
scheme: siteResource.scheme
|
scheme: siteResource.scheme
|
||||||
}
|
}
|
||||||
@@ -905,13 +873,7 @@ export const portRangeStringSchema = z
|
|||||||
message:
|
message:
|
||||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
|
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.openapi({
|
|
||||||
type: "string",
|
|
||||||
description:
|
|
||||||
'Port range string. Use "*" for all ports, a comma-separated list of ports, or ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.',
|
|
||||||
example: "80,443,8000-9000"
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a port range string into an array of port range objects
|
* Parses a port range string into an array of port range objects
|
||||||
|
|||||||
+18
-138
@@ -1,87 +1,28 @@
|
|||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
|
||||||
|
|
||||||
type LocalLockRecord = {
|
|
||||||
owner: string;
|
|
||||||
expiresAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const localLocks = new Map<string, LocalLockRecord>();
|
|
||||||
|
|
||||||
export class LockManager {
|
export class LockManager {
|
||||||
private clearExpiredLocalLock(lockKey: string): void {
|
|
||||||
const current = localLocks.get(lockKey);
|
|
||||||
if (current && current.expiresAt <= Date.now()) {
|
|
||||||
localLocks.delete(lockKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a local in-process lock using an optimistic Map-based check.
|
* Acquire a distributed lock using Redis SET with NX and PX options
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param ttlMs - Time to live in milliseconds
|
* @param ttlMs - Time to live in milliseconds
|
||||||
* @returns Promise<string | null> - a token identifying this specific acquisition
|
* @returns Promise<boolean> - true if lock acquired, false otherwise
|
||||||
* (truthy) on success, or null if the lock could not be acquired.
|
|
||||||
*/
|
*/
|
||||||
async acquireLock(
|
async acquireLock(
|
||||||
lockKey: string,
|
lockKey: string,
|
||||||
ttlMs: number = 30000,
|
ttlMs: number = 30000
|
||||||
maxRetries: number = 3,
|
): Promise<boolean> {
|
||||||
retryDelayMs: number = 100
|
return true;
|
||||||
): Promise<string | null> {
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
this.clearExpiredLocalLock(lockKey);
|
|
||||||
|
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
if (!existing) {
|
|
||||||
const token = `${instanceId}:${randomUUID()}`;
|
|
||||||
localLocks.set(lockKey, {
|
|
||||||
owner: token,
|
|
||||||
expiresAt: Date.now() + ttlMs
|
|
||||||
});
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The lock is currently held -- possibly by a different, unrelated
|
|
||||||
// caller in this same process. We intentionally do NOT treat
|
|
||||||
// same-process holders as automatically reentrant here: two
|
|
||||||
// independent logical operations (e.g. two different API requests)
|
|
||||||
// running concurrently in the same process must not both believe
|
|
||||||
// they hold the lock, or their writes under it can interleave
|
|
||||||
// unguarded. Just retry with backoff like any other contended lock.
|
|
||||||
if (attempt < maxRetries - 1) {
|
|
||||||
const delay = retryDelayMs * Math.pow(2, attempt);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release a lock previously acquired via acquireLock/acquireLockWithRetry.
|
* Release a lock using Lua script to ensure atomicity
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param token - the exact token returned by the acquisition being released.
|
|
||||||
* Required so a caller whose TTL already expired can't delete a
|
|
||||||
* different, currently-active holder's lock.
|
|
||||||
*/
|
*/
|
||||||
async releaseLock(lockKey: string, token: string): Promise<void> {
|
async releaseLock(lockKey: string): Promise<void> {}
|
||||||
this.clearExpiredLocalLock(lockKey);
|
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
|
|
||||||
if (existing && existing.owner === token) {
|
|
||||||
localLocks.delete(lockKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force release a lock regardless of owner (use with caution)
|
* Force release a lock regardless of owner (use with caution)
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
*/
|
*/
|
||||||
async forceReleaseLock(lockKey: string): Promise<void> {
|
async forceReleaseLock(lockKey: string): Promise<void> {}
|
||||||
localLocks.delete(lockKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a lock exists and get its info
|
* Check if a lock exists and get its info
|
||||||
@@ -94,44 +35,16 @@ export class LockManager {
|
|||||||
ttl: number;
|
ttl: number;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
}> {
|
}> {
|
||||||
this.clearExpiredLocalLock(lockKey);
|
return { exists: true, ownedByMe: true, ttl: 0 };
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return { exists: false, ownedByMe: false, ttl: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ttl = Math.max(0, existing.expiresAt - Date.now());
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
|
|
||||||
ttl,
|
|
||||||
owner: existing.owner.split(":")[0]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend the TTL of an existing lock, provided the token matches the
|
* Extend the TTL of an existing lock owned by this worker
|
||||||
* acquisition currently holding it.
|
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param ttlMs - New TTL in milliseconds
|
* @param ttlMs - New TTL in milliseconds
|
||||||
* @param token - the token returned by the acquisition being extended
|
|
||||||
* @returns Promise<boolean> - true if extended successfully
|
* @returns Promise<boolean> - true if extended successfully
|
||||||
*/
|
*/
|
||||||
async extendLock(
|
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
|
||||||
lockKey: string,
|
|
||||||
ttlMs: number,
|
|
||||||
token: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
this.clearExpiredLocalLock(lockKey);
|
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
|
|
||||||
if (!existing || existing.owner !== token) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.expiresAt = Date.now() + ttlMs;
|
|
||||||
localLocks.set(lockKey, existing);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,34 +54,15 @@ export class LockManager {
|
|||||||
* @param ttlMs - Time to live in milliseconds
|
* @param ttlMs - Time to live in milliseconds
|
||||||
* @param maxRetries - Maximum number of retry attempts
|
* @param maxRetries - Maximum number of retry attempts
|
||||||
* @param baseDelayMs - Base delay between retries in milliseconds
|
* @param baseDelayMs - Base delay between retries in milliseconds
|
||||||
* @returns Promise<string | null> - token if acquired, null otherwise
|
* @returns Promise<boolean> - true if lock acquired
|
||||||
*/
|
*/
|
||||||
async acquireLockWithRetry(
|
async acquireLockWithRetry(
|
||||||
lockKey: string,
|
lockKey: string,
|
||||||
ttlMs: number = 30000,
|
ttlMs: number = 30000,
|
||||||
maxRetries: number = 5,
|
maxRetries: number = 5,
|
||||||
baseDelayMs: number = 100
|
baseDelayMs: number = 100
|
||||||
): Promise<string | null> {
|
): Promise<boolean> {
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
return true;
|
||||||
const acquired = await this.acquireLock(
|
|
||||||
lockKey,
|
|
||||||
ttlMs,
|
|
||||||
1,
|
|
||||||
baseDelayMs
|
|
||||||
);
|
|
||||||
|
|
||||||
if (acquired) {
|
|
||||||
return acquired;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
const delay =
|
|
||||||
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,16 +77,16 @@ export class LockManager {
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
ttlMs: number = 30000
|
ttlMs: number = 30000
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = await this.acquireLock(lockKey, ttlMs);
|
const acquired = await this.acquireLock(lockKey, ttlMs);
|
||||||
|
|
||||||
if (!token) {
|
if (!acquired) {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseLock(lockKey, token);
|
await this.releaseLock(lockKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,21 +99,7 @@ export class LockManager {
|
|||||||
activeLocksCount: number;
|
activeLocksCount: number;
|
||||||
locksOwnedByMe: number;
|
locksOwnedByMe: number;
|
||||||
}> {
|
}> {
|
||||||
const now = Date.now();
|
return { activeLocksCount: 0, locksOwnedByMe: 0 };
|
||||||
for (const [key, value] of localLocks.entries()) {
|
|
||||||
if (value.expiresAt <= now) {
|
|
||||||
localLocks.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let locksOwnedByMe = 0;
|
|
||||||
for (const value of localLocks.values()) {
|
|
||||||
if (value.owner.startsWith(`${instanceId}:`)) {
|
|
||||||
locksOwnedByMe++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activeLocksCount: localLocks.size, locksOwnedByMe };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export function createApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
|
||||||
return z.object({
|
|
||||||
data: dataSchema.nullable(),
|
|
||||||
success: z.boolean(),
|
|
||||||
error: z.boolean(),
|
|
||||||
message: z.string(),
|
|
||||||
status: z.number()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user