Compare commits

..

29 Commits

Author SHA1 Message Date
Owen Schwartz aed6badbe4 New translations en-us.json (Spanish) 2026-04-22 17:41:47 -07:00
Owen Schwartz ab1a5fa5ce New translations en-us.json (Norwegian Bokmal) 2026-04-22 17:41:45 -07:00
Owen Schwartz a6205f8a56 New translations en-us.json (Chinese Simplified) 2026-04-22 17:41:44 -07:00
Owen Schwartz 119caa6fa8 New translations en-us.json (Turkish) 2026-04-22 17:41:42 -07:00
Owen Schwartz 45965b2ee0 New translations en-us.json (Russian) 2026-04-22 17:41:40 -07:00
Owen Schwartz 7d14772ae4 New translations en-us.json (Portuguese) 2026-04-22 17:41:38 -07:00
Owen Schwartz 5827ef1e9b New translations en-us.json (Polish) 2026-04-22 17:41:36 -07:00
Owen Schwartz ed6e1962b8 New translations en-us.json (Dutch) 2026-04-22 17:41:34 -07:00
Owen Schwartz ae125b7d0a New translations en-us.json (Korean) 2026-04-22 17:41:33 -07:00
Owen Schwartz 65520802db New translations en-us.json (Italian) 2026-04-22 17:41:31 -07:00
Owen Schwartz 537776153b New translations en-us.json (German) 2026-04-22 17:41:29 -07:00
Owen Schwartz 81f46c2f25 New translations en-us.json (Czech) 2026-04-22 17:41:28 -07:00
Owen Schwartz c53810b575 New translations en-us.json (Bulgarian) 2026-04-22 17:41:26 -07:00
Owen Schwartz d3a9489990 New translations en-us.json (French) 2026-04-22 17:41:24 -07:00
Owen Schwartz 086a6e57cd New translations en-us.json (German) 2026-04-22 16:28:03 -07:00
Owen Schwartz 0ae293c653 New translations en-us.json (Spanish) 2026-04-22 14:07:41 -07:00
Owen Schwartz 7dfae64e1e New translations en-us.json (Norwegian Bokmal) 2026-04-22 14:07:39 -07:00
Owen Schwartz 0925c31d64 New translations en-us.json (Chinese Simplified) 2026-04-22 14:07:37 -07:00
Owen Schwartz ee59fba976 New translations en-us.json (Turkish) 2026-04-22 14:07:35 -07:00
Owen Schwartz 13b0ca583e New translations en-us.json (Russian) 2026-04-22 14:07:33 -07:00
Owen Schwartz 10c26a3b94 New translations en-us.json (Portuguese) 2026-04-22 14:07:31 -07:00
Owen Schwartz 21a307196f New translations en-us.json (Polish) 2026-04-22 14:07:30 -07:00
Owen Schwartz 96895b9da5 New translations en-us.json (Dutch) 2026-04-22 14:07:28 -07:00
Owen Schwartz 3b26ca4683 New translations en-us.json (Korean) 2026-04-22 14:07:26 -07:00
Owen Schwartz 40d1e62c39 New translations en-us.json (Italian) 2026-04-22 14:07:24 -07:00
Owen Schwartz 82a9ac2390 New translations en-us.json (German) 2026-04-22 14:07:22 -07:00
Owen Schwartz 0148df5a16 New translations en-us.json (Czech) 2026-04-22 14:07:20 -07:00
Owen Schwartz d03ff3df67 New translations en-us.json (Bulgarian) 2026-04-22 14:07:18 -07:00
Owen Schwartz 9749f8d817 New translations en-us.json (French) 2026-04-22 14:07:16 -07:00
1132 changed files with 32548 additions and 144741 deletions
-31
View File
@@ -1,31 +0,0 @@
---
name: crud-endpoints
description: Use whenever asked to add, create, or scaffold a CRUD endpoint, router, or entity in this repo's server (create/list/get/update/delete handlers, new `server/routers/<entity>/` or `server/private/routers/<entity>/` folder). Points to the established file layout, middleware, ActionsEnum, and route-registration conventions before writing any code.
---
Before writing any router/handler/middleware code for a new entity, read
`docs/crud-endpoints.md` in full. It documents, with real examples from
`server/routers/aiProvider/` (public) and `server/private/routers/alertRule/`
(enterprise-only), how this repo structures CRUD endpoints:
- Directory/file layout per entity (`index.ts`, `types.ts`, `validation.ts`,
one file per operation).
- The standard handler anatomy (zod parsing, OpenAPI registry, response
envelope, error handling).
- Where access-control middleware (`verify<Entity>Access`) lives and when
it's needed vs. plain `verifyOrgAccess`.
- How to wire up `ActionsEnum` entries, `verifyUserHasAction`, and
`logActionAudit`.
- Which of the four router files (`server/routers/external.ts`,
`server/routers/internal.ts`, `server/private/routers/external.ts`,
`server/private/routers/internal.ts`) to register routes in, and the
middleware chain template per HTTP verb.
- The repo's non-standard verb convention: **`PUT` = create, `POST` =
update** (backwards from typical REST) — don't "fix" this to standard
REST verbs, match the existing convention.
- The `#dynamic` import alias, for the rare case of a hook needing different
implementations in OSS vs. enterprise builds.
Follow that doc's checklist (§8) step by step rather than improvising a
structure. If the doc and the actual code in `aiProvider`/`alertRule` ever
disagree, trust the code and flag the doc as stale.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
-7
View File
@@ -1,7 +0,0 @@
---
description:
alwaysApply: true
---
Proxy resources = public resources
Private resources = client resources = site resources
-7
View File
@@ -1,7 +0,0 @@
---
alwaysApply: true
---
When writing TypeScript:
Prefer to use types instead of interfaces.
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When creating forms, use React form for validation and use Zod schemas.
-2
View File
@@ -34,5 +34,3 @@ build.ts
tsconfig.json tsconfig.json
Dockerfile* Dockerfile*
drizzle.config.ts drizzle.config.ts
allowedDevOrigins.json
scratch/
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [fosrl]
+2 -3
View File
@@ -14,13 +14,12 @@ body:
label: Environment label: Environment
description: Please fill out the relevant details below for your environment. description: Please fill out the relevant details below for your environment.
value: | value: |
- OS Type & Version: - OS Type & Version: (e.g., Ubuntu 22.04)
- Pangolin Version: - Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version: - Gerbil Version:
- Traefik Version: - Traefik Version:
- Newt Version: - Newt Version:
- Client Version: - Olm Version: (if applicable)
validations: validations:
required: true required: true
+29 -19
View File
@@ -1,42 +1,52 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
npm-dependencies: dev-patch-updates:
patterns: dependency-type: "development"
- "*" update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
- package-ecosystem: "docker" - package-ecosystem: "docker"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
docker-dependencies: patch-updates:
patterns: update-types:
- "*" - "patch"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/install" directory: "/install"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
go-install-dependencies: patch-updates:
patterns: update-types:
- "*" - "patch"
minor-updates:
update-types:
- "minor"
+95 -39
View File
@@ -62,7 +62,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Extract tag name - name: Extract tag name
id: get-tag id: get-tag
@@ -264,7 +264,7 @@ jobs:
shell: bash shell: bash
- name: Install Go - name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
go-version: 1.25 go-version: 1.25
@@ -407,27 +407,35 @@ jobs:
shell: bash shell: bash
- name: Login to GitHub Container Registry (for cosign) - name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign - name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing # cosign is used to sign and verify container images (key and keyless)
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: v3.0.6
- name: Sign (GHCR, keyless) - name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor. # Sign each image by digest using keyless (OIDC) and key-based signing,
# Signatures are stored in the registry alongside the image. # then verify both the public key signature and the keyless OIDC signature.
env: env:
TAG: ${{ env.TAG }} TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true" COSIGN_YES: "true"
run: | run: |
set -euo pipefail set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release # Determine if this is an RC release
IS_RC="false" IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then if [[ "$TAG" == *"-rc."* ]]; then
@@ -455,47 +463,95 @@ jobs:
) )
fi fi
FAILED_TAGS=() # Sign each image variant for both registries
SUCCESSFUL_TAGS=() for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do # Wrap the entire tag processing in error handling
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}" (
TAG_FAILED=false set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
( echo "==> cosign sign (keyless) --recursive ${REF}"
set -e cosign sign --recursive "${REF}"
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${GHCR_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}" echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --recursive "${REF}" cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then # Retry wrapper for verification to handle registry propagation delays
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}" retry_verify() {
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") local cmd="$1"
else local attempts=6
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}" local delay=5
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") local i=1
fi until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
fi
done
done done
# Report summary
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Sign Summary" echo "Sign and Verify Summary"
echo "==========================================" echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:" echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag" echo " - $tag"
done done
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway" echo ""
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
else else
echo "✓ All images signed successfully!" echo "✓ All images signed and verified successfully!"
fi fi
shell: bash shell: bash
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '24' node-version: '24'
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
skopeo --version skopeo --version
- name: Install cosign - name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Input check - name: Input check
run: | run: |
+39
View File
@@ -0,0 +1,39 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"
+160
View File
@@ -0,0 +1,160 @@
name: SAAS Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
pre-run:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances started"
release-arm:
name: Build and Release (ARM64)
runs-on: [self-hosted, linux, arm64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download MaxMind GeoLite2 databases
env:
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
run: |
echo "Downloading MaxMind GeoLite2 databases..."
# Download GeoLite2-Country
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-Country.tar.gz
# Download GeoLite2-ASN
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-ASN.tar.gz
# Extract the .mmdb files
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
# Verify files exist
if [ ! -f "GeoLite2-Country.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
exit 1
fi
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
exit 1
fi
# Clean up tar files
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
echo "MaxMind databases downloaded successfully"
ls -lh GeoLite2-*.mmdb
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Build and push Docker images (Docker Hub - ARM64)
run: |
TAG=${{ env.TAG }}
make build-saas tag=$TAG
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
shell: bash
post-run:
needs: [pre-run, release-arm]
if: >-
${{
always() &&
needs.pre-run.result == 'success' &&
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
}}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances stopped"
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Node - name: Install Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build Docker image pg - name: Build Docker image pg
run: make dev-build-pg run: make dev-build-pg
+2 -4
View File
@@ -17,9 +17,9 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
*.db *.db
*.sqlite* *.sqlite
!Dockerfile.sqlite !Dockerfile.sqlite
*.sqlite3* *.sqlite3
*.log *.log
.machinelogs*.json .machinelogs*.json
*-audit.json *-audit.json
@@ -54,5 +54,3 @@ hydrateSaas.ts
CLAUDE.md CLAUDE.md
drizzle.config.ts drizzle.config.ts
server/setup/migrations.ts server/setup/migrations.ts
solo.yml
allowedDevOrigins.json
+1 -4
View File
@@ -18,8 +18,5 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true
"cSpell.words": [
"nessicary"
]
} }
+4 -4
View File
@@ -1,5 +1,5 @@
# FROM node:24.18.1-slim AS base # FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base FROM public.ecr.aws/docker/library/node:24-slim AS base
WORKDIR /app WORKDIR /app
@@ -32,8 +32,8 @@ FROM base AS builder
RUN npm ci --omit=dev RUN npm ci --omit=dev
# FROM node:24.18.1-slim AS runner # FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner FROM public.ecr.aws/docker/library/node:24-slim AS runner
WORKDIR /app WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.1-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
+4 -40
View File
@@ -41,7 +41,7 @@
</strong> </strong>
</p> </p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control. Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
## Installation ## Installation
@@ -63,26 +63,11 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform. Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
* Lightweight user-space connector runs anywhere
* Punches through any firewall
* Doesn't require open ports or a public IP
* Strict network segmentation
* WireGuard-based
* Get alerts when a device or network resource goes down
<img src="public/screenshots/sites.png" alt="Sites" width="100%" /> <img src="public/screenshots/sites.png" alt="Sites" width="100%" />
### Browser-based reverse proxy access ### Browser-based reverse proxy access
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
* Expose a web panel anywhere
* Access via any web browser
* Single sign-on across all resources
* HTTPS resources
* Remote desktop in the browser with VNC and RDP
* In-browser SSH terminal with privileged access management (PAM)
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" /> <img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
@@ -90,35 +75,14 @@ Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the b
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network. Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
* Peer-to-peer with intelligent NAT traversal
* Hosts/IPs and port ranges
* Network ranges/CIDRs
* Friendly DNS aliases for network addresses
* Privileged access management (PAM) with SSH resources
* Private HTTPS resources only accessible on the private network
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" /> <img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Give users and roles access to resources ### Give users and roles access to resources
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
* Bring your existing identity provider (IdP) or use Pangolin identities
* Sync users and roles from your IdP
* User- and role-based access control
* Full network audit and access logs
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" /> <img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
### Find and launch resources from a personalized home page
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
* Single place for admins and non-admins to see accessible resources
* Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
## Download Clients ## Download Clients
Download the Pangolin client for your platform: Download the Pangolin client for your platform:
@@ -143,7 +107,7 @@ the docs to illustrate some basic ideas.
## Licensing ## Licensing
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net). Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
## Contributions ## Contributions
-28
View File
@@ -1,28 +0,0 @@
import { CommandModule } from "yargs";
import { db, certificates } from "@server/db";
type ClearCertificatesArgs = {};
export const clearCertificates: CommandModule<{}, ClearCertificatesArgs> = {
command: "clear-certificates",
describe: "Delete all entries from the certificates table",
builder: (yargs) => {
return yargs;
},
handler: async (argv: {}) => {
try {
console.log("Clearing all certificates from the database...");
const deleted = await db.delete(certificates).returning();
console.log(
`Deleted ${deleted.length} certificate(s) from the database`
);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};
-60
View File
@@ -1,60 +0,0 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
/**
* Disable 2FA for a user by email address.
*/
type DisableUser2faArgs = {
email: string;
};
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
command: "disable-user-2fa",
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
builder: (yargs) => {
return yargs.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
});
},
handler: async (argv: { email: string }) => {
try {
const { email } = argv;
console.log(`Looking for user with email: ${email}`);
// Find the user by email
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (!user.twoFactorEnabled) {
console.log(`2FA is already disabled for user '${email}'.`);
process.exit(0);
}
// Update user: disable 2FA and clear secret
await db.update(users)
.set({
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorSetupRequested: false
})
.where(eq(users.userId, user.userId));
console.log(`2FA disabled for user '${email}'.`);
process.exit(0);
} catch (error) {
console.error("Error disabling 2FA:", error);
process.exit(1);
}
}
};
+1 -233
View File
@@ -1,5 +1,5 @@
import { CommandModule } from "yargs"; import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db"; import { db, idpOidcConfig, licenseKey } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto"; import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -129,19 +129,9 @@ export const rotateServerSecret: CommandModule<
console.log("\nReading encrypted data from database..."); console.log("\nReading encrypted data from database...");
const idpConfigs = await db.select().from(idpOidcConfig); const idpConfigs = await db.select().from(idpOidcConfig);
const licenseKeys = await db.select().from(licenseKey); const licenseKeys = await db.select().from(licenseKey);
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
const providers = await db.select().from(aiProviders);
const virtualKeys = await db.select().from(virtualApiKeys);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`); console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
console.log(`Found ${providers.length} AI provider(s)`);
console.log(`Found ${virtualKeys.length} virtual API key(s)`);
// Prepare all decrypted and re-encrypted values // Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values..."); console.log("\nDecrypting and re-encrypting values...");
@@ -159,40 +149,8 @@ export const rotateServerSecret: CommandModule<
encryptedInstanceId: string; encryptedInstanceId: string;
}; };
type CertUpdate = {
certId: number;
encryptedCertFile: string | null;
encryptedKeyFile: string | null;
};
type StreamingDestinationUpdate = {
destinationId: number;
encryptedConfig: string;
};
type WebhookActionUpdate = {
webhookActionId: number;
encryptedConfig: string;
};
type AiProviderUpdate = {
providerId: number;
encryptedApiKey: string | null;
encryptedHeaders: string | null;
};
type VirtualApiKeyUpdate = {
virtualApiKeyId: string;
encryptedToken: string;
};
const idpUpdates: IdpUpdate[] = []; const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
const aiProviderUpdates: AiProviderUpdate[] = [];
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
// Process idpOidcConfig entries // Process idpOidcConfig entries
for (const idpConfig of idpConfigs) { for (const idpConfig of idpConfigs) {
@@ -259,124 +217,6 @@ export const rotateServerSecret: CommandModule<
} }
} }
// Process certificate entries
for (const cert of certs) {
try {
const encryptedCertFile = cert.certFile
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
: null;
const encryptedKeyFile = cert.keyFile
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
: null;
certUpdates.push({
certId: cert.certId,
encryptedCertFile,
encryptedKeyFile
});
} catch (error) {
console.error(
`Error processing certificate ${cert.certId} (${cert.domain}):`,
error
);
throw error;
}
}
// Process eventStreamingDestinations entries
for (const dest of streamingDestinations) {
try {
const decryptedConfig = decrypt(dest.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
streamingDestinationUpdates.push({
destinationId: dest.destinationId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing event streaming destination ${dest.destinationId}:`,
error
);
throw error;
}
}
// Process alertWebhookActions entries
for (const webhook of webhookActions) {
try {
if (webhook.config == null) continue;
const decryptedConfig = decrypt(webhook.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
webhookActionUpdates.push({
webhookActionId: webhook.webhookActionId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing alert webhook action ${webhook.webhookActionId}:`,
error
);
throw error;
}
}
// Process aiProviders entries (apiKey + headers)
for (const provider of providers) {
try {
if (!provider.apiKey && !provider.headers) {
continue;
}
const encryptedApiKey = provider.apiKey
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
: null;
const encryptedHeaders = provider.headers
? encrypt(
decrypt(provider.headers, oldSecret),
newSecret
)
: null;
aiProviderUpdates.push({
providerId: provider.providerId,
encryptedApiKey,
encryptedHeaders
});
} catch (error) {
console.error(
`Error processing AI provider ${provider.providerId}:`,
error
);
throw error;
}
}
// Process virtualApiKeys entries (token)
for (const key of virtualKeys) {
try {
if (!key.token) {
continue;
}
virtualApiKeyUpdates.push({
virtualApiKeyId: key.virtualApiKeyId,
encryptedToken: encrypt(
decrypt(key.token, oldSecret),
newSecret
)
});
} catch (error) {
console.error(
`Error processing virtual API key ${key.virtualApiKeyId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction // Perform all database updates in a single transaction
console.log("\nUpdating database in transaction..."); console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
@@ -410,78 +250,10 @@ export const rotateServerSecret: CommandModule<
instanceId: update.encryptedInstanceId instanceId: update.encryptedInstanceId
}); });
} }
// Update certificate entries
for (const update of certUpdates) {
await trx
.update(certificates)
.set({
certFile: update.encryptedCertFile,
keyFile: update.encryptedKeyFile
})
.where(eq(certificates.certId, update.certId));
}
// Update event streaming destination entries
for (const update of streamingDestinationUpdates) {
await trx
.update(eventStreamingDestinations)
.set({ config: update.encryptedConfig })
.where(
eq(
eventStreamingDestinations.destinationId,
update.destinationId
)
);
}
// Update alert webhook action entries
for (const update of webhookActionUpdates) {
await trx
.update(alertWebhookActions)
.set({ config: update.encryptedConfig })
.where(
eq(
alertWebhookActions.webhookActionId,
update.webhookActionId
)
);
}
// Update AI provider entries
for (const update of aiProviderUpdates) {
await trx
.update(aiProviders)
.set({
apiKey: update.encryptedApiKey,
headers: update.encryptedHeaders
})
.where(eq(aiProviders.providerId, update.providerId));
}
// Update virtual API key entries
for (const update of virtualApiKeyUpdates) {
await trx
.update(virtualApiKeys)
.set({
token: update.encryptedToken
})
.where(
eq(
virtualApiKeys.virtualApiKeyId,
update.virtualApiKeyId
)
);
}
}); });
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`);
// Update config file with new secret // Update config file with new secret
console.log("\nUpdating config file..."); console.log("\nUpdating config file...");
@@ -498,10 +270,6 @@ export const rotateServerSecret: CommandModule<
console.log(`\nSummary:`); console.log(`\nSummary:`);
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
console.log(` - License keys: ${licenseKeyUpdates.length}`); console.log(` - License keys: ${licenseKeyUpdates.length}`);
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log(` - AI providers: ${aiProviderUpdates.length}`);
console.log( console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.` `\n IMPORTANT: Restart the server for the new secret to take effect.`
); );
-85
View File
@@ -1,85 +0,0 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
type SetServerAdminArgs = {
email: string;
remove: boolean;
};
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
command: "set-server-admin",
describe: "Add or remove server admin by email address",
builder: (yargs) => {
return yargs
.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
})
.option("remove", {
type: "boolean",
default: false,
describe: "Remove server admin status from the user"
});
},
handler: async (argv: SetServerAdminArgs) => {
try {
const email = argv.email.trim().toLowerCase();
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (argv.remove) {
if (!user.serverAdmin) {
console.log(`User '${email}' is not a server admin`);
process.exit(0);
}
const serverAdmins = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (serverAdmins.length <= 1) {
console.error(
"Cannot remove server admin: at least one server admin must exist"
);
process.exit(1);
}
await db
.update(users)
.set({ serverAdmin: false })
.where(eq(users.userId, user.userId));
console.log(`Server admin status removed from user '${email}'`);
process.exit(0);
}
if (user.serverAdmin) {
console.log(`User '${email}' is already a server admin`);
process.exit(0);
}
await db
.update(users)
.set({ serverAdmin: true })
.where(eq(users.userId, user.userId));
console.log(`User '${email}' has been marked as a server admin`);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};
-6
View File
@@ -9,9 +9,6 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient"; import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa";
import { setServerAdmin } from "./commands/setServerAdmin";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -22,8 +19,5 @@ yargs(hideBin(process.argv))
.command(clearLicenseKeys) .command(clearLicenseKeys)
.command(deleteClient) .command(deleteClient)
.command(generateOrgCaKeys) .command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.command(setServerAdmin)
.demandCommand() .demandCommand()
.help().argv; .help().argv;
-12
View File
@@ -1,12 +0,0 @@
services:
mailer:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
volumes:
- mailpit-storage:/data
environment:
- MP_DATABASE=/data/mailpit.db
volumes:
mailpit-storage:
-7559
View File
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -1,47 +1,54 @@
api: api:
insecure: true insecure: true
dashboard: true dashboard: true
providers: providers:
http: http:
endpoint: http://pangolin:3001/api/v1/traefik-config endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: 5s pollInterval: "5s"
file: file:
filename: /etc/traefik/dynamic_config.yml filename: "/etc/traefik/dynamic_config.yml"
experimental: experimental:
plugins: plugins:
badger: badger:
moduleName: github.com/fosrl/badger moduleName: "github.com/fosrl/badger"
version: v1.4.1 version: "{{.BadgerVersion}}"
log: log:
level: INFO level: "INFO"
format: common format: "common"
maxSize: 100 maxSize: 100
maxBackups: 3 maxBackups: 3
maxAge: 3 maxAge: 3
compress: true compress: true
certificatesResolvers: certificatesResolvers:
letsencrypt: letsencrypt:
acme: acme:
httpChallenge: httpChallenge:
entryPoint: web entryPoint: web
email: '{{.LetsEncryptEmail}}' email: "{{.LetsEncryptEmail}}"
storage: /letsencrypt/acme.json storage: "/letsencrypt/acme.json"
caServer: https://acme-v02.api.letsencrypt.org/directory caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints: entryPoints:
web: web:
address: ':80' address: ":80"
websecure: websecure:
address: ':443' address: ":443"
transport: transport:
respondingTimeouts: respondingTimeouts:
readTimeout: 30m readTimeout: "30m"
http: http:
tls: tls:
certResolver: letsencrypt certResolver: "letsencrypt"
encodedCharacters: encodedCharacters:
allowEncodedSlash: true allowEncodedSlash: true
allowEncodedQuestionMark: true allowEncodedQuestionMark: true
serversTransport: serversTransport:
insecureSkipVerify: true insecureSkipVerify: true
ping: ping:
entryPoint: web entryPoint: "web"
@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode
traefik: traefik:
image: traefik:v3.7 image: traefik:v3.6
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service
View File
-285
View File
@@ -1,285 +0,0 @@
# AI Gateway Provider Selection
How the AI gateway picks which attached provider handles a request when an
inference resource has more than one AI provider.
**Code:**
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
- Model catalog: `server/lib/aiModelCatalog.ts`
- Default capabilities per provider type: `server/lib/aiProviderDefaults.ts`
Overlapping model allows are permitted at save time. Selection happens at
request time. If the algorithm cannot confidently pick one provider, the
gateway returns `403` with an ambiguous-provider error.
## Selection Pipeline
Every gateway request runs through these steps in order. Each step narrows
the candidate set. Later steps only run when more than one provider remains.
```
1. Capability filter
2. Allow / block lists
3. Most specific allow pattern
4. Catalog ownership
5. Provider class preference
6. Ambiguous → error
```
### 1. Capability Filter
The incoming path selects a capability before any provider logic runs.
| Path | Capability |
|------|------------|
| `POST /v1/chat/completions` | `openai_chat` |
| `POST /v1/responses` | `openai_responses` |
| `POST /v1/messages` | `anthropic_messages` |
| Gemini / Vertex / Bedrock routes | their respective capability ids |
Only attached providers that advertise that capability stay in the candidate
set. Default capabilities do not overlap for native OpenAI vs Anthropic:
| Provider type | Default capabilities |
|---------------|----------------------|
| `openai` | `openai_chat`, `openai_responses` |
| `anthropic` | `anthropic_messages` |
| `openRouter` | `openai_chat` |
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
| `custom` | whatever was configured |
### 2. Allow / Block Lists
For each remaining provider, the gateway resolves the effective allow and
block patterns:
- **`inherit`**: use the provider's own model lists
- **`select`**: use the resource-selected subset of those lists
A candidate is kept only if `isAllowedByLists(requestedModel, allows, blocks)`
passes:
1. At least one allow pattern must match
2. No block pattern may match
Patterns support `*` and `?` globs (`gpt-*`, `claude-3-5-sonnet-?`).
### 3. Most Specific Allow Pattern
Among providers that allow the model, keep those whose matching allow
pattern is most specific:
1. Exact keys beat patterns
2. Fewer wildcard characters win
3. Longer literal length wins
Example: `gpt-4o` beats `gpt-*` beats `*`.
### 4. Catalog Ownership
When specificity is tied (common with multiple `*` allows), score each
provider against the known model catalog:
| Score | Meaning |
|------:|---------|
| 2 | Typed provider whose catalog contains the model (`openai` → openai catalog, `anthropic` → anthropic, etc.) |
| 1 | Aggregator or custom (`openRouter`, `vercelAiGateway`, `custom`) and the model exists somewhere in the catalog |
| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) |
Model id lookup tries the raw id, then a stripped `vendor/model` form
(e.g. `openai/gpt-4o` → also try `gpt-4o`).
Typed providers map to catalog providers as:
| Provider type | Catalog |
|---------------|---------|
| `openai` | `openai` |
| `anthropic` | `anthropic` |
| `googleGemini` | `gemini` |
| `vertexAi` | `vertex` |
| `bedrock` | `bedrock` |
| `microsoftFoundry` | `azure` |
| `openRouter` / `vercelAiGateway` / `custom` | none (aggregator/custom path) |
### 5. Provider Class Preference
If catalog ownership is still tied, prefer:
| Rank | Class |
|-----:|-------|
| 2 | Native typed provider (`openai`, `anthropic`, `googleGemini`, …) |
| 1 | Aggregator (`openRouter`, `vercelAiGateway`) |
| 0 | `custom` |
### 6. Ambiguous Error
If more than one distinct provider remains after all steps, the gateway
rejects the request:
```
Model "<id>" is ambiguous across multiple AI providers on this resource
```
Typical remaining ties: two OpenAI-type providers both with `*`, or two
customs advertising the same capability for an unknown model.
## Examples
Assume each provider below is attached and enabled on the same inference
resource.
### Example A: OpenAI + Anthropic, Both `*`
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, `openai_responses` |
| Anthropic | `*` | `anthropic_messages` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → only OpenAI remains
2. Allow → OpenAI matches `*`
3. Result → **OpenAI**
Anthropic never reaches pattern or catalog scoring. Capability alone decides.
**Request:** `POST /v1/messages` with `model: "claude-3-5-sonnet-latest"`
1. Capability → only Anthropic remains
2. Result → **Anthropic**
### Example B: OpenAI + OpenRouter, Both `*`
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, … |
| OpenRouter | `*` | `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → both remain (`openai_chat`)
2. Allow → both match `*`
3. Specificity → tie (`*` vs `*`)
4. Catalog → OpenAI scores `2` (owns `gpt-4o`); OpenRouter scores `1`
5. Result → **OpenAI**
### Example C: OpenRouter Only Serving a Claude Model Over OpenAI Chat
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenRouter | `*` | `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "anthropic/claude-3.5-sonnet"`
1. Capability → OpenRouter remains
2. Only one candidate → **OpenRouter**
No tie-breaking needed.
### Example D: OpenAI (`gpt-*`) + OpenRouter (`*`)
| Provider | Allow |
|----------|-------|
| OpenAI | `gpt-*` |
| OpenRouter | `*` |
**Request:** `model: "gpt-4o"` on `openai_chat`
1. Capability → both
2. Allow → both match
3. Specificity → OpenAI's `gpt-*` beats OpenRouter's `*`
4. Result → **OpenAI**
Catalog scoring is not needed because specificity already unique'd the set.
### Example E: OpenAI + Anthropic With Overlapping Custom Capabilities
Someone grants Anthropic `openai_chat` as well (non-default).
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, … |
| Anthropic | `*` | `anthropic_messages`, `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → both remain
2. Allow → both match `*`
3. Specificity → tie
4. Catalog → OpenAI `2`, Anthropic `0` (`gpt-4o` is not in the anthropic catalog)
5. Result → **OpenAI**
### Example F: Two Aggregators, Known Model
| Provider | Allow |
|----------|-------|
| OpenRouter | `*` |
| Vercel AI Gateway | `*` |
**Request:** `model: "gpt-4o"` on `openai_chat`
1. Capability → both
2. Allow / specificity → tie
3. Catalog → both score `1` (known model, no typed owner in the set)
4. Class → both aggregators (rank `1`) → still tied
5. Result → **ambiguous error**
Attach a native OpenAI provider (or narrow one aggregator's allow list) to
make this determinable.
### Example G: Two OpenAI Providers, Both `*`
| Provider | Type | Allow |
|----------|------|-------|
| OpenAI Prod | `openai` | `*` |
| OpenAI Staging | `openai` | `*` |
**Request:** `model: "gpt-4o"`
15 all leave both candidates (same capability, same specificity, same
catalog ownership, same class).
Result → **ambiguous error**
Disambiguate with different allow patterns, disable one attachment, or
split across resources.
### Example H: Unknown Model Across Native + Aggregator
| Provider | Allow |
|----------|-------|
| OpenAI | `*` |
| OpenRouter | `*` |
**Request:** `model: "my-fine-tune-v3"` (not in catalog)
1. Capability → both
2. Allow / specificity → tie
3. Catalog → both score `0` (typed miss + unknown aggregator model)
4. Class → OpenAI (`2`) beats OpenRouter (`1`)
5. Result → **OpenAI**
## Practical Guidance
- Native OpenAI + Anthropic with `*` is safe. Different default APIs never
collide.
- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI
models. Native wins.
- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share
a capability.
- Two providers of the same type both using `*` will stay ambiguous. Narrow
at least one allow list.
- Custom providers only win ties when no stronger native/aggregator signal
remains.
## Related Behavior
- **Saving providers on a resource does not reject overlapping allows.**
Collisions are resolved (or rejected) per request.
- Budgets, auth, and upstream URL / target routing run after a single
provider has been selected.
-347
View File
@@ -1,347 +0,0 @@
# How to build a CRUD endpoint in this repo
Reference for adding a new CRUD entity to the server. Based on two real
examples already in the codebase — read them side by side with this doc:
- **Public / open-source (Community Edition) pattern**: `server/routers/aiProvider/`
- **Enterprise-only pattern**: `server/private/routers/alertRule/`
The two are structurally identical. The only difference is *where the files
live* and *which router they get wired into*.
## 1. Decide: public or private?
- `server/routers/<entity>/` — ships in the open-source Community Edition.
Anyone running Pangolin gets this.
- `server/private/routers/<entity>/` — Enterprise/SaaS only. Gated behind
`verifyValidLicense` (and often `verifyValidSubscription(tierMatrix.x)`).
Every file here starts with the Fossorial Commercial License header block
(copy it verbatim from an existing private file).
Everything below applies to both — swap `@server/...` for `#private/...`
import paths and add license headers when building the private version.
## 2. Directory layout
One folder per entity, one file per operation, a barrel `index.ts`:
```
server/routers/<entity>/
index.ts # export * from each operation file + ./types
types.ts # response payload types + row->public mapper
validation.ts # zod schemas/refinements shared by create + update (optional)
create<Entity>.ts
list<Entities>.ts
get<Entity>.ts
update<Entity>.ts
delete<Entity>.ts
```
`index.ts` is a flat barrel:
```ts
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./types";
```
## 3. Anatomy of a single handler
Every handler file (`create<Entity>.ts`, etc.) follows the same shape:
```ts
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { <table>, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetXResponse } from "@server/routers/<entity>/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({ /* ... */ }); // create/update only
registry.registerPath({
method: "get", // put | post | delete
path: "/org/{orgId}/x",
description: "...",
tags: [OpenAPITags.<Entity>],
request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ },
responses: { 200: { description: "Successful response" } }
});
export async function getX(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
}
// parse body too, if present, same pattern
// ...business logic against db...
if (!row) {
return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`));
}
return response<GetXResponse>(res, {
data: { /* ... */ },
success: true,
error: false,
message: "X retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred"));
}
}
```
Rules to keep consistent with the rest of the codebase:
- `z.strictObject` for params/body — rejects unknown keys.
- Params parsed first, then body; each on its own `safeParse` + early
`next(createHttpError(...))` — never throw raw errors.
- Every handler registers itself with the OpenAPI `registry` even if nobody
reads the spec directly — it's how `/api/v1/docs` stays accurate.
- Catch-all `try/catch` at the bottom: `logger.error(error)` +
generic `500` message. Never leak internal error details to the client.
- Use `response<T>(res, { data, success, error, message, status })` from
`@server/lib/response` for every response, success or otherwise (errors go
through `next(createHttpError(...))` instead, not through `response`).
- If the route already ran an access-control middleware that fetched the row
(see §5), reuse it instead of re-querying:
`req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...`
### List handler specifics
Pagination is a fixed shape (`page`, `pageSize`, optional `query` for
search). See `listAiProviders.ts`:
```ts
const listSchema = z.object({
pageSize: z.coerce.number<string>().int().positive().optional().catch(20).default(20),
page: z.coerce.number<string>().int().min(0).optional().catch(1).default(1),
query: z.string().optional()
});
```
Run the count query and the page query in `Promise.all`, and return
`PaginatedResponse<{ items: T[] }>` (`@server/types/Pagination`) with
`{ total, pageSize, page }`.
### types.ts specifics
- Define one response type per operation: `List<Entities>Response`,
`Get<Entity>Response`, `CreateOrEdit<Entity>Response` (create and update
commonly share a response shape).
- If the raw DB row needs to be shaped for clients (decrypting secrets,
parsing a serialized column, hiding a column), put a `toPublic<Entity>()`
mapper here — see `toPublicAiProvider` for the pattern of stripping
`apiKey`/serialized columns and re-adding decrypted/parsed versions.
### validation.ts specifics
Only needed when create and update share non-trivial zod pieces (enums,
`superRefine` cross-field rules). Export the raw schemas (`z.enum([...])`)
and refinement functions, and import them into both `createX.ts` and
`updateX.ts` — see `aiProvider/validation.ts`'s
`refineProviderUpstreamFields`.
## 4. Wire up an access-control middleware (for id-scoped routes)
For routes scoped to a single row (`/x/:xId`, as opposed to
`/org/:orgId/x` create/list), add a `verify<Entity>Access` middleware in
`server/middlewares/` (or `server/private/middlewares/` for enterprise-only
entities) and export it from that directory's `index.ts`.
Pattern (`verifyAiProviderAccess.ts`):
1. Read the id param, `Number.parseInt`/validate it.
2. Load the row by id.
3. `404` if it doesn't exist.
4. Resolve the row's `orgId`, then check/attach `req.userOrg` (query
`userOrgs` if not already on the request), `403` if the user isn't in
that org.
5. Run `checkOrgAccessPolicy` if `req.orgPolicyAllowed` hasn't been resolved
yet.
6. Set `req.userOrgId`, `req.userOrgRoleIds`, and stash the row on the
request (e.g. `req.aiProvider = provider`) so downstream handlers and
`verifyUserHasAction` don't have to refetch it.
Org-scoped create/list routes (`/org/:orgId/x`) don't need a bespoke
middleware — they use the existing generic `verifyOrgAccess` from
`@server/middlewares`.
## 5. Register an action + permission check
Add one `ActionsEnum` entry per operation in `server/auth/actions.ts`,
grouped near the entity's other actions, named `create<Entity>`,
`get<Entity>`, `update<Entity>`, `delete<Entity>`, `list<Entities>`:
```ts
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
```
Every route uses `verifyUserHasAction(ActionsEnum.x)` to check the caller's
role/permissions for that action, and mutating routes (create/update/delete)
follow it with `logActionAudit(ActionsEnum.x)` to record the action in the
audit log.
## 6. Register the routes
There are four router files; which one(s) you touch depends on public vs.
private and user-facing vs. service-to-service:
| File | Purpose |
|---|---|
| `server/routers/external.ts` | Public, user-facing API. Exports `authenticated`, `unauthenticated`, `authRouter` Express routers. |
| `server/routers/internal.ts` | Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports `internalRouter`. |
| `server/private/routers/external.ts` | Enterprise-only, user-facing. Imports `authenticated`/`unauthenticated`/`authRouter` **from the public `external.ts`** and re-exports them, then adds more routes on top. |
| `server/private/routers/internal.ts` | Enterprise-only, service-to-service. Same re-export trick with `internalRouter`. |
Private router files always start:
```ts
import {
unauthenticated as ua,
authenticated as a,
authRouter as aa
} from "@server/routers/external";
export const authenticated = a;
export const unauthenticated = ua;
export const authRouter = aa;
```
...and then call `authenticated.get/put/post/delete(...)` to bolt on
additional, enterprise-only routes on the *same* router instances the public
build uses. This is why the private build has strictly more routes than the
public build, not a divergent copy.
### Route registration order (mutating vs read)
Standard middleware chain per verb, using `alertRule`'s registrations as the
template:
```ts
// Create — org-scoped, no row exists yet
authenticated.put(
"/org/:orgId/x",
verifyValidLicense, // private/enterprise routes only
verifyOrgAccess,
verifyLimits, // if the entity counts against a plan limit
verifyUserHasAction(ActionsEnum.createX),
logActionAudit(ActionsEnum.createX),
x.createX
);
// Update — row-scoped
authenticated.post(
"/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique
verifyValidLicense,
verifyOrgAccess, // or verifyXAccess if globally-keyed
verifyUserHasAction(ActionsEnum.updateX),
logActionAudit(ActionsEnum.updateX),
x.updateX
);
// Delete — row-scoped
authenticated.delete(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteX),
logActionAudit(ActionsEnum.deleteX),
x.deleteX
);
// List — org-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/xs",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listXs),
x.listXs
);
// Get one — row-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getX),
x.getX
);
```
Notes:
- HTTP verbs: `PUT` = create, `POST` = update, `GET` = read, `DELETE` =
delete. This repo does not use `PATCH` for entity updates (site
provisioning keys are the one exception, using `PATCH`).
- `verifyValidLicense` is only needed on private/enterprise routes; public
OSS routes skip it.
- Use `verifyValidSubscription(tierMatrix.someFeature)` right after
`verifyValidLicense` when a feature is gated to specific SaaS tiers (see
`tierMatrix` usages in `server/private/routers/external.ts`).
- `verifyLimits` goes on create routes for entities that count against a
plan/seat limit.
- For entities keyed by a globally-unique id (not nested under `/org/:orgId`),
use the dedicated `verify<Entity>Access` middleware from §4 instead of
`verifyOrgAccess` on the row-scoped routes (see how `/ai-provider/:providerId`
uses `verifyAiProviderAccess`, while `/org/:orgId/ai-provider` create/list
use plain `verifyOrgAccess`).
- Read-only routes (`get`, `list`) skip `logActionAudit` — only mutations are
audited.
- `internal*.ts` routes are for trusted internal callers (gerbil/badger
sidecars) and generally skip user-facing auth entirely, using
`verifySessionUserMiddleware` / `verifyUserFromResourceSessionMiddleware`
instead of `verifyOrgAccess`/`verifyUserHasAction`. CRUD entities almost
never need internal router entries — only add one if a sidecar process
needs direct access to the resource.
## 7. The `#dynamic` alias (advanced — most CRUD work can ignore this)
Some middleware (e.g. `logActionAudit`) needs a real implementation in the
enterprise/SaaS build but a no-op stub in the open-source build, while
being imported by identical code in `server/routers/external.ts` in both
builds. That's done via the `#dynamic/*` import alias, which
`tsconfig.oss.json` points at `./server/*` and `tsconfig.enterprise.json` /
`tsconfig.saas.json` point at `./server/private/*`. You only need this
pattern if you're adding a genuinely dual-implementation hook; a normal
private-only CRUD entity (like `alertRule`) never touches `#dynamic` — it
just lives entirely under `server/private/` and is imported with `#private/*`
directly from `server/private/routers/external.ts`.
## 8. Checklist for a new entity
1. Add the DB table to `server/db/pg/schema/schema.ts` (and sqlite schema if
applicable).
2. Add `ActionsEnum` entries in `server/auth/actions.ts`.
3. Create `server/routers/<entity>/` (or `server/private/routers/<entity>/`):
`types.ts`, optional `validation.ts`, one file per operation, `index.ts`
barrel.
4. If routes are row-scoped by a global id, add
`verify<Entity>Access.ts` to `server/middlewares/` or
`server/private/middlewares/`, and export it from that directory's
`index.ts`.
5. Wire routes into `external.ts` (public or private) following the verb/
middleware table in §6. Add to `internal.ts` only if a sidecar needs
direct access.
6. Add license header block to every new file if it's under `server/private/`.
+1 -1
View File
@@ -1,4 +1,4 @@
import { APP_PATH } from "./server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import path from "path"; import path from "path";
+1 -5
View File
@@ -22,8 +22,7 @@ server:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"] allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false credentials: false
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}} {{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
{{if .EnableEmail}} {{if .EnableEmail}}
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"
@@ -37,6 +36,3 @@ flags:
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: false disable_user_create_org: false
allow_raw_resources: true allow_raw_resources: true
{{if .IsPostgreSQL}}postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
+12 -61
View File
@@ -1,23 +1,15 @@
name: pangolin name: pangolin
services: services:
pangolin: pangolin:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}} image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
limits: limits:
memory: 2g memory: 1g
reservations: reservations:
memory: 512m memory: 256m
{{if or .IsPostgreSQL .IsRedis}}depends_on:
{{if .IsPostgreSQL}}postgres:
condition: service_healthy{{end}}
{{if .IsRedis}}redis:
condition: service_healthy{{end}}
networks:
- default
- backend{{end}}
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:
@@ -25,8 +17,8 @@ services:
interval: "10s" interval: "10s"
timeout: "10s" timeout: "10s"
retries: 15 retries: 15
{{if .InstallGerbil}}
{{if .InstallGerbil}}gerbil: gerbil:
image: docker.io/fosrl/gerbil:{{.GerbilVersion}} image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
@@ -47,16 +39,17 @@ services:
- 21820:21820/udp - 21820:21820/udp
- 443:443 - 443:443
- 443:443/udp # For http3 QUIC if desired - 443:443/udp # For http3 QUIC if desired
- 80:80{{end}} - 80:80
{{end}}
traefik: traefik:
image: docker.io/traefik:v3.7 image: docker.io/traefik:v3.6
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} {{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
ports: ports:
- 443:443 - 443:443
- 80:80{{end}} - 80:80
{{end}}
depends_on: depends_on:
pangolin: pangolin:
condition: service_healthy condition: service_healthy
@@ -67,50 +60,8 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
{{if .IsPostgreSQL}}postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: pangolin
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
POSTGRES_DB: pangolin
volumes:
- ./postgres18:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pangolin"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend{{end}}
{{if .IsRedis}}redis:
image: redis:8-trixie
container_name: redis
restart: unless-stopped
command: >
redis-server
--save 3600 1000
--appendonly yes
--requirepass {{.IsRedisPass}}
volumes:
- ./redis8:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
networks:
- backend{{end}}
networks: networks:
default: default:
driver: bridge driver: bridge
name: pangolin_frontend name: pangolin
{{if .EnableIPv6}} enable_ipv6: true{{end}} {{if .EnableIPv6}} enable_ipv6: true{{end}}
{{if or .IsPostgreSQL .IsRedis}} backend:
driver: bridge
name: pangolin_backend
internal: true{{end}}
-4
View File
@@ -1,4 +0,0 @@
{{if .IsRedis}}redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"{{end}}
+1 -70
View File
@@ -6,13 +6,12 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func installCrowdsec(config Config, installDir string) error { func installCrowdsec(config Config) error {
if err := stopContainers(config.InstallationContainerType); err != nil { if err := stopContainers(config.InstallationContainerType); err != nil {
return fmt.Errorf("failed to stop containers: %v", err) return fmt.Errorf("failed to stop containers: %v", err)
@@ -41,8 +40,6 @@ func installCrowdsec(config Config, installDir string) error {
os.Exit(1) os.Exit(1)
} }
setupTraefikLogRotate(installDir)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err) fmt.Printf("Error copying docker service: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -211,69 +208,3 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
fmt.Println("Added dependency of crowdsec to traefik") fmt.Println("Added dependency of crowdsec to traefik")
return nil return nil
} }
// setupTraefikLogRotate writes a logrotate config for the Traefik access log
// that CrowdSec depends on. This is only needed when CrowdSec is installed
// because the default Pangolin install does not enable Traefik access logs.
//
// copytruncate is used so Traefik does not need to be restarted or sent a
// signal after rotation — it keeps writing to the same file descriptor while
// the rotated copy is made and the original is truncated in place.
func setupTraefikLogRotate(installDir string) {
const logrotateDir = "/etc/logrotate.d"
const logrotateFile = "/etc/logrotate.d/pangolin-traefik"
logPath := filepath.Join(installDir, "config/traefik/logs/access.log")
if os.Geteuid() != 0 {
fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.")
fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,")
fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:")
printLogrotateConfig(logPath)
return
}
config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec.
# Generated by the Pangolin installer. Safe to edit.
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
if err := os.MkdirAll(logrotateDir, 0755); err != nil {
fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err)
return
}
if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil {
fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err)
fmt.Println("[logrotate] Set it up manually:")
printLogrotateConfig(logPath)
return
}
fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile)
fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.")
}
// printLogrotateConfig prints a logrotate config block to stdout so users can
// set it up manually when the installer cannot write to /etc.
func printLogrotateConfig(logPath string) {
fmt.Printf(`
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
}
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.25.0
require ( require (
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.45.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.47.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
) )
+4 -4
View File
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+21 -58
View File
@@ -4,7 +4,6 @@ import (
"crypto/rand" "crypto/rand"
"embed" "embed"
"encoding/base64" "encoding/base64"
"flag"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -54,13 +53,9 @@ type Config struct {
InstallGerbil bool InstallGerbil bool
TraefikBouncerKey string TraefikBouncerKey string
DoCrowdsecInstall bool DoCrowdsecInstall bool
EnableMaxMind bool EnableGeoblocking bool
Secret string Secret string
IsEnterprise bool IsEnterprise bool
IsPostgreSQL bool
IsPostgreSQLPass string
IsRedis bool
IsRedisPass string
} }
type SupportedContainer string type SupportedContainer string
@@ -71,14 +66,8 @@ const (
Undefined SupportedContainer = "undefined" Undefined SupportedContainer = "undefined"
) )
var redisFlag *bool
func main() { func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!") fmt.Println("Welcome to the Pangolin installer!")
@@ -130,11 +119,11 @@ func main() {
fmt.Println("\nConfiguration files created successfully!") fmt.Println("\nConfiguration files created successfully!")
// Download MaxMind Country / ASN database if requested // Download MaxMind database if requested
if config.EnableMaxMind { if config.EnableGeoblocking {
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===") fmt.Println("\n=== Downloading MaxMind Database ===")
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind databases: %v\n", err) fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can download it manually later if needed.") fmt.Println("You can download it manually later if needed.")
} }
} }
@@ -195,15 +184,15 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===") fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil { if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
fmt.Println("MaxMind GeoLite2 Country database found.") fmt.Println("MaxMind GeoLite2 Country database found.")
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) { if readBool("Would you like to update the MaxMind database to the latest version?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error updating MaxMind database: %v\n", err) fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.") fmt.Println("You can try updating it manually later if needed.")
} }
} }
} else { } else {
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.") fmt.Println("MaxMind GeoLite2 Country database not found.")
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) { if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.") fmt.Println("You can try downloading it manually later if needed.")
@@ -211,15 +200,13 @@ func main() {
// Now you need to update your config file accordingly to enable geoblocking // Now you need to update your config file accordingly to enable geoblocking
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n") fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server // add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server fmt.Println("Add the following line under the 'server' section:")
fmt.Println("Add the following lines under the 'server' section:")
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"") fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
} }
} }
} }
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() { if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===") fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed // check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) { if readBool("Would you like to install CrowdSec?", false) {
@@ -272,7 +259,7 @@ func main() {
} }
config.DoCrowdsecInstall = true config.DoCrowdsecInstall = true
err := installCrowdsec(config, installDir) err := installCrowdsec(config)
if err != nil { if err != nil {
fmt.Printf("Error installing CrowdSec: %v\n", err) fmt.Printf("Error installing CrowdSec: %v\n", err)
return return
@@ -493,17 +480,6 @@ func collectUserInput() Config {
fmt.Println("\n=== Basic Configuration ===") fmt.Println("\n=== Basic Configuration ===")
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
if config.IsEnterprise {
if *redisFlag {
config.IsRedis = true
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
}
}
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
if config.IsPostgreSQL {
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
}
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "") config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
@@ -547,7 +523,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===") fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true) config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
if config.DashboardDomain == "" { if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required") fmt.Println("Error: Dashboard Domain name is required")
@@ -800,42 +776,29 @@ func checkPortsAvailable(port int) error {
} }
func downloadMaxMindDatabase() error { func downloadMaxMindDatabase() error {
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...") fmt.Println("Downloading MaxMind GeoLite2 Country database...")
// Download the GeoLite2 Country databases // Download the GeoLite2 Country database
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz", if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil { "https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err) return fmt.Errorf("failed to download GeoLite2 database: %v", err)
}
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
} }
// Extract the Country database // Extract the database
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil { if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err) return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
}
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
} }
// Find the .mmdb file and move it to the config directory // Find the .mmdb file and move it to the config directory
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil { if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err) return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
}
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
} }
// Clean up the downloaded files // Clean up the downloaded files
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil { if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err) fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
}
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
} }
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!") fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
return nil return nil
} }
+97 -749
View File
File diff suppressed because it is too large Load Diff
+96 -748
View File
File diff suppressed because it is too large Load Diff
-3829
View File
File diff suppressed because it is too large Load Diff
+96 -748
View File
File diff suppressed because it is too large Load Diff
+72 -1119
View File
File diff suppressed because it is too large Load Diff
+98 -750
View File
File diff suppressed because it is too large Load Diff
+90 -742
View File
File diff suppressed because it is too large Load Diff
+97 -749
View File
File diff suppressed because it is too large Load Diff
+95 -747
View File
File diff suppressed because it is too large Load Diff
+99 -751
View File
File diff suppressed because it is too large Load Diff
+129 -781
View File
File diff suppressed because it is too large Load Diff
+93 -745
View File
File diff suppressed because it is too large Load Diff
+98 -750
View File
File diff suppressed because it is too large Load Diff
+91 -743
View File
File diff suppressed because it is too large Load Diff
+98 -750
View File
File diff suppressed because it is too large Load Diff
+143 -795
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -152,8 +152,8 @@
"shareErrorSelectResource": "請選擇一個資源", "shareErrorSelectResource": "請選擇一個資源",
"proxyResourceTitle": "管理公開資源", "proxyResourceTitle": "管理公開資源",
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
"publicResourcesBannerTitle": "基於網頁的公開存取", "proxyResourcesBannerTitle": "基於網頁的公開存取",
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"clientResourceTitle": "管理私有資源", "clientResourceTitle": "管理私有資源",
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
"privateResourcesBannerTitle": "零信任私有存取", "privateResourcesBannerTitle": "零信任私有存取",
@@ -489,7 +489,7 @@
"createdAt": "創建於", "createdAt": "創建於",
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。",
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
"proxyEnableSSL": "啟用 TLS", "proxyEnableSSL": "啟用 SSL",
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
"target": "目標", "target": "目標",
"configureTarget": "配置目標", "configureTarget": "配置目標",
@@ -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
View File
@@ -1,42 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import fs from "fs";
import path from "path";
const withNextIntl = createNextIntlPlugin(); const withNextIntl = createNextIntlPlugin();
// read allowedDevOrigins.json if it exists
let allowedDevOrigins: string[] = [];
const allowedDevOriginsPath = path.join(
process.cwd(),
"allowedDevOrigins.json"
);
if (fs.existsSync(allowedDevOriginsPath)) {
try {
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
allowedDevOrigins = JSON.parse(data);
} catch {}
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
reactCompiler: true, eslint: {
transpilePackages: ["@novnc/novnc"], ignoreDuringBuilds: true
output: "standalone", },
allowedDevOrigins, experimental: {
async redirects() { reactCompiler: true
return [ },
{ output: "standalone"
source: "/:orgId/settings/resources/proxy/:path*",
destination: "/:orgId/settings/resources/public/:path*",
permanent: true
},
{
source: "/:orgId/settings/resources/client/:path*",
destination: "/:orgId/settings/resources/private/:path*",
permanent: true
}
];
}
}; };
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);
+3625 -2538
View File
File diff suppressed because it is too large Load Diff
+58 -65
View File
@@ -32,15 +32,13 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0", "@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1056.0", "@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@faker-js/faker": "10.3.0",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz", "@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.18.0", "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,77 +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",
"gpt-tokenizer": "^3.4.0", "helmet": "8.1.0",
"helmet": "8.2.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.3.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.11", "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",
@@ -166,22 +160,21 @@
"@types/yargs": "17.0.35", "@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.28.1", "esbuild": "0.27.4",
"esbuild-node-externals": "1.22.0", "esbuild-node-externals": "1.20.1",
"eslint": "10.4.0", "eslint": "10.0.3",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.1.7",
"postcss": "8.5.15", "postcss": "8.5.8",
"prettier": "3.8.3", "prettier": "3.8.1",
"react-email": "6.5.0", "react-email": "5.2.10",
"tailwindcss": "4.3.0", "tailwindcss": "4.2.2",
"tsc-alias": "1.8.17", "tsc-alias": "1.8.16",
"tsx": "4.22.3", "tsx": "4.21.0",
"typescript": "6.0.3", "typescript": "5.9.3",
"typescript-eslint": "8.60.0" "typescript-eslint": "8.56.1"
}, },
"overrides": { "overrides": {
"esbuild": "0.28.1", "esbuild": "0.27.4",
"dompurify": "3.4.0", "dompurify": "3.3.2"
"postcss": "8.5.15"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 274 KiB

-39
View File
@@ -1,39 +0,0 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware
} from "@server/middlewares";
import { createAiGatewayRouter } from "@server/routers/aiGateway";
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
export function createAiGatewayServer() {
const aiGatewayServer = express();
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
aiGatewayServer.set("trust proxy", trustProxy);
}
aiGatewayServer.use(helmet());
aiGatewayServer.use(cors());
aiGatewayServer.use(express.json());
aiGatewayServer.use(createAiGatewayRouter());
aiGatewayServer.use(notFoundMiddleware);
aiGatewayServer.use(errorHandlerMiddleware);
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
if (err) throw err;
logger.info(
`AI gateway server is running on http://localhost:${aiGatewayPort}`
);
});
return aiGatewayServer;
}
+19 -70
View File
@@ -5,7 +5,6 @@ import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
export enum ActionsEnum { export enum ActionsEnum {
createOrgUser = "createOrgUser", createOrgUser = "createOrgUser",
@@ -21,8 +20,6 @@ export enum ActionsEnum {
getSite = "getSite", getSite = "getSite",
listSites = "listSites", listSites = "listSites",
updateSite = "updateSite", updateSite = "updateSite",
updateSiteApprovals = "updateSiteApprovals",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth", resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret", reGenerateSecret = "reGenerateSecret",
createResource = "createResource", createResource = "createResource",
@@ -50,8 +47,6 @@ export enum ActionsEnum {
setResourceUsers = "setResourceUsers", setResourceUsers = "setResourceUsers",
setResourceRoles = "setResourceRoles", setResourceRoles = "setResourceRoles",
listResourceUsers = "listResourceUsers", listResourceUsers = "listResourceUsers",
listResourceAiModels = "listResourceAiModels",
setResourceAiModels = "setResourceAiModels",
// removeRoleSite = "removeRoleSite", // removeRoleSite = "removeRoleSite",
// addRoleAction = "addRoleAction", // addRoleAction = "addRoleAction",
// removeRoleAction = "removeRoleAction", // removeRoleAction = "removeRoleAction",
@@ -74,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",
@@ -128,6 +122,8 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain", createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain", deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain", restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode", createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode", getRemoteExitNode = "getRemoteExitNode",
@@ -154,57 +150,14 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule", updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule", deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules", listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule", getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck", createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck", updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck", deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks", listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget", triggerSiteAlert = "triggerSiteAlert",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget", triggerResourceAlert = "triggerResourceAlert",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget", triggerHealthCheckAlert = "triggerHealthCheckAlert"
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets",
listResourcePolicies = "listResourcePolicies",
getResourcePolicy = "getResourcePolicy",
createResourcePolicy = "createResourcePolicy",
updateResourcePolicy = "updateResourcePolicy",
deleteResourcePolicy = "deleteResourcePolicy",
listResourcePolicyRoles = "listResourcePolicyRoles",
setResourcePolicyRoles = "setResourcePolicyRoles",
listResourcePolicyUsers = "listResourcePolicyUsers",
setResourcePolicyUsers = "setResourcePolicyUsers",
setResourcePolicyPassword = "setResourcePolicyPassword",
setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView",
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
createAiModel = "createAiModel",
deleteAiModel = "deleteAiModel",
getAiModel = "getAiModel",
listAiModels = "listAiModels",
updateAiModel = "updateAiModel",
createAiBudget = "createAiBudget",
deleteAiBudget = "deleteAiBudget",
getAiBudget = "getAiBudget",
listAiBudgets = "listAiBudgets",
updateAiBudget = "updateAiBudget",
createVirtualApiKey = "createVirtualApiKey",
deleteVirtualApiKey = "deleteVirtualApiKey",
getVirtualApiKey = "getVirtualApiKey",
listVirtualApiKeys = "listVirtualApiKeys",
updateVirtualApiKey = "updateVirtualApiKey"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(
@@ -237,23 +190,6 @@ export async function checkUserActionPermission(
} }
} }
// If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
if (roleActionPermission.length > 0) {
return true;
}
// Check if the user has direct permission for the action in the current org // Check if the user has direct permission for the action in the current org
const userActionPermission = await db const userActionPermission = await db
.select() .select()
@@ -271,7 +207,20 @@ export async function checkUserActionPermission(
return true; return true;
} }
return false; // If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
return roleActionPermission.length > 0;
} catch (error) { } catch (error) {
console.error("Error checking user action permission:", error); console.error("Error checking user action permission:", error);
throw createHttpError( throw createHttpError(
+25 -97
View File
@@ -1,12 +1,6 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { import { roleResources, userResources } from "@server/db";
rolePolicies,
roleResources,
resources,
userPolicies,
userResources
} from "@server/db";
export async function canUserAccessResource({ export async function canUserAccessResource({
userId, userId,
@@ -17,14 +11,9 @@ export async function canUserAccessResource({
resourceId: number; resourceId: number;
roleIds: number[]; roleIds: number[];
}): Promise<boolean> { }): Promise<boolean> {
const [ const roleResourceAccess =
roleResourceAccess,
rolePolicyAccess,
userResourceAccess,
userPolicyAccess
] = await Promise.all([
roleIds.length > 0 roleIds.length > 0
? db ? await db
.select() .select()
.from(roleResources) .from(roleResources)
.where( .where(
@@ -34,87 +23,26 @@ export async function canUserAccessResource({
) )
) )
.limit(1) .limit(1)
: [], : [];
roleIds.length > 0
? db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
rolePolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
.limit(1)
: [],
db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1),
db
.select({
userId: userPolicies.userId,
resourcePolicyId: userPolicies.resourcePolicyId
})
.from(userPolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
userPolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
userPolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
eq(userPolicies.userId, userId)
)
)
.limit(1)
]);
return ( if (roleResourceAccess.length > 0) {
roleResourceAccess.length > 0 || return true;
rolePolicyAccess.length > 0 || }
userResourceAccess.length > 0 ||
userPolicyAccess.length > 0 const userResourceAccess = await db
); .select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) {
return true;
}
return false;
} }
+1 -40
View File
@@ -12,7 +12,7 @@ import {
users users
} from "@server/db"; } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random"; import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random";
@@ -136,45 +136,6 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
} }
} }
export async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export function serializeSessionCookie( export function serializeSessionCookie(
token: string, token: string,
isSecure: boolean, isSecure: boolean,
+1 -10
View File
@@ -19,9 +19,6 @@ export async function createResourceSession(opts: {
userSessionId?: string | null; userSessionId?: string | null;
whitelistId?: number | null; whitelistId?: number | null;
accessTokenId?: string | null; accessTokenId?: string | null;
policyPasswordId?: number | null;
policyPincodeId?: number | null;
policyWhitelistId?: number | null;
doNotExtend?: boolean; doNotExtend?: boolean;
expiresAt?: number | null; expiresAt?: number | null;
sessionLength?: number | null; sessionLength?: number | null;
@@ -31,10 +28,7 @@ export async function createResourceSession(opts: {
!opts.pincodeId && !opts.pincodeId &&
!opts.whitelistId && !opts.whitelistId &&
!opts.accessTokenId && !opts.accessTokenId &&
!opts.userSessionId && !opts.userSessionId
!opts.policyPasswordId &&
!opts.policyPincodeId &&
!opts.policyWhitelistId
) { ) {
throw new Error("Auth method must be provided"); throw new Error("Auth method must be provided");
} }
@@ -55,9 +49,6 @@ export async function createResourceSession(opts: {
whitelistId: opts.whitelistId || null, whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false, doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null, accessTokenId: opts.accessTokenId || null,
policyPasswordId: opts.policyPasswordId || null,
policyPincodeId: opts.policyPincodeId || null,
policyWhitelistId: opts.policyWhitelistId || null,
isRequestToken: opts.isRequestToken || false, isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null, userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime() issuedAt: new Date().getTime()
-4
View File
@@ -3,16 +3,12 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
import { cleanup as wsCleanup } from "#dynamic/routers/ws"; import { cleanup as wsCleanup } from "#dynamic/routers/ws";
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
async function cleanup() { async function cleanup() {
await stopPingAccumulator(); await stopPingAccumulator();
await flushBandwidthToDb(); await flushBandwidthToDb();
await flushConnectionLogToDb(); await flushConnectionLogToDb();
await flushSiteBandwidthToDb(); await flushSiteBandwidthToDb();
await shutdownUsageRecorder();
await shutdownAiSessionLogger();
await wsCleanup(); await wsCleanup();
process.exit(0); process.exit(0);
+4 -7
View File
@@ -795,13 +795,10 @@ export const COUNTRIES = [
name: "Serbia", name: "Serbia",
code: "RS" code: "RS"
}, },
// Removed as this is a deprecated ISO country code, not supported anymore {
// Also the individual flags for Serbia & Montenegro are already included in the list name: "Serbia and Montenegro",
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS code: "CS"
// { },
// name: "Serbia and Montenegro",
// code: "CS"
// },
{ {
name: "Seychelles", name: "Seychelles",
code: "SC" code: "SC"
+128 -162
View File
@@ -1,53 +1,94 @@
{ {
"PowerMac4,4": "eMac",
"PowerMac6,4": "eMac",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook6,3": "iBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"iMac,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,5": "iMac",
"PowerMac6,1": "iMac",
"PowerMac6,3*": "iMac",
"PowerMac6,3": "iMac",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac12,1": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,2": "iMac",
"iMac5,1": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,3": "iMac",
"iMac14,2": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,2": "iMac",
"iMac19,1": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,2": "iMac",
"iMac21,1": "iMac",
"iMacPro1,1": "iMac Pro",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"ADP3,2": "Mac mini", "ADP3,2": "Mac mini",
"Macmini9,1": "Mac mini",
"Mac14,3": "Mac mini",
"Mac14,12": "Mac mini",
"MacPro1,1*": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"N/A*": "Power Macintosh",
"PowerMac1,1": "Power Macintosh",
"PowerMac3,1": "Power Macintosh",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"Mac13,1": "Mac Studio", "Mac13,1": "Mac Studio",
"Mac13,2": "Mac Studio", "Mac13,2": "Mac Studio",
"Mac14,10": "MacBook Pro",
"Mac14,12": "Mac mini",
"Mac14,13": "Mac Studio",
"Mac14,14": "Mac Studio",
"Mac14,15": "MacBook Air",
"Mac14,2": "MacBook Air",
"Mac14,3": "Mac mini",
"Mac14,5": "MacBook Pro",
"Mac14,6": "MacBook Pro",
"Mac14,7": "MacBook Pro",
"Mac14,8": "Mac Pro",
"Mac14,9": "MacBook Pro",
"Mac15,10": "MacBook Pro",
"Mac15,11": "MacBook Pro",
"Mac15,12": "MacBook Air",
"Mac15,13": "MacBook Air",
"Mac15,14": "Mac Studio",
"Mac15,3": "MacBook Pro",
"Mac15,4": "iMac",
"Mac15,5": "iMac",
"Mac15,6": "MacBook Pro",
"Mac15,7": "MacBook Pro",
"Mac15,8": "MacBook Pro",
"Mac15,9": "MacBook Pro",
"Mac16,1": "MacBook Pro",
"Mac16,10": "Mac mini",
"Mac16,11": "Mac mini",
"Mac16,12": "MacBook Air",
"Mac16,13": "MacBook Air",
"Mac16,2": "iMac",
"Mac16,3": "iMac",
"Mac16,5": "MacBook Pro",
"Mac16,6": "MacBook Pro",
"Mac16,7": "MacBook Pro",
"Mac16,8": "MacBook Pro",
"Mac16,9": "Mac Studio",
"Mac17,2": "MacBook Pro",
"Mac17,3": "MacBook Air",
"Mac17,4": "MacBook Air",
"Mac17,5": "MacBook Neo",
"Mac17,6": "MacBook Pro",
"Mac17,7": "MacBook Pro",
"Mac17,8": "MacBook Pro",
"Mac17,9": "MacBook Pro",
"MacBook1,1": "MacBook", "MacBook1,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBook2,1": "MacBook", "MacBook2,1": "MacBook",
"MacBook3,1": "MacBook", "MacBook3,1": "MacBook",
"MacBook4,1": "MacBook", "MacBook4,1": "MacBook",
@@ -57,8 +98,8 @@
"MacBook7,1": "MacBook", "MacBook7,1": "MacBook",
"MacBook8,1": "MacBook", "MacBook8,1": "MacBook",
"MacBook9,1": "MacBook", "MacBook9,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBookAir1,1": "MacBook Air", "MacBookAir1,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"MacBookAir2,1": "MacBook Air", "MacBookAir2,1": "MacBook Air",
"MacBookAir3,1": "MacBook Air", "MacBookAir3,1": "MacBook Air",
"MacBookAir3,2": "MacBook Air", "MacBookAir3,2": "MacBook Air",
@@ -73,163 +114,88 @@
"MacBookAir8,1": "MacBook Air", "MacBookAir8,1": "MacBook Air",
"MacBookAir8,2": "MacBook Air", "MacBookAir8,2": "MacBook Air",
"MacBookAir9,1": "MacBook Air", "MacBookAir9,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"Mac14,2": "MacBook Air",
"MacBookPro1,1": "MacBook Pro", "MacBookPro1,1": "MacBook Pro",
"MacBookPro1,2": "MacBook Pro", "MacBookPro1,2": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro",
"MacBookPro5,5": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro",
"MacBookPro7,1": "MacBook Pro",
"MacBookPro6,2": "MacBook Pro",
"MacBookPro6,1": "MacBook Pro",
"MacBookPro8,1": "MacBook Pro",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro10,1": "MacBook Pro", "MacBookPro10,1": "MacBook Pro",
"MacBookPro10,2": "MacBook Pro", "MacBookPro10,2": "MacBook Pro",
"MacBookPro11,1": "MacBook Pro", "MacBookPro11,1": "MacBook Pro",
"MacBookPro11,2": "MacBook Pro", "MacBookPro11,2": "MacBook Pro",
"MacBookPro11,3": "MacBook Pro", "MacBookPro11,3": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro11,4": "MacBook Pro", "MacBookPro11,4": "MacBook Pro",
"MacBookPro11,5": "MacBook Pro", "MacBookPro11,5": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro13,1": "MacBook Pro", "MacBookPro13,1": "MacBook Pro",
"MacBookPro13,2": "MacBook Pro", "MacBookPro13,2": "MacBook Pro",
"MacBookPro13,3": "MacBook Pro", "MacBookPro13,3": "MacBook Pro",
"MacBookPro14,1": "MacBook Pro", "MacBookPro14,1": "MacBook Pro",
"MacBookPro14,2": "MacBook Pro", "MacBookPro14,2": "MacBook Pro",
"MacBookPro14,3": "MacBook Pro", "MacBookPro14,3": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro", "MacBookPro15,2": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
"MacBookPro15,3": "MacBook Pro", "MacBookPro15,3": "MacBook Pro",
"MacBookPro15,4": "MacBook Pro", "MacBookPro15,4": "MacBook Pro",
"MacBookPro16,1": "MacBook Pro", "MacBookPro16,1": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro", "MacBookPro16,3": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
"MacBookPro16,4": "MacBook Pro", "MacBookPro16,4": "MacBook Pro",
"MacBookPro17,1": "MacBook Pro", "MacBookPro17,1": "MacBook Pro",
"MacBookPro18,1": "MacBook Pro",
"MacBookPro18,2": "MacBook Pro",
"MacBookPro18,3": "MacBook Pro", "MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro", "MacBookPro18,4": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro", "MacBookPro18,1": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro", "MacBookPro18,2": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro", "Mac14,7": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro", "Mac14,9": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro", "Mac14,5": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro", "Mac14,10": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro", "Mac14,6": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro", "PowerMac1,2": "Power Macintosh",
"MacBookPro5,5": "MacBook Pro", "PowerMac5,1": "Power Macintosh",
"MacBookPro6,1": "MacBook Pro", "PowerMac7,2": "Power Macintosh",
"MacBookPro6,2": "MacBook Pro", "PowerMac7,3": "Power Macintosh",
"MacBookPro7,1": "MacBook Pro", "PowerMac9,1": "Power Macintosh",
"MacBookPro8,1": "MacBook Pro", "PowerMac11,2": "Power Macintosh",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacPro1,1": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"Macmini9,1": "Mac mini",
"PowerBook1,1": "PowerBook", "PowerBook1,1": "PowerBook",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook3,1": "PowerBook", "PowerBook3,1": "PowerBook",
"PowerBook3,2": "PowerBook", "PowerBook3,2": "PowerBook",
"PowerBook3,3": "PowerBook", "PowerBook3,3": "PowerBook",
"PowerBook3,4": "PowerBook", "PowerBook3,4": "PowerBook",
"PowerBook3,5": "PowerBook", "PowerBook3,5": "PowerBook",
"PowerBook4,1": "iBook", "PowerBook6,1": "PowerBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook5,1": "PowerBook", "PowerBook5,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook5,2": "PowerBook", "PowerBook5,2": "PowerBook",
"PowerBook5,3": "PowerBook", "PowerBook5,3": "PowerBook",
"PowerBook6,4": "PowerBook",
"PowerBook5,4": "PowerBook", "PowerBook5,4": "PowerBook",
"PowerBook5,5": "PowerBook", "PowerBook5,5": "PowerBook",
"PowerBook6,8": "PowerBook",
"PowerBook5,6": "PowerBook", "PowerBook5,6": "PowerBook",
"PowerBook5,7": "PowerBook", "PowerBook5,7": "PowerBook",
"PowerBook5,8": "PowerBook", "PowerBook5,8": "PowerBook",
"PowerBook5,9": "PowerBook", "PowerBook5,9": "PowerBook",
"PowerBook6,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook6,3": "iBook",
"PowerBook6,4": "PowerBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"PowerBook6,8": "PowerBook",
"PowerMac1,1": "Power Macintosh",
"PowerMac1,2": "Power Macintosh",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"PowerMac11,2": "Power Macintosh",
"PowerMac12,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac3,1": "Mac Server",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,4": "eMac",
"PowerMac4,5": "iMac",
"PowerMac5,1": "Power Macintosh",
"PowerMac6,1": "iMac",
"PowerMac6,3": "iMac",
"PowerMac6,4": "eMac",
"PowerMac7,2": "Power Macintosh",
"PowerMac7,3": "Power Macintosh",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac9,1": "Power Macintosh",
"RackMac1,1": "Xserve", "RackMac1,1": "Xserve",
"RackMac1,2": "Xserve", "RackMac1,2": "Xserve",
"RackMac3,1": "Xserve", "RackMac3,1": "Xserve",
"Xserve1,1": "Xserve", "Xserve1,1": "Xserve",
"Xserve2,1": "Xserve", "Xserve2,1": "Xserve",
"Xserve3,1": "Xserve", "Xserve3,1": "Xserve"
"iMac,1": "iMac", }
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,2": "iMac",
"iMac14,3": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,1": "iMac",
"iMac19,2": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,1": "iMac",
"iMac21,2": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,1": "iMac",
"iMac5,2": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMacPro1,1": "iMac Pro"
}
+1 -36
View File
@@ -1,12 +1,6 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { import { clients, db, resources, siteResources } from "@server/db";
clients,
db,
resourcePolicies,
resources,
siteResources
} from "@server/db";
import { randomInt } from "crypto"; import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@@ -113,35 +107,6 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
} }
} }
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const policyCount = await db
.select({
niceId: resourcePolicies.niceId,
orgId: resourcePolicies.orgId
})
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, name),
eq(resourcePolicies.orgId, orgId)
)
);
if (policyCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueSiteResourceName( export async function getUniqueSiteResourceName(
orgId: string orgId: string
): Promise<string> { ): Promise<string> {
+1 -1
View File
@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types export const primaryDb = db.$primary;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];
-1
View File
@@ -4,4 +4,3 @@ export * from "./safeRead";
export * from "./schema/schema"; export * from "./schema/schema";
export * from "./schema/privateSchema"; export * from "./schema/privateSchema";
export * from "./migrate"; export * from "./migrate";
export { alias } from "drizzle-orm/pg-core";
+4 -3
View File
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { readConfigFile } from "@server/lib/readConfigFile"; import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core"; import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build"; import { build } from "@server/build";
import { db as mainDb } from "./driver"; import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
import { createPool } from "./poolConfig"; import { createPool } from "./poolConfig";
function createLogsDb() { function createLogsDb() {
@@ -63,7 +63,8 @@ function createLogsDb() {
}) })
); );
} else { } else {
const maxReplicaConnections = poolConfig?.max_replica_connections || 20; const maxReplicaConnections =
poolConfig?.max_replica_connections || 20;
for (const conn of replicaConnections) { for (const conn of replicaConnections) {
const replicaPool = createPool( const replicaPool = createPool(
conn.connection_string, conn.connection_string,
@@ -90,4 +91,4 @@ function createLogsDb() {
export const logsDb = createLogsDb(); export const logsDb = createLogsDb();
export default logsDb; export default logsDb;
export const primaryLogsDb = logsDb.$primary; export const primaryLogsDb = logsDb.$primary;
+4 -26
View File
@@ -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;
} }
+16 -90
View File
@@ -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", {
@@ -95,8 +93,7 @@ export const subscriptions = pgTable("subscriptions", {
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }),
trial: boolean("trial").default(false), trial: boolean("trial").default(false),
type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
override: boolean("override").default(false)
}); });
export const subscriptionItems = pgTable("subscriptionItems", { export const subscriptionItems = pgTable("subscriptionItems", {
@@ -200,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")
@@ -246,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")
@@ -382,7 +332,6 @@ export const connectionAuditLog = pgTable(
clientId: integer("clientId").references(() => clients.clientId, { clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
@@ -490,8 +439,6 @@ export const eventStreamingDestinations = pgTable(
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc. type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: bigint("lastErrorAt", { mode: "number" }), // epoch ms of last error, null if healthy
createdAt: bigint("createdAt", { mode: "number" }).notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull() updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
} }
@@ -537,7 +484,6 @@ export const alertRules = pgTable("alertRules", {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle" | "resource_toggle"
>() >()
.notNull(), .notNull(),
@@ -619,17 +565,6 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
}); });
export const trialNotifications = pgTable("trialNotifications", {
notificationId: serial("notificationId").primaryKey(),
subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -668,12 +603,3 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRules = InferSelectModel<typeof alertRules>;
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipients = InferSelectModel<
typeof alertEmailRecipients
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
File diff suppressed because it is too large Load Diff
+41 -312
View File
@@ -17,37 +17,22 @@ import {
resourceHeaderAuth, resourceHeaderAuth,
ResourceHeaderAuth, ResourceHeaderAuth,
resourceRules, resourceRules,
resourcePolicyRules,
resources, resources,
roleResources, roleResources,
rolePolicies,
sessions, sessions,
userResources, userResources,
userPolicies,
users, users,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility
resourcePolicies,
resourcePolicyPincode,
ResourcePolicyPincode,
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
} from "@server/db"; } from "@server/db";
import { alias } from "@server/db"; import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import logger from "@server/logger";
export type ResourceWithAuth = { export type ResourceWithAuth = {
resource: Resource | null; resource: Resource | null;
pincode: ResourcePincode | ResourcePolicyPincode | null; pincode: ResourcePincode | null;
password: ResourcePassword | ResourcePolicyPassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org; org: Org;
}; };
@@ -62,44 +47,7 @@ export type UserSessionWithUser = {
export async function getResourceByDomain( export async function getResourceByDomain(
domain: string domain: string
): Promise<ResourceWithAuth | null> { ): Promise<ResourceWithAuth | null> {
// Build wildcard domain variants to match against. const [result] = await db
// For a domain like "me.example.test.com", we want to match:
// - "*.example.test.com" (subdomain wildcard)
// - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent)
const parts = domain.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < parts.length; i++) {
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
const sharedPolicyPincode = alias(
resourcePolicyPincode,
"sharedPolicyPincode"
);
const defaultPolicyPincode = alias(
resourcePolicyPincode,
"defaultPolicyPincode"
);
const sharedPolicyPassword = alias(
resourcePolicyPassword,
"sharedPolicyPassword"
);
const defaultPolicyPassword = alias(
resourcePolicyPassword,
"defaultPolicyPassword"
);
const sharedPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"sharedPolicyHeaderAuth"
);
const defaultPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"defaultPolicyHeaderAuth"
);
const potentialResults = await db
.select() .select()
.from(resources) .from(resources)
.leftJoin( .leftJoin(
@@ -121,133 +69,21 @@ export async function getResourceByDomain(
resources.resourceId resources.resourceId
) )
) )
.leftJoin(
sharedPolicy,
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
sharedPolicyPincode,
eq(
sharedPolicyPincode.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
sharedPolicyPassword,
eq(
sharedPolicyPassword.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
sharedPolicyHeaderAuth,
eq(
sharedPolicyHeaderAuth.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicy,
eq(
defaultPolicy.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
.leftJoin(
defaultPolicyPincode,
eq(
defaultPolicyPincode.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyPassword,
eq(
defaultPolicyPassword.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyHeaderAuth,
eq(
defaultPolicyHeaderAuth.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where( .where(eq(resources.fullDomain, domain))
or( .limit(1);
// Exact match
eq(resources.fullDomain, domain),
// Wildcard match: resource fullDomain is one of the wildcard candidates
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
if (!potentialResults.length) {
return null;
}
// Prefer exact match over wildcard match
const exactMatch = potentialResults.find(
(r) => r.resources?.fullDomain === domain
);
const result = exactMatch ?? potentialResults[0];
if (!result) { if (!result) {
return null; return null;
} }
// If a shared (custom) policy is assigned to the resource, use ONLY
// its values — do not fall back to the default policy. The default
// policy is only consulted when no shared policy is assigned at all.
const hasSharedPolicy = result.sharedPolicy !== null;
const effectivePolicyPincode = hasSharedPolicy
? result.sharedPolicyPincode
: (result.defaultPolicyPincode ?? null);
const effectivePolicyPassword = hasSharedPolicy
? result.sharedPolicyPassword
: (result.defaultPolicyPassword ?? null);
const effectivePolicyHeaderAuth = hasSharedPolicy
? result.sharedPolicyHeaderAuth
: (result.defaultPolicyHeaderAuth ?? null);
const selectedPolicy = hasSharedPolicy
? result.sharedPolicy
: result.defaultPolicy;
const effectiveApplyRules =
selectedPolicy?.applyRules ?? result.resources.applyRules;
const effectiveSSO = selectedPolicy?.sso ?? result.resources.sso;
const effectiveEmailWhitelistEnabled =
selectedPolicy?.emailWhitelistEnabled ??
result.resources.emailWhitelistEnabled;
return { return {
resource: { resource: result.resources,
...result.resources, pincode: result.resourcePincode,
applyRules: effectiveApplyRules, password: result.resourcePassword,
sso: effectiveSSO, headerAuth: result.resourceHeaderAuth,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled headerAuthExtendedCompatibility:
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct result.resourceHeaderAuthExtendedCompatibility,
pincode: effectivePolicyPincode ?? result.resourcePincode,
password: effectivePolicyPassword ?? result.resourcePassword,
headerAuth: effectivePolicyHeaderAuth ?? result.resourceHeaderAuth,
headerAuthExtendedCompatibility: effectivePolicyHeaderAuth
? ({
headerAuthExtendedCompatibilityId: 0,
resourceId: result.resources.resourceId,
extendedCompatibilityIsActivated:
effectivePolicyHeaderAuth.extendedCompatibility
} as ResourceHeaderAuthExtendedCompatibility)
: result.resourceHeaderAuthExtendedCompatibility,
applyRules: effectiveApplyRules,
sso: effectiveSSO,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled,
org: result.orgs org: result.orgs
}; };
} }
@@ -287,165 +123,58 @@ export async function getRoleName(roleId: number): Promise<string | null> {
} }
/** /**
* Check if role has access to resource (direct or via resource policy) * Check if role has access to resource
*/ */
export async function getRoleResourceAccess( export async function getRoleResourceAccess(
resourceId: number, resourceId: number,
roleIds: number[] roleIds: number[]
) { ) {
const [direct, viaPolicies] = await Promise.all([ const roleResourceAccess = await db
db .select()
.select() .from(roleResources)
.from(roleResources) .where(
.where( and(
and( eq(roleResources.resourceId, resourceId),
eq(roleResources.resourceId, resourceId), inArray(roleResources.roleId, roleIds)
inArray(roleResources.roleId, roleIds)
)
),
db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
rolePolicies.resourcePolicyId
)
)
)
) )
.where( );
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
]);
const combined = [...direct, ...viaPolicies]; return roleResourceAccess.length > 0 ? roleResourceAccess : null;
return combined.length > 0 ? combined : null;
} }
/** /**
* Check if user has access to resource (direct or via resource policy) * Check if user has direct access to resource
*/ */
export async function getUserResourceAccess( export async function getUserResourceAccess(
userId: string, userId: string,
resourceId: number resourceId: number
) { ) {
const [direct, viaPolicies] = await Promise.all([ const userResourceAccess = await db
db .select()
.select() .from(userResources)
.from(userResources) .where(
.where( and(
and( eq(userResources.userId, userId),
eq(userResources.userId, userId), eq(userResources.resourceId, resourceId)
eq(userResources.resourceId, resourceId)
)
) )
.limit(1), )
db .limit(1);
.select({
userId: userPolicies.userId,
resourcePolicyId: userPolicies.resourcePolicyId
})
.from(userPolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
userPolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
userPolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
eq(userPolicies.userId, userId)
)
)
.limit(1)
]);
return direct[0] ?? viaPolicies[0] ?? null; return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
} }
/** /**
* Get resource rules for a given resource (direct and via resource policy) * Get resource rules for a given resource
*/ */
export async function getResourceRules( export async function getResourceRules(
resourceId: number resourceId: number
): Promise<ResourceRule[]> { ): Promise<ResourceRule[]> {
const [directRules, policyRules] = await Promise.all([ const rules = await db
db .select()
.select() .from(resourceRules)
.from(resourceRules) .where(eq(resourceRules.resourceId, resourceId));
.where(eq(resourceRules.resourceId, resourceId)),
db
.select({
ruleId: resourcePolicyRules.ruleId,
resourceId: sql<number>`${resourceId}`,
enabled: resourcePolicyRules.enabled,
priority: resourcePolicyRules.priority,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value
})
.from(resourcePolicyRules)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
resourcePolicyRules.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
resourcePolicyRules.resourcePolicyId
)
)
)
)
.where(eq(resources.resourceId, resourceId))
]);
const maxDirectPriority = directRules.reduce( return rules;
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
} }
/** /**
+2 -31
View File
@@ -5,7 +5,6 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs"; import { existsSync, mkdirSync } from "fs";
import logger from "@server/logger";
export const location = path.join(APP_PATH, "db", "db.sqlite"); export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = checkFileExists(location); export const exists = checkFileExists(location);
@@ -13,35 +12,7 @@ export const exists = checkFileExists(location);
bootstrapVolume(); bootstrapVolume();
function createDb() { function createDb() {
const verbose = const sqlite = new Database(location);
process.env.QUERY_LOGGING == "true"
? (message: unknown) => logger.debug(String(message))
: undefined;
const sqlite = new Database(location, { verbose });
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL");
}
// No busy_timeout pragma: better-sqlite3 already arms
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
// (lib/database.js), so an explicit pragma is redundant.
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
// fresh statement per query (no statement cache), so statements cannot
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
return DrizzleSqlite(sqlite, { return DrizzleSqlite(sqlite, {
schema schema
}); });
@@ -52,7 +23,7 @@ export default db;
export const primaryDb = db; export const primaryDb = db;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];
export const DB_TYPE: "pg" | "sqlite" = "sqlite"; export const DB_TYPE: "pg" | "sqlite" = "sqlite";
function checkFileExists(filePath: string): boolean { function checkFileExists(filePath: string): boolean {
-1
View File
@@ -4,4 +4,3 @@ export * from "./safeRead";
export * from "./schema/schema"; export * from "./schema/schema";
export * from "./schema/privateSchema"; export * from "./schema/privateSchema";
export * from "./migrate"; export * from "./migrate";
export { alias } from "drizzle-orm/sqlite-core";
+12 -96
View File
@@ -12,7 +12,6 @@ import {
clients, clients,
domains, domains,
exitNodes, exitNodes,
labels,
orgs, orgs,
resources, resources,
roles, roles,
@@ -89,8 +88,7 @@ export const subscriptions = sqliteTable("subscriptions", {
expiresAt: integer("expiresAt"), expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false), trial: integer("trial", { mode: "boolean" }).default(false),
billingCycleAnchor: integer("billingCycleAnchor"), billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type"), // tier1, tier2, tier3, or license type: text("type") // tier1, tier2, tier3, or license
override: integer("override", { mode: "boolean" }).default(false)
}); });
export const subscriptionItems = sqliteTable("subscriptionItems", { export const subscriptionItems = sqliteTable("subscriptionItems", {
@@ -194,44 +192,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")
@@ -369,7 +329,6 @@ export const connectionAuditLog = sqliteTable(
clientId: integer("clientId").references(() => clients.clientId, { clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
@@ -466,25 +425,15 @@ export const eventStreamingDestinations = sqliteTable(
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }) sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false),
.notNull() sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false),
.default(false), sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false),
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }) sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false),
.notNull()
.default(false),
sendActionLogs: integer("sendActionLogs", { mode: "boolean" })
.notNull()
.default(false),
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" })
.notNull()
.default(false),
type: text("type").notNull(), // e.g. "http", "kafka", etc. type: text("type").notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: integer("lastErrorAt"), // epoch ms of last error, null if healthy
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull() updatedAt: integer("updatedAt").notNull()
} }
@@ -527,19 +476,14 @@ export const alertRules = sqliteTable("alertRules", {
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle" | "resource_toggle"
>() >()
.notNull(), .notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300), cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), allSites: integer("allSites", { mode: "boolean" }).notNull().default(false),
allHealthChecks: integer("allHealthChecks", { mode: "boolean" }) allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false),
.notNull() allResources: integer("allResources", { mode: "boolean" }).notNull().default(false),
.default(false),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
lastTriggeredAt: integer("lastTriggeredAt"), lastTriggeredAt: integer("lastTriggeredAt"),
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull() updatedAt: integer("updatedAt").notNull()
@@ -587,44 +531,23 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
emailActionId: integer("emailActionId") emailActionId: integer("emailActionId")
.notNull() .notNull()
.references(() => alertEmailActions.emailActionId, { .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
onDelete: "cascade" userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
}), roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
email: text("email") email: text("email")
}); });
export const alertWebhookActions = sqliteTable("alertWebhookActions", { export const alertWebhookActions = sqliteTable("alertWebhookActions", {
webhookActionId: integer("webhookActionId").primaryKey({ webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }),
autoIncrement: true
}),
alertRuleId: integer("alertRuleId") alertRuleId: integer("alertRuleId")
.notNull() .notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }), .references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
webhookUrl: text("webhookUrl").notNull(), webhookUrl: text("webhookUrl").notNull(),
config: text("config"), // encrypted JSON with auth config (authType, credentials) config: text("config"), // encrypted JSON with auth config (authType, credentials)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSentAt: integer("lastSentAt") lastSentAt: integer("lastSentAt")
}); });
export const trialNotifications = sqliteTable("trialNotifications", {
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: integer("sentAt").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -657,10 +580,3 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRule = InferSelectModel<typeof alertRules>;
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
File diff suppressed because it is too large Load Diff
+17 -67
View File
@@ -23,7 +23,6 @@ export type AlertEventType =
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy" | "resource_healthy"
| "resource_unhealthy" | "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"; | "resource_toggle";
export type AlertNotificationProps = { export type AlertNotificationProps = {
@@ -37,8 +36,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: string; heading: string;
previewText: string; previewText: string;
summary: string; summary: string;
statusLabel: string | null; statusLabel: string;
statusColor: string | null; statusColor: string;
} { } {
switch (eventType) { switch (eventType) {
case "site_online": case "site_online":
@@ -64,8 +63,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Site Status Changed", heading: "Site Status Changed",
previewText: "A site in your organization has changed status.", previewText: "A site in your organization has changed status.",
summary: "A site in your organization has changed status.", summary: "A site in your organization has changed status.",
statusLabel: null, statusLabel: "Status Changed",
statusColor: null statusColor: "#f59e0b"
}; };
case "health_check_healthy": case "health_check_healthy":
return { return {
@@ -94,8 +93,8 @@ function getEventMeta(eventType: AlertEventType): {
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
summary: summary:
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
statusLabel: null, statusLabel: "Status Changed",
statusColor: null statusColor: "#f59e0b"
}; };
case "resource_healthy": case "resource_healthy":
return { return {
@@ -115,23 +114,14 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Unhealthy", statusLabel: "Unhealthy",
statusColor: "#dc2626" statusColor: "#dc2626"
}; };
case "resource_degraded":
return {
heading: "Resource Degraded",
previewText: "A resource in your organization is degraded.",
summary:
"A resource in your organization is currently degraded.",
statusLabel: "Degraded",
statusColor: "#dc2626"
};
case "resource_toggle": case "resource_toggle":
return { return {
heading: "Resource Status Changed", heading: "Resource Status Changed",
previewText: previewText:
"A resource in your organization has changed status.", "A resource in your organization has changed status.",
summary: "A resource in your organization has changed status.", summary: "A resource in your organization has changed status.",
statusLabel: null, statusLabel: "Status Changed",
statusColor: null statusColor: "#f59e0b"
}; };
default: default:
return { return {
@@ -145,31 +135,11 @@ function getEventMeta(eventType: AlertEventType): {
} }
} }
function resolveToggleStatus(status: unknown): {
label: string;
color: string;
} {
switch (String(status).toLowerCase()) {
case "online":
return { label: "Online", color: "#16a34a" };
case "offline":
return { label: "Offline", color: "#dc2626" };
case "healthy":
return { label: "Healthy", color: "#16a34a" };
case "unhealthy":
return { label: "Unhealthy", color: "#dc2626" };
case "degraded":
return { label: "Degraded", color: "#dc2626" };
default:
return { label: String(status ?? "Unknown"), color: "#f59e0b" };
}
}
function formatDataItems( function formatDataItems(
data: Record<string, unknown> data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] { ): { label: string; value: React.ReactNode }[] {
return Object.entries(data) return Object.entries(data)
.filter(([key]) => key !== "orgId" && key !== "status") .filter(([key]) => key !== "orgId")
.map(([key, value]) => ({ .map(([key, value]) => ({
label: key label: key
.replace(/([A-Z])/g, " $1") .replace(/([A-Z])/g, " $1")
@@ -184,36 +154,16 @@ export const AlertNotification = (props: AlertNotificationProps) => {
const meta = getEventMeta(eventType); const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data); const dataItems = formatDataItems(data);
const isToggle =
eventType === "site_toggle" ||
eventType === "health_check_toggle" ||
eventType === "resource_toggle";
const resolvedStatus = isToggle
? resolveToggleStatus(data.status)
: meta.statusLabel != null
? { label: meta.statusLabel, color: meta.statusColor! }
: null;
const allItems: { label: string; value: React.ReactNode }[] = [ const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId }, { label: "Organization", value: orgId },
...(resolvedStatus != null {
? [ label: "Status",
{ value: (
label: "Status", <span style={{ color: meta.statusColor, fontWeight: 600 }}>
value: ( {meta.statusLabel}
<span </span>
style={{ )
color: resolvedStatus.color, },
fontWeight: 600
}}
>
{resolvedStatus.label}
</span>
)
}
]
: []),
{ label: "Time", value: new Date().toUTCString() }, { label: "Time", value: new Date().toUTCString() },
...dataItems ...dataItems
]; ];
+13 -14
View File
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
const isLastDay = daysRemaining === 1; const isLastDay = daysRemaining === 1;
const previewText = hasEnded const previewText = hasEnded
? `Your cloud trial for ${orgName} has ended.` ? `Your trial for ${orgName} has ended.`
: isLastDay : isLastDay
? `Your cloud trial for ${orgName} ends tomorrow.` ? `Your trial for ${orgName} ends tomorrow.`
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`; : `Your trial for ${orgName} ends in ${daysRemaining} days.`;
const heading = hasEnded const heading = hasEnded
? "Your Cloud Trial Ended" ? "Your Trial Ended"
: "Your Cloud Trial is Ending Soon"; : "Your Trial is Ending Soon";
return ( return (
<Html> <Html>
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
{hasEnded ? ( {hasEnded ? (
<> <>
<EmailText> <EmailText>
Your cloud free trial for{" "} Your free trial for{" "}
<strong>{orgName}</strong> ended on{" "} <strong>{orgName}</strong> ended on{" "}
<strong>{trialEndsAt}</strong>. Your account <strong>{trialEndsAt}</strong>. Your account
has been moved to the free plan, which has been moved to the free plan, which
@@ -64,11 +64,10 @@ export const NotifyTrialExpiring = ({
<EmailText> <EmailText>
Some features and resources may now be Some features and resources may now be
restricted. To restore full access and restricted or disconnected. To restore full
continue using all the features you had access and continue using all the features
during your trial, please upgrade to a paid you had during your trial, please upgrade to
plan. This does not effect any self hosted a paid plan.
licenses.
</EmailText> </EmailText>
<EmailText> <EmailText>
@@ -86,7 +85,7 @@ export const NotifyTrialExpiring = ({
<strong>{orgName}</strong> will end on{" "} <strong>{orgName}</strong> will end on{" "}
<strong>{trialEndsAt}</strong> <strong>{trialEndsAt}</strong>
{isLastDay {isLastDay
? " - that's tomorrow!" ? " that's tomorrow!"
: `, in ${daysRemaining} days`} : `, in ${daysRemaining} days`}
. .
</EmailText> </EmailText>
@@ -94,8 +93,8 @@ export const NotifyTrialExpiring = ({
<EmailText> <EmailText>
After your trial ends, your account will be After your trial ends, your account will be
moved to the free plan and some moved to the free plan and some
functionality may be restricted. This does functionality may be restricted or your
not effect any self hosted licenses. sites may disconnect.
</EmailText> </EmailText>
<EmailText> <EmailText>
+2 -17
View File
@@ -1,24 +1,19 @@
#! /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";
import { createNextServer } from "./nextServer"; import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer"; import { createInternalServer } from "./internalServer";
import { createAiGatewayServer } from "./aiGatewayServer";
import { createIntegrationApiServer } from "./integrationApiServer"; import { createIntegrationApiServer } from "./integrationApiServer";
import { import {
ApiKey, ApiKey,
ApiKeyOrg, ApiKeyOrg,
AiBudget,
AiModel,
AiProvider,
RemoteExitNode, RemoteExitNode,
Session, Session,
SiteResource, SiteResource,
User, User,
UserOrg, UserOrg
VirtualApiKey
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta"; import { setHostMeta } from "@server/lib/hostMeta";
@@ -29,8 +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";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
async function startServers() { async function startServers() {
await setHostMeta(); await setHostMeta();
@@ -48,13 +41,10 @@ async function startServers() {
initLogCleanupInterval(); initLogCleanupInterval();
initAcmeCertSync(); initAcmeCertSync();
startRebuildQueueProcessor();
await initAiModelCatalog();
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();
const internalServer = createInternalServer(); const internalServer = createInternalServer();
const aiGatewayServer = createAiGatewayServer();
const nextServer = await createNextServer(); const nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) { if (config.getRawConfig().traefik.file_mode) {
@@ -73,7 +63,6 @@ async function startServers() {
apiServer, apiServer,
nextServer, nextServer,
internalServer, internalServer,
aiGatewayServer,
integrationServer integrationServer
}; };
} }
@@ -92,10 +81,6 @@ declare global {
userOrgIds?: string[]; userOrgIds?: string[];
remoteExitNode?: RemoteExitNode; remoteExitNode?: RemoteExitNode;
siteResource?: SiteResource; siteResource?: SiteResource;
aiProvider?: AiProvider;
aiModel?: AiModel;
aiBudget?: AiBudget;
virtualApiKey?: VirtualApiKey;
orgPolicyAllowed?: boolean; orgPolicyAllowed?: boolean;
} }
} }
+5 -14
View File
@@ -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) {
-571
View File
@@ -1,571 +0,0 @@
import { and, eq, gte, inArray, isNull, or, sql, SQL, type InferInsertModel } from "drizzle-orm";
import {
AiBudget,
aiBudgetBreachEvents,
aiBudgets,
aiModels,
aiUsageRecords,
db,
userOrgRoles
} from "@server/db";
import { modelKeyMatches } from "@server/lib/aiModelKeyMatch";
import type { AiUsage } from "@server/lib/aiUsageExtraction";
import { regionalCache as cache } from "#dynamic/lib/cache";
import logger from "@server/logger";
type BudgetPeriod = AiBudget["period"];
const PERIOD_DURATIONS_MS: Record<Exclude<BudgetPeriod, "lifetime">, number> = {
hourly: 60 * 60 * 1000,
daily: 24 * 60 * 60 * 1000,
weekly: 7 * 24 * 60 * 60 * 1000,
monthly: 30 * 24 * 60 * 60 * 1000,
yearly: 365 * 24 * 60 * 60 * 1000
};
// Budgets are cheap to be a little stale about (enforcement is already
// check-then-act, not transactional). Re-derive each budget's usage sum
// from aiUsageRecords at most this often; in between, completed requests
// just add their own contribution onto the cached sum instead of
// re-querying/re-aggregating from scratch.
const BUDGET_CACHE_REFRESH_MS = 8_000;
// Redis-level TTL is only a safety net for eviction if a budget stops
// seeing traffic - the actual staleness check is the computedAt timestamp
// stored in the cached value, compared against BUDGET_CACHE_REFRESH_MS.
const BUDGET_CACHE_SAFETY_TTL_SEC = 60;
function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string {
const roleKey = [...ctx.roleIds].sort((a, b) => a - b).join(",");
return [
"aiBudget:applicable",
ctx.orgId,
ctx.providerId,
ctx.requestedModel,
ctx.resourceId ?? "",
ctx.siteResourceId ?? "",
roleKey
].join(":");
}
function budgetUsageCacheKey(budgetId: number): string {
return `aiBudget:usage:${budgetId}`;
}
type CachedBudgetUsage = {
sum: number;
computedAt: number;
};
// Budget periods are trailing windows from "now", not calendar-aligned
// (e.g. "daily" = last 24h). "lifetime" has no lower bound.
function windowStart(period: BudgetPeriod, now: number): number {
if (period === "lifetime") {
return 0;
}
return now - PERIOD_DURATIONS_MS[period];
}
export type BudgetScopeContext = {
orgId: string;
providerId: number;
requestedModel: string;
resourceId: number | null;
siteResourceId: number | null;
roleIds: number[];
requestUserId: string | null;
};
/**
* Every budget that could apply to this request: the provider itself, any
* model on that provider whose (possibly wildcarded) modelKey matches the
* requested model, the target resource/site-resource, and any role the
* requesting user holds in the org. Cached for BUDGET_CACHE_REFRESH_MS since
* budget/model config changes are rare and a request-scoped org/provider/
* model/resource/role combination repeats constantly under real traffic.
*/
export async function resolveApplicableBudgets(
ctx: BudgetScopeContext
): Promise<AiBudget[]> {
const cacheKey = applicableBudgetsCacheKey(ctx);
const cached = await cache.get<AiBudget[]>(cacheKey);
if (cached !== undefined) {
return cached;
}
const budgets = await fetchApplicableBudgets(ctx);
await cache.set(cacheKey, budgets, BUDGET_CACHE_REFRESH_MS / 1000);
return budgets;
}
async function fetchApplicableBudgets(
ctx: BudgetScopeContext
): Promise<AiBudget[]> {
const providerModels = await db
.select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, ctx.providerId),
eq(aiModels.enabled, true)
)
);
const matchingModelIds = providerModels
.filter((m) => modelKeyMatches(m.modelKey, ctx.requestedModel))
.map((m) => m.modelId);
const scopeConditions: SQL[] = [
and(
eq(aiBudgets.providerId, ctx.providerId),
isNull(aiBudgets.modelId)
)!
];
if (matchingModelIds.length > 0) {
scopeConditions.push(inArray(aiBudgets.modelId, matchingModelIds));
}
if (ctx.resourceId != null) {
scopeConditions.push(eq(aiBudgets.resourceId, ctx.resourceId));
}
if (ctx.siteResourceId != null) {
scopeConditions.push(eq(aiBudgets.siteResourceId, ctx.siteResourceId));
}
if (ctx.roleIds.length > 0) {
scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds));
}
return db
.select()
.from(aiBudgets)
.where(
and(
eq(aiBudgets.orgId, ctx.orgId),
eq(aiBudgets.enabled, true),
or(...scopeConditions)
)
);
}
async function sumUsageAmount(
where: SQL,
unit: AiBudget["unit"]
): Promise<number> {
const column =
unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens;
const [row] = await db
.select({ total: sql<number>`coalesce(sum(${column}), 0)` })
.from(aiUsageRecords)
.where(where);
return Number(row?.total ?? 0);
}
/**
* Sums recorded usage for a single budget's scope + rolling window. Model
* budgets can't be pushed down to SQL because the model's key may itself be
* a glob, so those rows are fetched for the provider+window and matched in
* JS the same way access-control matching does.
*/
export async function sumUsageForBudget(
budget: AiBudget,
ctx: BudgetScopeContext,
now: number
): Promise<number> {
const start = windowStart(budget.period, now);
if (budget.modelId != null) {
const [model] = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey
})
.from(aiModels)
.where(eq(aiModels.modelId, budget.modelId))
.limit(1);
if (!model) {
return 0;
}
const rows = await db
.select({
requestedModel: aiUsageRecords.requestedModel,
costUsd: aiUsageRecords.costUsd,
totalTokens: aiUsageRecords.totalTokens
})
.from(aiUsageRecords)
.where(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.providerId, model.providerId),
gte(aiUsageRecords.createdAt, start)
)
);
return rows
.filter((r) => modelKeyMatches(model.modelKey, r.requestedModel))
.reduce(
(sum, r) =>
sum +
(budget.unit === "usd" ? (r.costUsd ?? 0) : r.totalTokens),
0
);
}
if (budget.providerId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.providerId, budget.providerId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.resourceId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.resourceId, budget.resourceId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.siteResourceId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.siteResourceId, budget.siteResourceId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.roleId != null) {
const members = await db
.select({ userId: userOrgRoles.userId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.roleId, budget.roleId),
eq(userOrgRoles.orgId, ctx.orgId)
)
);
const userIds = members.map((m) => m.userId);
if (userIds.length === 0) {
return 0;
}
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
inArray(aiUsageRecords.userId, userIds),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
return 0;
}
/**
* Cached wrapper around sumUsageForBudget. Reuses a per-budget cached sum
* for up to BUDGET_CACHE_REFRESH_MS, and otherwise falls through to the DB
* aggregation and reseeds the cache. Completed requests within that window
* top the cached sum up via applyUsageToBudgetCache below rather than
* forcing a re-aggregation on every request.
*/
async function getBudgetUsage(
budget: AiBudget,
ctx: BudgetScopeContext,
now: number
): Promise<number> {
const cacheKey = budgetUsageCacheKey(budget.budgetId);
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
if (cached && now - cached.computedAt < BUDGET_CACHE_REFRESH_MS) {
return cached.sum;
}
const sum = await sumUsageForBudget(budget, ctx, now);
await cache.set(
cacheKey,
{ sum, computedAt: now } satisfies CachedBudgetUsage,
BUDGET_CACHE_SAFETY_TTL_SEC
);
return sum;
}
/**
* Called once a request's actual usage is known, for every budget that was
* resolved as applicable to it (i.e. checkBudgets' returned `budgets`).
* Adds this request's contribution directly onto each budget's cached sum
* so the next request in the same refresh window doesn't need to re-query
* or re-aggregate. If there's no warm cache entry, or it's already due for
* a refresh, this is a no-op - the next reader re-derives from the DB,
* which by then already includes this request's row via recordUsage.
*/
export async function applyUsageToBudgetCache(
budgets: AiBudget[],
usage: { usd: number; tokens: number }
): Promise<void> {
await Promise.all(
budgets.map(async (budget) => {
const delta = budget.unit === "usd" ? usage.usd : usage.tokens;
if (!delta) {
return;
}
const cacheKey = budgetUsageCacheKey(budget.budgetId);
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
if (
!cached ||
Date.now() - cached.computedAt >= BUDGET_CACHE_REFRESH_MS
) {
return;
}
await cache.set(
cacheKey,
{
sum: cached.sum + delta,
computedAt: cached.computedAt
} satisfies CachedBudgetUsage,
BUDGET_CACHE_SAFETY_TTL_SEC
);
})
);
}
// Throttled to one durable event per budget per breach window, so a soft
// budget being exceeded doesn't write a row on every subsequent request
// while it stays over.
async function recordBreachEventIfNew(
budget: AiBudget,
ctx: BudgetScopeContext,
usageAmount: number,
now: number
): Promise<void> {
try {
const start = windowStart(budget.period, now);
const [existing] = await db
.select({ id: aiBudgetBreachEvents.id })
.from(aiBudgetBreachEvents)
.where(
and(
eq(aiBudgetBreachEvents.budgetId, budget.budgetId),
gte(aiBudgetBreachEvents.createdAt, start)
)
)
.limit(1);
if (existing) {
return;
}
await db.insert(aiBudgetBreachEvents).values({
orgId: ctx.orgId,
budgetId: budget.budgetId,
enforcement: budget.enforcement,
unit: budget.unit,
period: budget.period,
amount: budget.amount,
usageAmount,
blocked: budget.enforcement === "hard",
requestUserId: ctx.requestUserId,
createdAt: now
});
} catch (error) {
logger.error("Failed to record AI budget breach event", {
error,
budgetId: budget.budgetId
});
}
}
export type BudgetCheckResult = {
blocked: boolean;
blockingBudget?: AiBudget;
// Every budget resolved as applicable to this request, regardless of
// whether it was breached - pass to applyUsageToBudgetCache once this
// request's actual usage is known.
budgets: AiBudget[];
};
export async function checkBudgets(
ctx: BudgetScopeContext
): Promise<BudgetCheckResult> {
const budgets = await resolveApplicableBudgets(ctx);
if (budgets.length === 0) {
return { blocked: false, budgets: [] };
}
const now = Date.now();
let blockingBudget: AiBudget | undefined;
for (const budget of budgets) {
const usage = await getBudgetUsage(budget, ctx, now);
if (usage < budget.amount) {
continue;
}
await recordBreachEventIfNew(budget, ctx, usage, now);
if (budget.enforcement === "hard" && !blockingBudget) {
blockingBudget = budget;
}
}
return blockingBudget
? { blocked: true, blockingBudget, budgets }
: { blocked: false, budgets };
}
export type UsageRecordInput = {
orgId: string;
providerId: number;
resourceId: number | null;
siteResourceId: number | null;
userId: string | null;
requestedModel: string;
usage: AiUsage;
costUsd: number | null;
createdAt?: number;
// Same id as the aiSessionLog row logged for this request, so the two
// can be joined to show token/cost usage alongside the session
// transcript. Undefined when the session wasn't logged (e.g. session
// log retention disabled for the org).
sessionId?: string;
};
type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>;
// In-memory buffer for batching AI usage record inserts, mirroring the
// approach in server/routers/badger/logRequestAudit.ts. Usage rows are read
// back on every budget-cache miss (see getBudgetUsage above), which happens
// at least every BUDGET_CACHE_REFRESH_MS, so this buffer is flushed much
// more aggressively than the request audit log to keep the table from
// lagging behind what budget enforcement needs. Unlike the audit log, there
// is no retention/cleanup job for this table - usage history is kept
// indefinitely for billing and historical reporting.
const usageRecordBuffer: AiUsageRecordInsert[] = [];
const USAGE_BATCH_SIZE = 20; // Write to DB every 20 records
const USAGE_BATCH_INTERVAL_MS = 1000; // Or every 1 second, whichever comes first
const USAGE_MAX_BUFFER_SIZE = 5000; // Prevent unbounded memory growth
let usageFlushTimer: NodeJS.Timeout | null = null;
let isUsageFlushInProgress = false;
async function flushUsageRecords() {
if (usageRecordBuffer.length === 0 || isUsageFlushInProgress) {
return;
}
isUsageFlushInProgress = true;
const recordsToWrite = usageRecordBuffer.splice(0, usageRecordBuffer.length);
try {
// Use a transaction to ensure all inserts succeed or fail together
await db.transaction(async (tx) => {
// Batch insert in groups to avoid overwhelming the database
const DB_BATCH_SIZE = 25;
for (let i = 0; i < recordsToWrite.length; i += DB_BATCH_SIZE) {
const batch = recordsToWrite.slice(i, i + DB_BATCH_SIZE);
await tx.insert(aiUsageRecords).values(batch);
}
});
logger.debug(`Flushed ${recordsToWrite.length} AI usage records to database`);
} catch (error) {
logger.error("Error flushing AI usage records:", error);
// On transaction error, put records back at the front of the buffer
// to retry, but only if the buffer isn't too large
if (usageRecordBuffer.length < USAGE_MAX_BUFFER_SIZE - recordsToWrite.length) {
usageRecordBuffer.unshift(...recordsToWrite);
logger.info(`Re-queued ${recordsToWrite.length} AI usage records for retry`);
} else {
logger.error(`Buffer full, dropped ${recordsToWrite.length} AI usage records`);
}
} finally {
isUsageFlushInProgress = false;
// If buffer filled up while we were flushing, flush again
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
flushUsageRecords().catch((err) =>
logger.error("Error in follow-up AI usage flush:", err)
);
}
}
}
function scheduleUsageFlush() {
if (usageFlushTimer === null) {
usageFlushTimer = setTimeout(() => {
usageFlushTimer = null;
flushUsageRecords().catch((err) =>
logger.error("Error in scheduled AI usage flush:", err)
);
}, USAGE_BATCH_INTERVAL_MS);
}
}
/**
* Gracefully flush all pending AI usage records (call this on shutdown).
*/
export async function shutdownUsageRecorder() {
if (usageFlushTimer) {
clearTimeout(usageFlushTimer);
usageFlushTimer = null;
}
// Force flush even if one is in progress by waiting and retrying
while (isUsageFlushInProgress) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await flushUsageRecords();
}
export async function recordUsage(input: UsageRecordInput): Promise<void> {
try {
const { usage } = input;
const totalTokens =
usage.promptTokens +
usage.cacheReadTokens +
usage.cacheWriteTokens +
usage.completionTokens +
usage.reasoningTokens;
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
if (usageRecordBuffer.length >= USAGE_MAX_BUFFER_SIZE) {
const dropped = usageRecordBuffer.splice(0, USAGE_BATCH_SIZE);
logger.warn(
`AI usage record buffer exceeded max size (${USAGE_MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
);
}
usageRecordBuffer.push({
orgId: input.orgId,
providerId: input.providerId,
resourceId: input.resourceId,
siteResourceId: input.siteResourceId,
userId: input.userId,
sessionId: input.sessionId,
requestedModel: input.requestedModel,
promptTokens: usage.promptTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
completionTokens: usage.completionTokens,
reasoningTokens: usage.reasoningTokens,
totalTokens,
costUsd: input.costUsd,
estimated: usage.estimated,
createdAt: input.createdAt ?? Date.now()
});
// Flush immediately if buffer is full, otherwise schedule a flush
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
flushUsageRecords().catch((err) =>
logger.error("Error flushing AI usage records:", err)
);
} else {
scheduleUsageFlush();
}
} catch (error) {
logger.error("Failed to record AI usage", { error });
}
}
-283
View File
@@ -1,283 +0,0 @@
import type { Request } from "express";
export const AI_CAPABILITIES = [
"openai_chat",
"openai_responses",
"anthropic_messages",
"gemini_generate_content",
"bedrock_model_invoke",
"google_generate_content",
"google_raw_predict",
"bedrock_converse"
] as const;
export type AiCapability = (typeof AI_CAPABILITIES)[number];
export type AiCapabilityRoute = {
method: "POST";
path: string;
};
export type AiCapabilityDefinition = {
id: AiCapability;
routes: AiCapabilityRoute[];
extractModel: (req: Request) => string | undefined;
resolveUpstreamUrl: (
baseUrl: string,
req: Request,
model: string
) => string;
isStreaming: (req: Request, contentType: string) => boolean;
};
function bodyModel(req: Request): string | undefined {
return typeof req.body?.model === "string" ? req.body.model : undefined;
}
function paramModel(req: Request): string | undefined {
const model = req.params?.model;
return typeof model === "string" && model.length > 0 ? model : undefined;
}
export function joinUpstreamUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, "");
let suffix = path.startsWith("/") ? path : `/${path}`;
let basePathname = "/";
try {
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
} catch {
// Fall through with "/" non-absolute bases are not expected in
// production, but keep joining usable for malformed input.
}
if (basePathname !== "/") {
const baseSegs = basePathname.split("/").filter(Boolean);
const pathSegs = suffix.split("/").filter(Boolean);
const max = Math.min(baseSegs.length, pathSegs.length);
let overlap = 0;
for (let n = max; n >= 1; n--) {
const baseSuffix = baseSegs.slice(-n);
const pathPrefix = pathSegs.slice(0, n);
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
overlap = n;
break;
}
}
if (overlap > 0) {
const remaining = pathSegs.slice(overlap);
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
}
}
if (suffix === "/") {
return base;
}
return `${base}${suffix}`;
}
function pathFromRequest(req: Request): string {
const raw = req.originalUrl || req.url || req.path;
return raw.startsWith("/") ? raw : `/${raw}`;
}
function bodyRequestsStream(req: Request): boolean {
return req.body?.stream === true;
}
function contentTypeIsSse(contentType: string): boolean {
return contentType.includes("text/event-stream");
}
function contentTypeIsAmazonEventStream(contentType: string): boolean {
return contentType.includes("application/vnd.amazon.eventstream");
}
function pathIncludes(req: Request, fragment: string): boolean {
return pathFromRequest(req).includes(fragment);
}
function isBodyOrSseStreaming(req: Request, contentType: string): boolean {
return bodyRequestsStream(req) || contentTypeIsSse(contentType);
}
function isGeminiStyleStreaming(req: Request, contentType: string): boolean {
return (
pathIncludes(req, "streamGenerateContent") ||
pathIncludes(req, "alt=sse") ||
contentTypeIsSse(contentType)
);
}
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
{
openai_chat: {
id: "openai_chat",
routes: [
{ method: "POST", path: "/v1/chat/completions" },
{ method: "POST", path: "/chat/completions" }
],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
openai_responses: {
id: "openai_responses",
routes: [{ method: "POST", path: "/v1/responses" }],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
anthropic_messages: {
id: "anthropic_messages",
routes: [{ method: "POST", path: "/v1/messages" }],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
gemini_generate_content: {
id: "gemini_generate_content",
routes: [
{
method: "POST",
path: "/v1beta/models/:model\\:generateContent"
},
{
method: "POST",
path: "/v1beta/models/:model\\:streamGenerateContent"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isGeminiStyleStreaming
},
google_generate_content: {
id: "google_generate_content",
routes: [
{
method: "POST",
// Vertex publisher model generateContent
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
},
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isGeminiStyleStreaming
},
google_raw_predict: {
id: "google_raw_predict",
routes: [
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
},
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "streamRawPredict") ||
pathIncludes(req, "alt=sse") ||
contentTypeIsSse(contentType)
},
bedrock_model_invoke: {
id: "bedrock_model_invoke",
routes: [
{ method: "POST", path: "/model/:model/invoke" },
{
method: "POST",
path: "/model/:model/invoke-with-response-stream"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "invoke-with-response-stream") ||
contentTypeIsAmazonEventStream(contentType) ||
contentTypeIsSse(contentType)
},
bedrock_converse: {
id: "bedrock_converse",
routes: [
{ method: "POST", path: "/model/:model/converse" },
{ method: "POST", path: "/model/:model/converse-stream" }
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "converse-stream") ||
contentTypeIsAmazonEventStream(contentType) ||
contentTypeIsSse(contentType)
}
};
export function isAiCapability(value: unknown): value is AiCapability {
return (
typeof value === "string" &&
(AI_CAPABILITIES as readonly string[]).includes(value)
);
}
export function parseCapabilities(raw: unknown): AiCapability[] {
if (raw == null) {
return [];
}
let parsed: unknown = raw;
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) {
return [];
}
try {
parsed = JSON.parse(trimmed);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) {
return [];
}
const out: AiCapability[] = [];
const seen = new Set<AiCapability>();
for (const item of parsed) {
if (isAiCapability(item) && !seen.has(item)) {
seen.add(item);
out.push(item);
}
}
return out;
}
export function serializeCapabilities(capabilities: AiCapability[]): string {
return JSON.stringify(capabilities);
}
export function providerHasCapability(
capabilities: AiCapability[] | string | null | undefined,
capability: AiCapability
): boolean {
const list =
typeof capabilities === "string" || capabilities == null
? parseCapabilities(capabilities)
: capabilities;
return list.includes(capability);
}
-82
View File
@@ -1,82 +0,0 @@
import http from "node:http";
import https from "node:https";
import { Readable } from "node:stream";
type UpstreamFetchInit = {
method: string;
headers: Record<string, string>;
body?: string;
skipTlsVerification?: boolean;
signal?: AbortSignal;
};
const insecureHttpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true
});
export function aiGatewayUpstreamFetch(
url: string,
init: UpstreamFetchInit
): Promise<Response> {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const agent =
isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined;
return new Promise((resolve, reject) => {
if (init.signal?.aborted) {
reject(init.signal.reason ?? new Error("Request aborted"));
return;
}
const req = lib.request(
url,
{
method: init.method,
headers: init.headers,
agent
},
(res) => {
const headers = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
headers.append(key, entry);
}
} else {
headers.set(key, value);
}
}
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>;
resolve(
new Response(body, {
status: res.statusCode ?? 502,
statusText: res.statusMessage,
headers
})
);
}
);
req.on("error", reject);
if (init.signal) {
const onAbort = () => req.destroy(init.signal!.reason);
init.signal.addEventListener("abort", onAbort, { once: true });
req.on("close", () =>
init.signal!.removeEventListener("abort", onAbort)
);
}
if (init.body !== undefined) {
req.write(init.body);
}
req.end();
});
}

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