Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] cd2b301729 Bump sigstore/cosign-installer from 4.1.1 to 4.1.2
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 01:38:47 +00:00
790 changed files with 27818 additions and 83430 deletions
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
-5
View File
@@ -1,5 +0,0 @@
---
alwaysApply: true
---
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
-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
Dockerfile*
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
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version:
- OS Type & Version: (e.g., Ubuntu 22.04)
- Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Client Version:
- Olm Version: (if applicable)
validations:
required: true
+29 -19
View File
@@ -1,42 +1,52 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
npm-dependencies:
patterns:
- "*"
dev-patch-updates:
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"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
docker-dependencies:
patterns:
- "*"
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"
- package-ecosystem: "gomod"
directory: "/install"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
go-install-dependencies:
patterns:
- "*"
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
+8 -10
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Monitor storage space
run: |
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Monitor storage space
run: |
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Extract tag name
id: get-tag
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -416,8 +416,6 @@ jobs:
- name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
- name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '24'
+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:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-stale: 14
days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '24'
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build Docker image pg
run: make dev-build-pg
+2 -4
View File
@@ -17,9 +17,9 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
*.db
*.sqlite*
*.sqlite
!Dockerfile.sqlite
*.sqlite3*
*.sqlite3
*.log
.machinelogs*.json
*-audit.json
@@ -54,5 +54,3 @@ hydrateSaas.ts
CLAUDE.md
drizzle.config.ts
server/setup/migrations.ts
solo.yml
allowedDevOrigins.json
+1 -4
View File
@@ -18,8 +18,5 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true,
"cSpell.words": [
"nessicary"
]
"editor.formatOnSave": true
}
+4 -40
View File
@@ -41,7 +41,7 @@
</strong>
</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
@@ -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.
* 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%" />
### 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 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
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.
<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.
* 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%" />
### 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.
* 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
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.
<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 the Pangolin client for your platform:
@@ -143,7 +107,7 @@ the docs to illustrate some basic ideas.
## 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
-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);
}
}
};
-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);
}
}
};
-4
View File
@@ -10,8 +10,6 @@ import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa";
import { setServerAdmin } from "./commands/setServerAdmin";
yargs(hideBin(process.argv))
.scriptName("pangctl")
@@ -23,7 +21,5 @@ yargs(hideBin(process.argv))
.command(deleteClient)
.command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.command(setServerAdmin)
.demandCommand()
.help().argv;
+22 -15
View File
@@ -1,47 +1,54 @@
api:
insecure: true
dashboard: true
providers:
http:
endpoint: http://pangolin:3001/api/v1/traefik-config
pollInterval: 5s
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: /etc/traefik/dynamic_config.yml
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: github.com/fosrl/badger
version: v1.4.1
moduleName: "github.com/fosrl/badger"
version: "{{.BadgerVersion}}"
log:
level: INFO
format: common
level: "INFO"
format: "common"
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: '{{.LetsEncryptEmail}}'
storage: /letsencrypt/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
email: "{{.LetsEncryptEmail}}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints:
web:
address: ':80'
address: ":80"
websecure:
address: ':443'
address: ":443"
transport:
respondingTimeouts:
readTimeout: 30m
readTimeout: "30m"
http:
tls:
certResolver: letsencrypt
certResolver: "letsencrypt"
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: web
entryPoint: "web"
View File
+1 -1
View File
@@ -1,4 +1,4 @@
import { APP_PATH } from "./server/lib/consts";
import { APP_PATH } from "@server/lib/consts";
import { defineConfig } from "drizzle-kit";
import path from "path";
+1 -5
View File
@@ -22,8 +22,7 @@ server:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableEmail}}
email:
smtp_host: "{{.EmailSMTPHost}}"
@@ -37,6 +36,3 @@ flags:
disable_signup_without_invite: true
disable_user_create_org: false
allow_raw_resources: true
{{if .IsPostgreSQL}}postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
+11 -60
View File
@@ -1,23 +1,15 @@
name: pangolin
services:
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
restart: unless-stopped
deploy:
resources:
limits:
memory: 2g
memory: 1g
reservations:
memory: 512m
{{if or .IsPostgreSQL .IsRedis}}depends_on:
{{if .IsPostgreSQL}}postgres:
condition: service_healthy{{end}}
{{if .IsRedis}}redis:
condition: service_healthy{{end}}
networks:
- default
- backend{{end}}
memory: 256m
volumes:
- ./config:/app/config
healthcheck:
@@ -25,8 +17,8 @@ services:
interval: "10s"
timeout: "10s"
retries: 15
{{if .InstallGerbil}}gerbil:
{{if .InstallGerbil}}
gerbil:
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil
restart: unless-stopped
@@ -47,16 +39,17 @@ services:
- 21820:21820/udp
- 443:443
- 443:443/udp # For http3 QUIC if desired
- 80:80{{end}}
- 80:80
{{end}}
traefik:
image: docker.io/traefik:v3.6
container_name: traefik
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:
- 443:443
- 80:80{{end}}
- 80:80
{{end}}
depends_on:
pangolin:
condition: service_healthy
@@ -67,50 +60,8 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./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:
default:
driver: bridge
name: pangolin_frontend
name: pangolin
{{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}}
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.44.0
golang.org/x/term v0.42.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
+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/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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
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/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+20 -57
View File
@@ -4,7 +4,6 @@ import (
"crypto/rand"
"embed"
"encoding/base64"
"flag"
"fmt"
"io"
"io/fs"
@@ -54,13 +53,9 @@ type Config struct {
InstallGerbil bool
TraefikBouncerKey string
DoCrowdsecInstall bool
EnableMaxMind bool
EnableGeoblocking bool
Secret string
IsEnterprise bool
IsPostgreSQL bool
IsPostgreSQLPass string
IsRedis bool
IsRedisPass string
}
type SupportedContainer string
@@ -71,14 +66,8 @@ const (
Undefined SupportedContainer = "undefined"
)
var redisFlag *bool
func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!")
@@ -130,11 +119,11 @@ func main() {
fmt.Println("\nConfiguration files created successfully!")
// Download MaxMind Country / ASN database if requested
if config.EnableMaxMind {
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
// Download MaxMind database if requested
if config.EnableGeoblocking {
fmt.Println("\n=== Downloading MaxMind Database ===")
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.")
}
}
@@ -195,15 +184,15 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
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 {
fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.")
}
}
} else {
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
fmt.Println("MaxMind GeoLite2 Country database not found.")
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err)
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
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_asn_path: "./config/GeoLite2-ASN.mmdb" under server
fmt.Println("Add the following lines under the 'server' section:")
fmt.Println("Add the following line under the 'server' section:")
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 ===")
// check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) {
@@ -493,17 +480,6 @@ func collectUserInput() Config {
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.")
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)", "")
@@ -547,7 +523,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===")
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 == "" {
fmt.Println("Error: Dashboard Domain name is required")
@@ -800,42 +776,29 @@ func checkPortsAvailable(port int) 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",
"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)
}
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)
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
}
// Extract the Country database
// Extract the database
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 Country 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)
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
}
// 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 {
return fmt.Errorf("failed to move GeoLite2 Country 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)
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
}
// Clean up the downloaded files
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary country 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)
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
}
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
return nil
}
+44 -567
View File
File diff suppressed because it is too large Load Diff
+43 -566
View File
File diff suppressed because it is too large Load Diff
-3805
View File
File diff suppressed because it is too large Load Diff
+80 -603
View File
File diff suppressed because it is too large Load Diff
+45 -570
View File
File diff suppressed because it is too large Load Diff
+43 -566
View File
File diff suppressed because it is too large Load Diff
+37 -560
View File
File diff suppressed because it is too large Load Diff
+42 -565
View File
File diff suppressed because it is too large Load Diff
+42 -565
View File
File diff suppressed because it is too large Load Diff
+43 -566
View File
File diff suppressed because it is too large Load Diff
+42 -565
View File
File diff suppressed because it is too large Load Diff
+41 -564
View File
File diff suppressed because it is too large Load Diff
+43 -566
View File
File diff suppressed because it is too large Load Diff
+38 -561
View File
File diff suppressed because it is too large Load Diff
+43 -566
View File
File diff suppressed because it is too large Load Diff
+94 -617
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -152,8 +152,8 @@
"shareErrorSelectResource": "請選擇一個資源",
"proxyResourceTitle": "管理公開資源",
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
"publicResourcesBannerTitle": "基於網頁的公開存取",
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"proxyResourcesBannerTitle": "基於網頁的公開存取",
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"clientResourceTitle": "管理私有資源",
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
"privateResourcesBannerTitle": "零信任私有存取",
@@ -489,7 +489,7 @@
"createdAt": "創建於",
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。",
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
"proxyEnableSSL": "啟用 TLS",
"proxyEnableSSL": "啟用 SSL",
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
"target": "目標",
"configureTarget": "配置目標",
@@ -1099,7 +1099,6 @@
"actionGenerateAccessToken": "生成訪問令牌",
"actionDeleteAccessToken": "刪除訪問令牌",
"actionListAccessTokens": "訪問令牌",
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
"actionCreateResourceRule": "創建資源規則",
"actionDeleteResourceRule": "刪除資源規則",
"actionListResourceRules": "列出資源規則",
@@ -1764,7 +1763,7 @@
"description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能",
"introTitle": "託管式自架 Pangolin",
"introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"benefitSimplerOperations": {
"title": "簡單的操作",
"description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"
+7 -32
View File
@@ -1,42 +1,17 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import fs from "fs";
import path from "path";
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 = {
reactStrictMode: false,
reactCompiler: true,
transpilePackages: ["@novnc/novnc"],
output: "standalone",
allowedDevOrigins,
async redirects() {
return [
{
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
}
];
}
eslint: {
ignoreDuringBuilds: true
},
experimental: {
reactCompiler: true
},
output: "standalone"
};
export default withNextIntl(nextConfig);
+3621 -2283
View File
File diff suppressed because it is too large Load Diff
+58 -64
View File
@@ -32,15 +32,13 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
"@aws-sdk/client-s3": "3.1056.0",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -61,20 +59,16 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/body": "0.3.0",
"@react-email/components": "1.0.12",
"@react-email/render": "2.0.8",
"@react-email/tailwind": "2.0.7",
"@react-email/components": "1.0.8",
"@react-email/render": "2.0.4",
"@react-email/tailwind": "2.0.5",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"@simplewebauthn/server": "13.3.0",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.16.1",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -86,76 +80,77 @@
"d3": "7.9.0",
"drizzle-orm": "0.45.2",
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"express-rate-limit": "8.3.0",
"glob": "13.0.6",
"helmet": "8.2.0",
"helmet": "8.1.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"ioredis": "5.10.0",
"jmespath": "0.16.0",
"js-yaml": "4.2.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"lucide-react": "0.577.0",
"maxmind": "5.0.5",
"moment": "2.30.1",
"next": "16.2.6",
"next-intl": "4.13.0",
"next": "15.5.15",
"next-intl": "4.8.3",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "9.0.1",
"nodemailer": "8.0.5",
"oslo": "1.2.1",
"pg": "8.21.0",
"posthog-node": "5.35.6",
"pg": "8.20.0",
"posthog-node": "5.28.0",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react-dom": "19.2.4",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.76.1",
"react-hook-form": "7.71.2",
"react-icons": "5.6.0",
"recharts": "3.8.1",
"recharts": "2.15.4",
"reodotdev": "1.1.0",
"semver": "7.8.1",
"resend": "6.9.2",
"semver": "7.7.4",
"sshpk": "1.18.0",
"stripe": "22.2.0",
"stripe": "20.4.1",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0",
"tailwind-merge": "3.5.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.21.0",
"yaml": "2.9.0",
"ws": "8.19.0",
"yaml": "2.8.3",
"yargs": "18.0.0",
"zod": "4.4.3",
"zod": "4.3.6",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.69.1",
"@dotenvx/dotenvx": "1.54.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.2",
"@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3",
"@types/express": "5.0.6",
"@types/express-session": "1.19.0",
"@types/express-session": "1.18.2",
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.9.1",
"@types/nodemailer": "8.0.0",
"@types/node": "25.3.5",
"@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3",
"@types/pg": "8.20.0",
"@types/react": "19.2.15",
"@types/pg": "8.18.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/sshpk": "1.17.4",
@@ -165,22 +160,21 @@
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.28.1",
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.15",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
"tsc-alias": "1.8.17",
"tsx": "4.22.3",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0"
"esbuild": "0.27.4",
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"postcss": "8.5.8",
"prettier": "3.8.1",
"react-email": "5.2.10",
"tailwindcss": "4.2.2",
"tsc-alias": "1.8.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1"
},
"overrides": {
"esbuild": "0.28.1",
"dompurify": "3.4.0",
"postcss": "8.5.15"
"esbuild": "0.27.4",
"dompurify": "3.3.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

+15 -48
View File
@@ -5,7 +5,6 @@ import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
@@ -21,7 +20,6 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret",
createResource = "createResource",
@@ -71,7 +69,6 @@ export enum ActionsEnum {
setResourceWhitelist = "setResourceWhitelist",
getResourceWhitelist = "getResourceWhitelist",
generateAccessToken = "generateAccessToken",
createResourceSessionToken = "createResourceSessionToken",
deleteAcessToken = "deleteAcessToken",
listAccessTokens = "listAccessTokens",
createResourceRule = "createResourceRule",
@@ -151,37 +148,11 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets",
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"
listHealthChecks = "listHealthChecks"
}
export async function checkUserActionPermission(
@@ -214,23 +185,6 @@ export async function checkUserActionPermission(
}
}
// If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
if (roleActionPermission.length > 0) {
return true;
}
// Check if the user has direct permission for the action in the current org
const userActionPermission = await db
.select()
@@ -248,7 +202,20 @@ export async function checkUserActionPermission(
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) {
console.error("Error checking user action permission:", error);
throw createHttpError(
+25 -97
View File
@@ -1,12 +1,6 @@
import { db } from "@server/db";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import {
rolePolicies,
roleResources,
resources,
userPolicies,
userResources
} from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { roleResources, userResources } from "@server/db";
export async function canUserAccessResource({
userId,
@@ -17,14 +11,9 @@ export async function canUserAccessResource({
resourceId: number;
roleIds: number[];
}): Promise<boolean> {
const [
roleResourceAccess,
rolePolicyAccess,
userResourceAccess,
userPolicyAccess
] = await Promise.all([
const roleResourceAccess =
roleIds.length > 0
? db
? await db
.select()
.from(roleResources)
.where(
@@ -34,87 +23,26 @@ export async function canUserAccessResource({
)
)
.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 (
roleResourceAccess.length > 0 ||
rolePolicyAccess.length > 0 ||
userResourceAccess.length > 0 ||
userPolicyAccess.length > 0
);
if (roleResourceAccess.length > 0) {
return true;
}
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
} 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 type { RandomReader } 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(
token: string,
isSecure: boolean,
+1 -10
View File
@@ -19,9 +19,6 @@ export async function createResourceSession(opts: {
userSessionId?: string | null;
whitelistId?: number | null;
accessTokenId?: string | null;
policyPasswordId?: number | null;
policyPincodeId?: number | null;
policyWhitelistId?: number | null;
doNotExtend?: boolean;
expiresAt?: number | null;
sessionLength?: number | null;
@@ -31,10 +28,7 @@ export async function createResourceSession(opts: {
!opts.pincodeId &&
!opts.whitelistId &&
!opts.accessTokenId &&
!opts.userSessionId &&
!opts.policyPasswordId &&
!opts.policyPincodeId &&
!opts.policyWhitelistId
!opts.userSessionId
) {
throw new Error("Auth method must be provided");
}
@@ -55,9 +49,6 @@ export async function createResourceSession(opts: {
whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null,
policyPasswordId: opts.policyPasswordId || null,
policyPincodeId: opts.policyPincodeId || null,
policyWhitelistId: opts.policyWhitelistId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime()
+4 -7
View File
@@ -795,13 +795,10 @@ export const COUNTRIES = [
name: "Serbia",
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
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
// {
// name: "Serbia and Montenegro",
// code: "CS"
// },
{
name: "Serbia and Montenegro",
code: "CS"
},
{
name: "Seychelles",
code: "SC"
+1 -36
View File
@@ -1,12 +1,6 @@
import { join } from "path";
import { readFileSync } from "fs";
import {
clients,
db,
resourcePolicies,
resources,
siteResources
} from "@server/db";
import { clients, db, resources, siteResources } from "@server/db";
import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db";
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(
orgId: string
): Promise<string> {
+1 -1
View File
@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb();
export default db;
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0]
>[0];
-1
View File
@@ -4,4 +4,3 @@ export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
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 { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build";
import { db as mainDb } from "./driver";
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
import { createPool } from "./poolConfig";
function createLogsDb() {
@@ -63,7 +63,8 @@ function createLogsDb() {
})
);
} else {
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
const maxReplicaConnections =
poolConfig?.max_replica_connections || 20;
for (const conn of replicaConnections) {
const replicaPool = createPool(
conn.connection_string,
@@ -90,4 +91,4 @@ function createLogsDb() {
export const logsDb = createLogsDb();
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 logger from "@server/logger";
export function createPoolConfig(
connectionString: string,
@@ -27,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
pool.on("error", (err) => {
// This catches errors on idle clients in the pool. Without this
// handler an unexpected disconnect would crash the process.
console.error(
logger.error(
`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
// query can't block the pool forever
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
console.warn(
logger.warn(
`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);
return pool;
}
}
+15 -64
View File
@@ -2,7 +2,6 @@ import {
pgTable,
serial,
varchar,
unique,
boolean,
integer,
bigint,
@@ -12,7 +11,7 @@ import {
primaryKey,
uniqueIndex
} from "drizzle-orm/pg-core";
import { InferSelectModel, sql } from "drizzle-orm";
import { InferSelectModel } from "drizzle-orm";
import {
domains,
orgs,
@@ -20,13 +19,12 @@ import {
roles,
users,
exitNodes,
sessions,
clients,
resources,
siteResources,
targetHealthCheck,
sites,
clients,
sessions,
labels
sites
} from "./schema";
export const certificates = pgTable("certificates", {
@@ -199,42 +197,6 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
})
});
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
.notNull()
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
}),
destination: varchar("destination").notNull() // a cidr range
});
export const remoteExitNodePreferenceLabels = pgTable(
// this controls what sites are enforced to connect to this node
"remoteExitNodePreferenceLabels",
{
remoteExitNodePreferenceLabelId: serial(
"remoteExitNodePreferenceLabelId"
).primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [
unique("remote_exit_node_preference_label_uniq").on(
t.remoteExitNodeId,
t.labelId
)
]
);
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
sessionId: varchar("id").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
@@ -245,28 +207,17 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const loginPage = pgTable(
"loginPage",
{
loginPageId: serial("loginPageId").primaryKey(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
exitNodeId: integer("exitNodeId").references(
() => exitNodes.exitNodeId,
{
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 loginPage = pgTable("loginPage", {
loginPageId: serial("loginPageId").primaryKey(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
});
export const loginPageOrg = pgTable("loginPageOrg", {
loginPageId: integer("loginPageId")
File diff suppressed because it is too large Load Diff
+38 -278
View File
@@ -17,37 +17,22 @@ import {
resourceHeaderAuth,
ResourceHeaderAuth,
resourceRules,
resourcePolicyRules,
resources,
roleResources,
rolePolicies,
sessions,
userResources,
userPolicies,
users,
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility,
resourcePolicies,
resourcePolicyPincode,
ResourcePolicyPincode,
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
resourceHeaderAuthExtendedCompatibility
} from "@server/db";
import { alias } from "@server/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import logger from "@server/logger";
import { and, eq, inArray, or, sql } from "drizzle-orm";
export type ResourceWithAuth = {
resource: Resource | null;
pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org;
};
@@ -72,33 +57,6 @@ export async function getResourceByDomain(
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()
.from(resources)
@@ -121,59 +79,6 @@ export async function getResourceByDomain(
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))
.where(
or(
@@ -203,51 +108,13 @@ export async function getResourceByDomain(
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 {
resource: {
...result.resources,
applyRules: effectiveApplyRules,
sso: effectiveSSO,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct
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,
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility:
result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs
};
}
@@ -287,165 +154,58 @@ export async function getRoleName(roleId: number): Promise<string | null> {
}
/**
* Check if role has access to resource (direct or via resource policy)
* Check if role has access to resource
*/
export async function getRoleResourceAccess(
resourceId: number,
roleIds: number[]
) {
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
),
db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
// 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
)
)
)
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
.where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
]);
);
const combined = [...direct, ...viaPolicies];
return combined.length > 0 ? combined : null;
return roleResourceAccess.length > 0 ? roleResourceAccess : 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(
userId: string,
resourceId: number
) {
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
const userResourceAccess = await 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)
]);
)
.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(
resourceId: number
): Promise<ResourceRule[]> {
const [directRules, policyRules] = await Promise.all([
db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId)),
db
.select({
ruleId: resourcePolicyRules.ruleId,
resourceId: sql<number>`${resourceId}`,
enabled: resourcePolicyRules.enabled,
priority: resourcePolicyRules.priority,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value
})
.from(resourcePolicyRules)
.innerJoin(
resources,
// 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 rules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
const maxDirectPriority = directRules.reduce(
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
return rules;
}
/**
+50 -12
View File
@@ -1,5 +1,6 @@
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import * as schema from "./schema/schema";
import path from "path";
import fs from "fs";
@@ -11,31 +12,68 @@ export const exists = checkFileExists(location);
bootstrapVolume();
/**
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
* execution, freeing native sqlite3_stmt memory deterministically instead
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
* WARNING: Finalizes after first execution incompatible with drizzle's
* reusable .prepare() builders. No such usage exists in this codebase.
*/
function autoFinalizeStatement(
stmt: BetterSqlite3.Statement
): BetterSqlite3.Statement {
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() {
const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL");
}
// No busy_timeout pragma: better-sqlite3 already arms
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
// (lib/database.js), so an explicit pragma is redundant.
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
// retry loops that accumulate memory.
sqlite.pragma("busy_timeout = 5000");
// 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.
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
// TraefikConfigManager JOINs that block the event loop.
sqlite.pragma("cache_size = -65536");
// 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.
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
// reducing event-loop blocking.
sqlite.pragma("mmap_size = 268435456");
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
// first use, preventing sqlite3_stmt accumulation between GC cycles.
const originalPrepare = sqlite.prepare.bind(sqlite);
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
return autoFinalizeStatement(originalPrepare(source));
};
return DrizzleSqlite(sqlite, {
schema
-1
View File
@@ -4,4 +4,3 @@ export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
export { alias } from "drizzle-orm/sqlite-core";
+3 -39
View File
@@ -12,7 +12,6 @@ import {
clients,
domains,
exitNodes,
labels,
orgs,
resources,
roles,
@@ -22,6 +21,9 @@ import {
targetHealthCheck,
users
} from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -193,44 +195,6 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
})
});
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
autoIncrement: true
}),
remoteExitNodeId: text("remoteExitNodeId")
.notNull()
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
}),
destination: text("destination").notNull() // a cidr range
});
export const remoteExitNodePreferenceLabels = sqliteTable(
// this controls what sites are enforced to connect to this node
"remoteExitNodePreferenceLabels",
{
remoteExitNodePreferenceLabelId: integer(
"remoteExitNodePreferenceLabelId"
).primaryKey({ autoIncrement: true }),
remoteExitNodeId: text("remoteExitNodeId")
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [
uniqueIndex("remote_exit_node_preference_label_uniq").on(
t.remoteExitNodeId,
t.labelId
)
]
);
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
sessionId: text("id").primaryKey(),
remoteExitNodeId: text("remoteExitNodeId")
+34 -355
View File
@@ -20,10 +20,8 @@ export const domains = sqliteTable("domains", {
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"),
customCertResolver: text("customCertResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
errorMessage: text("errorMessage")
});
export const dnsRecords = sqliteTable("dnsRecords", {
@@ -64,13 +62,7 @@ export const orgs = sqliteTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: integer(
"settingsEnableGlobalNewtAutoUpdate",
{ mode: "boolean" }
)
.notNull()
.default(false)
billingOrgId: text("billingOrgId")
});
export const userDomains = sqliteTable("userDomains", {
@@ -124,29 +116,11 @@ export const sites = sqliteTable("sites", {
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
})
.notNull()
.default(false),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: text("resourceGuid", { length: 36 })
.unique()
.notNull()
@@ -167,12 +141,16 @@ export const resources = sqliteTable("resources", {
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"),
sso: integer("sso", { mode: "boolean" }),
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
@@ -188,6 +166,7 @@ export const resources = sqliteTable("resources", {
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
})
@@ -201,123 +180,9 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
});
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name").notNull(),
config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
@@ -339,12 +204,7 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100),
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
priority: integer("priority").notNull().default(100)
});
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
@@ -359,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -423,11 +281,11 @@ export const siteResources = sqliteTable("siteResources", {
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination"), // ip, cidr, hostname
destination: text("destination").notNull(), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"),
aliasAddress: text("aliasAddress"),
@@ -437,11 +295,8 @@ export const siteResources = sqliteTable("siteResources", {
.notNull()
.default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.$type<"site" | "remote">()
.default("site"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
@@ -1054,47 +909,6 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull()
});
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
pincodeHash: text("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
passwordHash: text("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = sqliteTable(
"resourcePolicyHeaderAuth",
{
headerAuthId: integer("headerAuthId").primaryKey({
autoIncrement: true
}),
headerAuthHash: text("headerAuthHash").notNull(),
extendedCompatibility: integer("extendedCompatibility", {
mode: "boolean"
})
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
}
);
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
"resourceHeaderAuthExtendedCompatibility",
{
@@ -1123,7 +937,6 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
path: text("path"),
tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"),
@@ -1170,24 +983,6 @@ export const resourceSessions = sqliteTable("resourceSessions", {
onDelete: "cascade"
}
),
policyPasswordId: integer("policyPasswordId").references(
() => resourcePolicyPassword.passwordId,
{
onDelete: "cascade"
}
),
policyPincodeId: integer("policyPincodeId").references(
() => resourcePolicyPincode.pincodeId,
{
onDelete: "cascade"
}
),
policyWhitelistId: integer("policyWhitelistId").references(
() => resourcePolicyWhiteList.whitelistId,
{
onDelete: "cascade"
}
),
issuedAt: integer("issuedAt")
});
@@ -1224,101 +1019,10 @@ export const resourceRules = sqliteTable("resourceRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP, PASS
match: text("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), // CIDR, PATH, IP
match: text("match").notNull(), // CIDR, PATH, IP
value: text("value").notNull()
});
export const rolePolicies = sqliteTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = sqliteTable("userPolicies", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(),
value: text("value").notNull()
});
export const resourcePolicies = sqliteTable("resourcePolicies", {
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false),
niceId: text("niceId").notNull(),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
name: text("name").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(),
@@ -1492,30 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1585,17 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
export type ResourcePolicyPassword = InferSelectModel<
typeof resourcePolicyPassword
>;
export type ResourcePolicyHeaderAuth = InferSelectModel<
typeof resourcePolicyHeaderAuth
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
+1 -3
View File
@@ -1,5 +1,5 @@
#! /usr/bin/env node
import "./extendZod";
import "./extendZod.ts";
import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
@@ -24,7 +24,6 @@ import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
async function startServers() {
await setHostMeta();
@@ -42,7 +41,6 @@ async function startServers() {
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
// Start all servers
const apiServer = createApiServer();
+3 -11
View File
@@ -152,19 +152,11 @@ function getOpenApiDocumentation() {
if (!hasExistingResponses) {
def.route.responses = {
"200": {
description: "Successful response",
"*": {
description: "",
content: {
"application/json": {
schema: z.object({
data: z
.record(z.string(), z.any())
.nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
schema: z.object({})
}
}
}
+3 -11
View File
@@ -221,18 +221,10 @@ async function handleResource(
)
.where(eq(targets.resourceId, resource.resourceId));
const monitoredTargets = otherTargets.filter(
(t) => t.hcHealth !== "unknown"
);
let health = "healthy";
const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every(
(t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
if (allUnknown) {
logger.debug(
+26 -36
View File
@@ -1,39 +1,28 @@
export enum LimitId {
export enum FeatureId {
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes",
ORGANIZATIONS = "organizations",
PUBLIC_RESOURCES = "publicResources",
PRIVATE_RESOURCES = "privateResources",
MACHINE_CLIENTS = "machineClients",
ORGINIZATIONS = "organizations",
TIER1 = "tier1"
}
export async function getFeatureDisplayName(
featureId: LimitId
): Promise<string> {
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
switch (featureId) {
case LimitId.USERS:
case FeatureId.USERS:
return "Users";
case LimitId.SITES:
case FeatureId.SITES:
return "Sites";
case LimitId.EGRESS_DATA_MB:
case FeatureId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case LimitId.DOMAINS:
case FeatureId.DOMAINS:
return "Domains";
case LimitId.REMOTE_EXIT_NODES:
case FeatureId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case LimitId.ORGANIZATIONS:
case FeatureId.ORGINIZATIONS:
return "Organizations";
case LimitId.PUBLIC_RESOURCES:
return "Public Resources";
case LimitId.PRIVATE_RESOURCES:
return "Private Resources";
case LimitId.MACHINE_CLIENTS:
return "Machine Clients";
case LimitId.TIER1:
case FeatureId.TIER1:
return "Home Lab";
default:
return featureId;
@@ -41,16 +30,15 @@ export async function getFeatureDisplayName(
}
// this is from the old system
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
// right now we are not charging for any data
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
};
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: LimitId): string | undefined {
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -61,20 +49,22 @@ export function getFeatureMeterId(featureId: LimitId): string | undefined {
}
}
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
export function getFeatureIdByMetricId(
metricId: string
): FeatureId | undefined {
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
([_, v]) => v === metricId
)?.[0];
}
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export const tier1FeaturePriceSet: FeaturePriceSet = {
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
};
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
};
export function getTier1FeaturePriceSet(): FeaturePriceSet {
@@ -89,11 +79,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
}
export const tier2FeaturePriceSet: FeaturePriceSet = {
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
};
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
};
export function getTier2FeaturePriceSet(): FeaturePriceSet {
@@ -108,11 +98,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
}
export const tier3FeaturePriceSet: FeaturePriceSet = {
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
};
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
export function getTier3FeaturePriceSet(): FeaturePriceSet {
@@ -126,7 +116,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
}
}
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
// Check all feature price sets
const allPriceSets = [
getTier1FeaturePriceSet(),
@@ -135,7 +125,7 @@ export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
];
for (const priceSet of allPriceSets) {
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
([_, price]) => price === priceId
);
if (entry) {
+5 -5
View File
@@ -1,19 +1,19 @@
import Stripe from "stripe";
import { LimitId, FeaturePriceSet } from "./features";
import { FeatureId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService";
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string
orgId: string,
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, LimitId.USERS);
const users = await usageService.getUsage(orgId, FeatureId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === LimitId.USERS) {
if (featureId === FeatureId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === LimitId.TIER1) {
} else if (featureId === FeatureId.TIER1) {
quantity = 1;
}
+23 -35
View File
@@ -1,82 +1,70 @@
import { LimitId } from "./features";
import { FeatureId } from "./features";
export type LimitSet = Partial<{
[key in LimitId]: {
[key in FeatureId]: {
value: number | null; // null indicates no limit
description?: string;
};
}>;
export const freeLimitSet: LimitSet = {
[LimitId.SITES]: { value: 5, description: "Basic limit" },
[LimitId.USERS]: { value: 5, description: "Basic limit" },
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
};
export const tier1LimitSet: LimitSet = {
[LimitId.USERS]: { value: 7, description: "Home limit" },
[LimitId.SITES]: { value: 10, description: "Home limit" },
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
[FeatureId.USERS]: { value: 7, description: "Home limit" },
[FeatureId.SITES]: { value: 10, description: "Home limit" },
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
};
export const tier2LimitSet: LimitSet = {
[LimitId.USERS]: {
[FeatureId.USERS]: {
value: 50,
description: "Team limit"
},
[LimitId.SITES]: {
[FeatureId.SITES]: {
value: 50,
description: "Team limit"
},
[LimitId.DOMAINS]: {
[FeatureId.DOMAINS]: {
value: 50,
description: "Team limit"
},
[LimitId.REMOTE_EXIT_NODES]: {
[FeatureId.REMOTE_EXIT_NODES]: {
value: 3,
description: "Team limit"
},
[LimitId.ORGANIZATIONS]: {
[FeatureId.ORGINIZATIONS]: {
value: 1,
description: "Team limit"
},
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
}
};
export const tier3LimitSet: LimitSet = {
[LimitId.USERS]: {
[FeatureId.USERS]: {
value: 250,
description: "Business limit"
},
[LimitId.SITES]: {
[FeatureId.SITES]: {
value: 250,
description: "Business limit"
},
[LimitId.DOMAINS]: {
[FeatureId.DOMAINS]: {
value: 100,
description: "Business limit"
},
[LimitId.REMOTE_EXIT_NODES]: {
[FeatureId.REMOTE_EXIT_NODES]: {
value: 20,
description: "Business limit"
},
[LimitId.ORGANIZATIONS]: {
[FeatureId.ORGINIZATIONS]: {
value: 5,
description: "Business limit"
},
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { db, limits } from "@server/db";
import { and, eq } from "drizzle-orm";
import { LimitSet } from "./limitSet";
import { LimitId } from "./features";
import { FeatureId } from "./features";
import logger from "@server/logger";
class LimitService {
@@ -38,7 +38,7 @@ class LimitService {
async getOrgLimit(
orgId: string,
featureId: LimitId
featureId: FeatureId
): Promise<number | null> {
const limitId = `${orgId}-${featureId}`;
const [limit] = await db
+6 -10
View File
@@ -16,17 +16,15 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam",
FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem", // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
WildcardSubdomain = "wildcardSubdomain"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -60,15 +58,13 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise"
],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
};
+19 -26
View File
@@ -9,10 +9,10 @@ import {
Transaction,
orgs
} from "@server/db";
import { LimitId, getFeatureMeterId } from "./features";
import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { build } from "@server/build";
import { regionalCache as cache } from "#dynamic/lib/cache";
import cache from "#dynamic/lib/cache";
export function noop() {
if (build !== "saas") {
@@ -22,6 +22,7 @@ export function noop() {
}
export class UsageService {
constructor() {
if (noop()) {
return;
@@ -37,7 +38,7 @@ export class UsageService {
public async add(
orgId: string,
featureId: LimitId,
featureId: FeatureId,
value: number,
transaction: any = null
): Promise<Usage | null> {
@@ -56,10 +57,7 @@ export class UsageService {
try {
let usage;
if (transaction) {
const orgIdToUse = await this.getBillingOrg(
orgId,
transaction
);
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
usage = await this.internalAddUsage(
orgIdToUse,
featureId,
@@ -114,7 +112,7 @@ export class UsageService {
private async internalAddUsage(
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: LimitId,
featureId: FeatureId,
value: number,
trx: Transaction
): Promise<Usage> {
@@ -163,7 +161,7 @@ export class UsageService {
async updateCount(
orgId: string,
featureId: LimitId,
featureId: FeatureId,
value?: number,
customerId?: string
): Promise<void> {
@@ -227,7 +225,7 @@ export class UsageService {
private async getCustomerId(
orgId: string,
featureId: LimitId
featureId: FeatureId
): Promise<string | null> {
const orgIdToUse = await this.getBillingOrg(orgId);
@@ -269,19 +267,18 @@ export class UsageService {
public async getUsage(
orgId: string,
featureId: LimitId,
featureId: FeatureId,
trx: Transaction | typeof db = db
): Promise<Usage | null> {
if (noop()) {
return null;
}
let orgIdToUse = orgId;
const orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
const [result] = await trx
.select()
.from(usage)
@@ -341,12 +338,8 @@ export class UsageService {
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
error
);
if (process.env.NODE_ENV !== "development") {
throw error;
}
throw error;
}
return null;
}
public async getBillingOrg(
@@ -381,7 +374,7 @@ export class UsageService {
public async checkLimitSet(
orgId: string,
featureId?: LimitId,
featureId?: FeatureId,
usage?: Usage,
trx: Transaction | typeof db = db
): Promise<boolean> {
@@ -389,13 +382,13 @@ export class UsageService {
return false;
}
const orgIdToUse = await this.getBillingOrg(orgId, trx);
// This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org
let hasExceededLimits = false;
let orgIdToUse = orgId;
try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
try {
let orgLimits: Limit[] = [];
if (featureId) {
// Get all limits set for this organization
@@ -429,7 +422,7 @@ export class UsageService {
} else {
currentUsage = await this.getUsage(
orgIdToUse,
limit.featureId as LimitId,
limit.featureId as FeatureId,
trx
);
}
+193 -91
View File
@@ -3,43 +3,29 @@ import {
newts,
blueprints,
Blueprint,
Site,
siteResources,
roleSiteResources,
userSiteResources,
clientSiteResources
} from "@server/db";
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
import {
PublicResourcesResults,
updatePublicResources
} from "./publicResources";
import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { sites } from "@server/db";
import { eq, and, isNotNull } from "drizzle-orm";
import {
addTargets as addProxyTargets,
sendBrowserGatewayTargets
} from "@server/routers/newt/targets";
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
import { addTargets as addClientTargets } from "@server/routers/client/targets";
import {
ClientResourcesResults,
updatePrivateResources
} from "./privateResources";
import { updateResourcePolicies } from "./resourcePolicies";
updateClientResources
} from "./clientResources";
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { generateName } from "@server/db/names";
import {
handleMessagingForUpdatedSiteResource,
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { faker } from "@faker-js/faker";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
type ApplyBlueprintArgs = {
orgId: string;
@@ -56,39 +42,40 @@ export async function applyBlueprint({
name,
source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false;
let blueprintMessage = "";
let blueprintMessage: string;
let error: any | null = null;
try {
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = [];
let proxyResourcesResults: ProxyResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);
publicResourcesResults = await updatePublicResources(
proxyResourcesResults = await updateProxyResources(
orgId,
config,
trx,
siteId
);
privateResourcesResults = await updatePrivateResources(
clientResourcesResults = await updateClientResources(
orgId,
config,
trx,
siteId
);
logger.debug(
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of publicResourcesResults) {
for (const result of proxyResourcesResults) {
for (const target of result.targetsToUpdate) {
const [site] = await trx
.select()
@@ -115,63 +102,178 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId
);
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck ? [matchingHealthcheck] : [],
result.proxyResource.protocol,
site.newt.version
);
}
}
}
logger.debug(
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
for (const result of clientResourcesResults) {
if (
result.oldSiteResource &&
JSON.stringify(result.newSites?.sort()) !==
JSON.stringify(result.oldSites?.sort())
) {
// query existing associations
const existingRoleIds = await trx
.select()
.from(roleSiteResources)
.where(
eq(
roleSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
.then((rows) => rows.map((row) => row.roleId));
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
const existingUserIds = await trx
.select()
.from(userSiteResources)
.where(
eq(
userSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
.then((rows) => rows.map((row) => row.userId));
const existingClientIds = await trx
.select()
.from(clientSiteResources)
.where(
eq(
clientSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
.then((rows) => rows.map((row) => row.clientId));
// delete the existing site resource
await trx
.delete(siteResources)
.where(
and(
eq(
siteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
);
await rebuildClientAssociationsFromSiteResource(
result.oldSiteResource,
trx
);
const [insertedSiteResource] = await trx
.insert(siteResources)
.values({
...result.newSiteResource
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
//////////////////// update the associations ////////////////////
if (existingRoleIds.length > 0) {
await trx.insert(roleSiteResources).values(
existingRoleIds.map((roleId) => ({
roleId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingUserIds.length > 0) {
await trx.insert(userSiteResources).values(
existingUserIds.map((userId) => ({
userId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingClientIds.length > 0) {
await trx.insert(clientSiteResources).values(
existingClientIds.map((clientId) => ({
clientId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
insertedSiteResource,
trx
);
} else {
let good = true;
for (const newSite of result.newSites) {
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, newSite.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (!site) {
logger.debug(
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
good = false;
break;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
);
}
if (!good) {
continue;
}
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.newSites.map((site) => ({
siteId: site.siteId,
orgId: result.newSiteResource.orgId
})),
trx
);
}
// await addClientTargets(
// site.newt.newtId,
// result.resource.destination,
// result.resource.destinationPort,
// result.resource.protocol,
// result.resource.proxyPort
// );
}
});
blueprintSucceeded = true;
@@ -179,9 +281,7 @@ export async function applyBlueprint({
} catch (err) {
blueprintSucceeded = false;
blueprintMessage = `Blueprint applied with errors: ${err}`;
logger.debug(
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
);
logger.error(blueprintMessage);
error = err;
}
@@ -191,7 +291,9 @@ export async function applyBlueprint({
.insert(blueprints)
.values({
orgId,
name: name ?? generateName(),
name:
name ??
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
contents: stringifyYaml(configData),
createdAt: Math.floor(Date.now() / 1000),
succeeded: blueprintSucceeded,
@@ -1,56 +1,10 @@
import { sendToClient } from "#dynamic/routers/ws";
import { processContainerLabels } from "./parseDockerContainers";
import { applyBlueprint } from "./applyBlueprint";
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
import { db, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
type BlueprintResult = ReturnType<typeof processContainerLabels>;
function filterInvalidResources(blueprint: BlueprintResult): {
skippedCount: number;
skippedKeys: string[];
} {
const skippedKeys: string[] = [];
for (const section of ["proxy-resources", "public-resources"] as const) {
const resources = blueprint[section];
for (const [key, value] of Object.entries(resources)) {
const result = PublicResourceSchema.safeParse(value);
if (!result.success) {
const errors = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
logger.warn(
`Skipping invalid Docker ${section} "${key}": ${errors}`
);
delete resources[key];
skippedKeys.push(`${section}.${key}`);
}
}
}
for (const section of ["client-resources", "private-resources"] as const) {
const resources = blueprint[section];
for (const [key, value] of Object.entries(resources)) {
const result = PrivateResourceSchema.safeParse(value);
if (!result.success) {
const errors = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
logger.warn(
`Skipping invalid Docker ${section} "${key}": ${errors}`
);
delete resources[key];
skippedKeys.push(`${section}.${key}`);
}
}
}
return { skippedCount: skippedKeys.length, skippedKeys };
}
export async function applyNewtDockerBlueprint(
siteId: number,
newtId: string,
@@ -67,27 +21,17 @@ export async function applyNewtDockerBlueprint(
return;
}
let skippedCount = 0;
let skippedKeys: string[] = [];
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
try {
// Some Newt clients can report null/undefined containers when Docker
// labels are unavailable. Treat that as an empty blueprint payload.
const safeContainers = Array.isArray(containers) ? containers : [];
const blueprint = processContainerLabels(safeContainers);
const blueprint = processContainerLabels(containers);
logger.debug(
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
);
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
const filterResult = filterInvalidResources(blueprint);
skippedCount = filterResult.skippedCount;
skippedKeys = filterResult.skippedKeys;
if (skippedCount > 0) {
logger.warn(
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
);
// make sure this is not an empty object
if (isEmptyObject(blueprint)) {
return;
}
if (
@@ -96,15 +40,6 @@ export async function applyNewtDockerBlueprint(
isEmptyObject(blueprint["public-resources"]) &&
isEmptyObject(blueprint["private-resources"])
) {
if (skippedCount > 0) {
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
success: false,
message: `All resources were invalid and skipped: ${skippedKeys.join(", ")}`
}
});
}
return;
}
@@ -116,7 +51,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT"
});
} catch (error) {
logger.debug(`Failed to update database from config: ${error}`);
logger.error(`Failed to update database from config: ${error}`);
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
@@ -131,10 +66,7 @@ export async function applyNewtDockerBlueprint(
type: "newt/blueprint/results",
data: {
success: true,
message:
skippedCount > 0
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
: "Config updated successfully"
message: "Config updated successfully"
}
});
}
@@ -3,7 +3,6 @@ import {
clientSiteResources,
domains,
orgDomains,
roleActions,
roles,
roleSiteResources,
Site,
@@ -20,17 +19,8 @@ import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
@@ -111,7 +101,7 @@ export type ClientResourcesResults = {
oldSites: { siteId: number }[];
}[];
export async function updatePrivateResources(
export async function updateClientResources(
orgId: string,
config: Config,
trx: Transaction,
@@ -122,30 +112,6 @@ export async function updatePrivateResources(
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx
.select()
.from(siteResources)
@@ -259,11 +225,7 @@ export async function updatePrivateResources(
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
domainId: domainInfo ? domainInfo.domainId : null
})
.where(
eq(
@@ -370,7 +332,8 @@ export async function updatePrivateResources(
}
if (resourceData.roles.length > 0) {
const existingRoles = await trx
// Re-add specified roles but we need to get the roleIds from the role name in the array
const rolesToUpdate = await trx
.select()
.from(roles)
.where(
@@ -380,30 +343,7 @@ export async function updatePrivateResources(
)
);
const foundNames = new Set(existingRoles.map((r) => r.name));
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(
`Auto-created role "${name}" in org ${orgId} from blueprint`
);
}
const roleIds = existingRoles.map((role) => role.roleId);
const roleIds = rolesToUpdate.map((role) => role.roleId);
await trx
.insert(roleSiteResources)
@@ -419,47 +359,9 @@ export async function updatePrivateResources(
oldSites: existingSiteIds
});
} else {
// create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PRIVATE_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Private resource limit exceeded. Please upgrade your plan."
);
}
}
let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (
resourceData.mode === "host" ||
resourceData.mode === "http" ||
resourceData.mode === "ssh"
) {
const { value, release } = await getNextAvailableAliasAddress(
orgId,
trx
);
aliasAddress = value;
releaseAliasLock = release;
if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
}
let domainInfo:
@@ -513,16 +415,10 @@ export async function updatePrivateResources(
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
domainId: domainInfo ? domainInfo.domainId : null
})
.returning();
await releaseAliasLock?.();
const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
@@ -548,7 +444,8 @@ export async function updatePrivateResources(
});
if (resourceData.roles.length > 0) {
const existingRoles = await trx
// get roleIds from role names
const rolesToUpdate = await trx
.select()
.from(roles)
.where(
@@ -558,30 +455,7 @@ export async function updatePrivateResources(
)
);
const foundNames = new Set(existingRoles.map((r) => r.name));
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(
`Auto-created role "${name}" in org ${orgId} from blueprint`
);
}
const roleIds = existingRoles.map((role) => role.roleId);
const roleIds = rolesToUpdate.map((role) => role.roleId);
await trx
.insert(roleSiteResources)
@@ -643,8 +517,6 @@ export async function updatePrivateResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
results.push({
newSiteResource: newResource,
newSites: allSites,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-654
View File
@@ -1,654 +0,0 @@
import {
db,
idp,
idpOrg,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
roles,
Transaction,
userOrgs,
userPolicies,
users
} from "@server/db";
import { eq, and, or } from "drizzle-orm";
import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
export type ResourcePoliciesResults = {
resourcePolicyId: number;
niceId: string;
}[];
export async function updateResourcePolicies(
orgId: string,
config: Config,
trx: Transaction
): Promise<ResourcePoliciesResults> {
const results: ResourcePoliciesResults = [];
for (const [policyNiceId, policyData] of Object.entries(
config["public-policies"]
)) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.resourcePolicies
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
);
}
// Validate rules
for (const rule of policyData.rules) {
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
throw new Error(
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
throw new Error(
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (
rule.match === "path" &&
!isValidUrlGlobPattern(rule.value)
) {
throw new Error(
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
);
}
}
// Validate auto-login-idp if provided
if (policyData["auto-login-idp"]) {
const [provider] = await trx
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(
and(
eq(idp.idpId, policyData["auto-login-idp"]),
eq(idpOrg.orgId, orgId)
)
)
.limit(1);
if (!provider) {
throw new Error(
`Identity provider not found for policy '${policyNiceId}' in this organization`
);
}
}
// Look up the admin role
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error("Admin role not found");
}
// Find existing policy by niceId and orgId
const [existingPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, policyNiceId),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
let resourcePolicyId: number;
if (existingPolicy) {
// Update the existing policy
await trx
.update(resourcePolicies)
.set({
name: policyData.name,
sso: policyData.sso ?? true,
idpId: policyData["auto-login-idp"] ?? null,
emailWhitelistEnabled:
policyData["email-whitelist-enabled"] ??
policyData["whitelist-users"].length > 0,
applyRules:
policyData["apply-rules"] || policyData.rules.length > 0
})
.where(
eq(
resourcePolicies.resourcePolicyId,
existingPolicy.resourcePolicyId
)
);
resourcePolicyId = existingPolicy.resourcePolicyId;
// Sync password
await trx
.delete(resourcePolicyPassword)
.where(
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicyId
)
);
if (policyData.password) {
const passwordHash = await hashPassword(policyData.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId,
passwordHash
});
}
// Sync pincode
await trx
.delete(resourcePolicyPincode)
.where(
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
);
if (policyData.pincode) {
const pincodeHash = await hashPassword(policyData.pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
// Sync header auth
await trx
.delete(resourcePolicyHeaderAuth)
.where(
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicyId
)
);
if (policyData["basic-auth"]) {
const basicAuth = policyData["basic-auth"];
const headerAuthHash = await hashPassword(
Buffer.from(
`${basicAuth.user}:${basicAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility:
basicAuth["extended-compatibility"] ?? true
});
}
// Sync SSO roles
await syncRolePolicies(
resourcePolicyId,
policyData["sso-roles"],
orgId,
adminRole.roleId,
trx
);
// Sync SSO users
await syncUserPolicies(
resourcePolicyId,
policyData["sso-users"],
orgId,
trx
);
// Sync whitelist users
await syncWhitelistPolicyUsers(
resourcePolicyId,
policyData["whitelist-users"],
trx
);
// Sync rules
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
logger.debug(
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
);
} else {
// Create a new policy
const [newPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: policyData.name,
sso: policyData.sso ?? true,
idpId: policyData["auto-login-idp"] ?? null,
emailWhitelistEnabled:
policyData["email-whitelist-enabled"] ??
policyData["whitelist-users"].length > 0,
applyRules:
policyData["apply-rules"] ||
policyData.rules.length > 0,
scope: "global"
})
.returning();
resourcePolicyId = newPolicy.resourcePolicyId;
// Always add admin role
await trx.insert(rolePolicies).values({
roleId: adminRole.roleId,
resourcePolicyId
});
// Add SSO roles
await addRolePolicies(
resourcePolicyId,
policyData["sso-roles"],
orgId,
adminRole.roleId,
trx
);
// Add SSO users
await addUserPolicies(
resourcePolicyId,
policyData["sso-users"],
orgId,
trx
);
// Add password
if (policyData.password) {
const passwordHash = await hashPassword(policyData.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId,
passwordHash
});
}
// Add pincode
if (policyData.pincode) {
const pincodeHash = await hashPassword(policyData.pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
// Add header auth
if (policyData["basic-auth"]) {
const basicAuth = policyData["basic-auth"];
const headerAuthHash = await hashPassword(
Buffer.from(
`${basicAuth.user}:${basicAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility:
basicAuth["extended-compatibility"] ?? true
});
}
// Add whitelist users
if (policyData["whitelist-users"].length > 0) {
await trx.insert(resourcePolicyWhiteList).values(
policyData["whitelist-users"].map((email) => ({
email,
resourcePolicyId
}))
);
}
// Add rules
if (policyData.rules.length > 0) {
await trx.insert(resourcePolicyRules).values(
policyData.rules.map((rule, index) => ({
resourcePolicyId,
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: rule.priority ?? index + 1,
enabled: rule.enabled ?? true
}))
);
}
logger.debug(
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
);
}
results.push({ resourcePolicyId, niceId: policyNiceId });
}
return results;
}
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
if (input === "allow") return "ACCEPT";
if (input === "deny") return "DROP";
return "PASS";
}
function getRuleMatch(
input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
}
async function syncRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
adminRoleId: number,
trx: Transaction
) {
const existingRolePolicies = await trx
.select()
.from(rolePolicies)
.where(eq(rolePolicies.resourcePolicyId, policyId));
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
logger.warn(
`Role '${roleName}' not found in org '${orgId}', skipping`
);
continue;
}
if (role.isAdmin) {
continue; // admin role is always included, skip
}
const alreadyExists = existingRolePolicies.some(
(rp) => rp.roleId === role.roleId
);
if (!alreadyExists) {
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
// Remove roles no longer in the list (except admin)
for (const existingRolePolicy of existingRolePolicies) {
if (existingRolePolicy.roleId === adminRoleId) {
continue;
}
const [role] = await trx
.select()
.from(roles)
.where(eq(roles.roleId, existingRolePolicy.roleId))
.limit(1);
if (role?.isAdmin) {
continue;
}
if (role && !ssoRoles.includes(role.name)) {
await trx
.delete(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
eq(rolePolicies.roleId, existingRolePolicy.roleId)
)
);
}
}
}
async function addRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
adminRoleId: number,
trx: Transaction
) {
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
logger.warn(
`Role '${roleName}' not found in org '${orgId}', skipping`
);
continue;
}
if (role.isAdmin) {
continue; // admin already added
}
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
async function syncUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
const existingUserPolicies = await trx
.select()
.from(userPolicies)
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.user.userId
);
if (!alreadyExists) {
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
// Remove users no longer in the list
for (const existingUserPolicy of existingUserPolicies) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
eq(users.userId, existingUserPolicy.userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (
user &&
user.user.username &&
!ssoUsers.includes(user.user.username) &&
!ssoUsers.includes(user.user.email ?? "")
) {
await trx
.delete(userPolicies)
.where(
and(
eq(userPolicies.resourcePolicyId, policyId),
eq(userPolicies.userId, existingUserPolicy.userId)
)
);
}
}
}
async function addUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
async function syncWhitelistPolicyUsers(
policyId: number,
whitelistUsers: string[],
trx: Transaction
) {
const existingWhitelist = await trx
.select()
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
for (const email of whitelistUsers) {
const alreadyExists = existingWhitelist.some((w) => w.email === email);
if (!alreadyExists) {
await trx.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
}
}
for (const existingEntry of existingWhitelist) {
if (!whitelistUsers.includes(existingEntry.email)) {
await trx
.delete(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
eq(resourcePolicyWhiteList.email, existingEntry.email)
)
);
}
}
}
async function syncPolicyRules(
policyId: number,
rules: ResourcePolicyData["rules"],
trx: Transaction
) {
const existingRules = await trx
.select()
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
.orderBy(resourcePolicyRules.priority);
for (const [index, rule] of rules.entries()) {
const intendedPriority = rule.priority ?? index + 1;
const existingRule = existingRules[index];
if (existingRule) {
await trx
.update(resourcePolicyRules)
.set({
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: intendedPriority,
enabled: rule.enabled ?? true
})
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
} else {
await trx.insert(resourcePolicyRules).values({
resourcePolicyId: policyId,
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: intendedPriority,
enabled: rule.enabled ?? true
});
}
}
// Remove extra rules
if (existingRules.length > rules.length) {
const rulesToDelete = existingRules.slice(rules.length);
for (const rule of rulesToDelete) {
await trx
.delete(resourcePolicyRules)
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
}
}
}
+34 -256
View File
@@ -1,23 +1,8 @@
import { z } from "zod";
import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
const hasMaxmindCountryDb =
typeof maxmindDbPath === "string" &&
maxmindDbPath.length > 0 &&
existsSync(maxmindDbPath);
const hasMaxmindAsnDb =
typeof maxmindAsnPath === "string" &&
maxmindAsnPath.length > 0 &&
existsSync(maxmindAsnPath);
export const SiteSchema = z.object({
name: z.string().min(1).max(100),
@@ -97,9 +82,8 @@ export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.coerce.string(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
value: z.string(),
priority: z.int().optional()
})
.refine(
(rule) => {
@@ -132,9 +116,6 @@ export const RuleSchema = z
.refine(
(rule) => {
if (rule.match === "country") {
if (!hasMaxmindCountryDb) {
return false;
}
// Check if it's a valid 2-letter country code or "ALL"
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
}
@@ -143,15 +124,12 @@ export const RuleSchema = z
{
path: ["value"],
message:
"Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
}
)
.refine(
(rule) => {
if (rule.match === "asn") {
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
return false;
}
// Check if it's either AS<number> format or "ALL"
const asNumberPattern = /^AS\d+$/i;
return asNumberPattern.test(rule.value) || rule.value === "ALL";
@@ -161,7 +139,7 @@ export const RuleSchema = z
{
path: ["value"],
message:
"ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
}
)
.refine(
@@ -183,34 +161,11 @@ export const HeaderSchema = z.object({
value: z.string().min(1)
});
export const AuthDaemonSchema = z
.object({
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
mode: z.enum(["site", "remote", "native"]).optional().default("site"),
port: z.int().min(1).max(65535).optional()
})
.refine(
(data) => {
if (data.mode === "remote") {
return data.port !== undefined;
}
return true;
},
{
path: ["port"],
message: "port is required when auth-daemon mode is 'remote'"
}
);
// Schema for individual resource
export const PublicResourceSchema = z
export const ResourceSchema = z
.object({
name: z.string().optional(),
protocol: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
policy: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(),
@@ -222,10 +177,7 @@ export const PublicResourceSchema = z
"tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
"proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional()
maintenance: MaintenanceSchema.optional()
})
.refine(
(resource) => {
@@ -233,10 +185,9 @@ export const PublicResourceSchema = z
return true;
}
// Otherwise, require name and protocol/mode for full resource definition
// Otherwise, require name and protocol for full resource definition
return (
resource.name !== undefined &&
(resource.mode !== undefined || resource.protocol !== undefined)
resource.name !== undefined && resource.protocol !== undefined
);
},
{
@@ -250,8 +201,8 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is http, all targets must have method field
if ((resource.mode ?? resource.protocol) === "http") {
// If protocol is http, all targets must have method field
if (resource.protocol === "http") {
return resource.targets.every(
(target) => target == null || target.method !== undefined
);
@@ -269,9 +220,8 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is tcp or udp, no target should have method field
const effectiveProtocol1 = resource.mode ?? resource.protocol;
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
// If protocol is tcp or udp, no target should have method field
if (resource.protocol === "tcp" || resource.protocol === "udp") {
return resource.targets.every(
(target) => target == null || target.method === undefined
);
@@ -289,37 +239,8 @@ export const PublicResourceSchema = z
return true;
}
const effectiveProtocol = resource.mode ?? resource.protocol;
if (effectiveProtocol !== "ssh") {
return true;
}
const authDaemonMode = resource["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
return (
resource.targets.filter((target) => target != null).length <= 1
);
},
{
path: ["targets"],
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
const effectiveProtocol = resource.mode ?? resource.protocol;
if (
effectiveProtocol !== undefined &&
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
) {
// If protocol is http, it must have a full-domain
if (resource.protocol === "http") {
return (
resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0
@@ -329,7 +250,7 @@ export const PublicResourceSchema = z
},
{
path: ["full-domain"],
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
error: "When protocol is 'http', a 'full-domain' must be provided"
}
)
.refine(
@@ -338,9 +259,8 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is tcp or udp, it must have both proxy-port
const effectiveProtocol2 = resource.mode ?? resource.protocol;
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
// If protocol is tcp or udp, it must have both proxy-port
if (resource.protocol === "tcp" || resource.protocol === "udp") {
return resource["proxy-port"] !== undefined;
}
return true;
@@ -357,9 +277,8 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is tcp or udp, it must not have auth
const effectiveProtocol3 = resource.mode ?? resource.protocol;
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
// If protocol is tcp or udp, it must not have auth
if (resource.protocol === "tcp" || resource.protocol === "udp") {
return resource.auth === undefined;
}
return true;
@@ -421,8 +340,7 @@ export const PublicResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{
@@ -430,46 +348,22 @@ export const PublicResourceSchema = z
message:
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
}
)
.refine(
(resource) => {
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode !== "tcp") {
return (
resource["proxy-protocol"] === undefined &&
resource["proxy-protocol-version"] === undefined
);
}
return true;
},
{
path: ["proxy-protocol"],
message:
"'proxy-protocol' and 'proxy-protocol-version' can only be set when mode is 'tcp'"
}
)
.transform((resource) => {
// Normalize: prefer mode, fall back to protocol for backwards compatibility
if (resource.mode === undefined && resource.protocol !== undefined) {
resource.mode = resource.protocol;
}
return resource;
});
);
export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets;
}
export const PrivateResourceSchema = z
export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http", "ssh"]),
mode: z.enum(["host", "cidr", "http"]),
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(),
destination: z.string().min(1),
// enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
@@ -492,31 +386,11 @@ export const PrivateResourceSchema = z
error: "Admin role cannot be included in roles"
}),
users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional()
machines: z.array(z.string()).optional().default([])
})
.refine(
(data) => {
// destination is optional only for ssh+native; required for everything else
const isNativeSSH =
data.mode === "ssh" &&
(data["auth-daemon"] === undefined ||
data["auth-daemon"].mode === "native");
if (!isNativeSSH && !data.destination) {
return false;
}
return true;
},
{
path: ["destination"],
message:
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
}
)
.refine(
(data) => {
if (data.mode === "host") {
if (!data.destination) return true; // caught by the destination-required refine
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z
.union([z.ipv4(), z.ipv6()])
@@ -544,7 +418,6 @@ export const PrivateResourceSchema = z
.refine(
(data) => {
if (data.mode === "cidr") {
if (!data.destination) return true; // caught by the destination-required refine
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()])
@@ -556,112 +429,25 @@ export const PrivateResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
)
.refine(
(data) => {
if (data.mode !== "ssh") {
return true;
}
const authDaemonMode = data["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
const uniqueSites = new Set<string>();
if (data.site) {
uniqueSites.add(data.site);
}
for (const site of data.sites) {
uniqueSites.add(site);
}
return uniqueSites.size <= 1;
},
{
path: ["sites"],
message:
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
}
)
.transform((data) => {
if (
data.mode === "ssh" &&
data.destination !== undefined &&
data["destination-port"] === undefined
) {
data["destination-port"] = 22;
}
return data;
});
export const ResourcePolicyRuleSchema = RuleSchema;
export const ResourcePolicySchema = z.object({
name: z.string().min(1).max(255),
sso: z.boolean().optional().default(true),
"auto-login-idp": z.int().positive().optional().nullable(),
"sso-roles": z
.array(z.string())
.optional()
.default([])
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in sso-roles"
}),
"sso-users": z.array(z.string()).optional().default([]),
password: z.string().min(4).max(100).optional().nullable(),
pincode: z
.string()
.regex(/^\d{6}$/)
.optional()
.nullable(),
"basic-auth": z
.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
"extended-compatibility": z.boolean().default(true)
})
.optional()
.nullable(),
"email-whitelist-enabled": z.boolean().optional().default(false),
"whitelist-users": z
.array(
z.email().or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
error: "Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
"apply-rules": z.boolean().optional().default(false),
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
});
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
);
// Schema for the entire configuration object
export const ConfigSchema = z
.object({
"proxy-resources": z
.record(z.string(), PublicResourceSchema)
.record(z.string(), ResourceSchema)
.optional()
.prefault({}),
"public-resources": z
.record(z.string(), PublicResourceSchema)
.record(z.string(), ResourceSchema)
.optional()
.prefault({}),
"client-resources": z
.record(z.string(), PrivateResourceSchema)
.record(z.string(), ClientResourceSchema)
.optional()
.prefault({}),
"private-resources": z
.record(z.string(), PrivateResourceSchema)
.optional()
.prefault({}),
"public-policies": z
.record(z.string(), ResourcePolicySchema)
.record(z.string(), ClientResourceSchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -686,17 +472,10 @@ export const ConfigSchema = z
}
return data as {
"proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
"client-resources": Record<
string,
z.infer<typeof PrivateResourceSchema>
>;
"public-policies": Record<
string,
z.infer<typeof ResourcePolicySchema>
z.infer<typeof ClientResourceSchema>
>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
@@ -835,6 +614,5 @@ export const ConfigSchema = z
// Type inference from the schema
export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Resource = z.infer<typeof ResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
-11
View File
@@ -154,19 +154,8 @@ class AdaptiveCache {
keys(): string[] {
return localCache.keys();
}
/**
* Get keys with a specific prefix
* @param prefix - Key prefix to match
* @returns Array of matching keys
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
const allKeys = localCache.keys();
return allKeys.filter((key) => key.startsWith(prefix));
}
}
// Export singleton instance
export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export default cache;
+384 -390
View File
@@ -6,7 +6,6 @@ import {
db,
olms,
orgs,
primaryDb,
roleClients,
roles,
Transaction,
@@ -24,427 +23,422 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations
import { OlmErrorCodes } from "@server/routers/olm/error";
import { tierMatrix } from "./billing/tierMatrix";
type ClientRow = typeof clients.$inferSelect;
function runQueuedClientAssociationRebuilds(
userId: string,
queuedClients: ClientRow[]
) {
if (queuedClients.length === 0) {
return;
}
const uniqueClientsById = new Map<number, ClientRow>();
for (const client of queuedClients) {
uniqueClientsById.set(client.clientId, client);
}
for (const client of uniqueClientsById.values()) {
rebuildClientAssociationsFromClient(client).catch((error) => {
logger.error(
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
error
)}`
);
});
}
logger.debug(
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
);
}
export async function calculateUserClientsForOrgs(
userId: string
userId: string,
trx: Transaction | typeof db = db
): Promise<void> {
const trx = primaryDb;
const execute = async (transaction: Transaction | typeof db) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const queuedAssociationRebuilds: ClientRow[] = [];
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) =>
`${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await trx
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await trx
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await trx
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await trx
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(
userId,
trx,
[],
queuedAssociationRebuilds
);
return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await trx
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(orgId, olm.olmId);
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
return exitNodes;
};
if (!hasRoleAccess) {
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await transaction
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(userId, transaction);
return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await transaction
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<
string,
(typeof userOrgRoleRows)[0][]
>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
if (!hasUserAccess) {
await trx.insert(userClients).values({
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!hasRoleAccess) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
existingClient.clientId
);
if (!hasUserAccess) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[
Math.floor(Math.random() * exitNodesList.length)
];
// Get next available subnet
const newSubnet = await getNextAvailableClientSubnet(
orgId,
transaction
);
if (!newSubnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
);
continue;
}
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await transaction
.insert(clients)
.values(newClientData)
.returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
await transaction
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
await rebuildClientAssociationsFromClient(
newClient,
transaction
);
// Grant admin role access to the client
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await trx
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
// create approval request
if (requireApproval) {
await trx
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
queuedAssociationRebuilds.push(newClient);
// Grant admin role access to the client
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await trx.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
}
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(userId, transaction, userOrgIds);
};
if (trx) {
// Use provided transaction
await execute(trx);
} else {
// Create new transaction
await db.transaction(async (transaction) => {
await execute(transaction);
});
}
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(
userId,
trx,
userOrgIds,
queuedAssociationRebuilds
);
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
}
async function cleanupOrphanedClients(
userId: string,
trx: Transaction | typeof db,
userOrgIds: string[] = [],
queuedAssociationRebuilds: ClientRow[] = []
userOrgIds: string[] = []
): Promise<void> {
// Find all OLM clients for this user that should be deleted
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
@@ -474,9 +468,9 @@ async function cleanupOrphanedClients(
)
.returning();
// Queue deleted clients for post-trx association cleanup.
// Rebuild associations for each deleted client to clean up related data
for (const deletedClient of deletedClients) {
queuedAssociationRebuilds.push(deletedClient);
await rebuildClientAssociationsFromClient(deletedClient, trx);
if (deletedClient.olmId) {
await sendTerminateClient(
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.20.0";
export const APP_VERSION = "1.18.3";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
-83
View File
@@ -1,83 +0,0 @@
import logger from "@server/logger";
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
/**
* Detect transient errors that are safe to retry (connection drops, deadlocks,
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
* retry: the database guarantees exactly one winner per deadlock pair, so the
* loser just needs to try again.
*/
export function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*/
export async function withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries: number = MAX_RETRIES,
baseDelayMs: number = BASE_DELAY_MS
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < maxRetries) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
+7 -9
View File
@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
import { usageService } from "./billing/usageService";
import { LimitId } from "./billing";
import { FeatureId } from "./billing";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
@@ -140,9 +140,7 @@ export async function deleteOrgById(
.select({ count: count() })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
logger.info(
`Found ${orgCount.count} orgs using domain ${domainId}`
);
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
if (orgCount.count === 1) {
domainIdsToDelete.push(domainId);
}
@@ -154,7 +152,7 @@ export async function deleteOrgById(
.where(inArray(domains.domainId, domainIdsToDelete));
}
await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
@@ -201,22 +199,22 @@ export async function deleteOrgById(
if (org.billingOrgId) {
usageService.updateCount(
org.billingOrgId,
LimitId.DOMAINS,
FeatureId.DOMAINS,
domainCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
LimitId.SITES,
FeatureId.SITES,
siteCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
LimitId.USERS,
FeatureId.USERS,
userCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
LimitId.REMOTE_EXIT_NODES,
FeatureId.REMOTE_EXIT_NODES,
remoteExitNodeCount ?? 0
);
}
-144
View File
@@ -1,144 +0,0 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets,
type Resource,
type Target,
type TargetHealthCheck,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
targetsToBeRemoved: Target[];
healthChecksToBeRemoved: TargetHealthCheck[];
};
export async function performDeleteResources(
resourceIds: number[],
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult[]> {
if (resourceIds.length === 0) {
return [];
}
const targetsToBeRemoved = await trx
.select()
.from(targets)
.where(inArray(targets.resourceId, resourceIds));
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
const healthChecksToBeRemoved =
targetIds.length > 0
? await trx
.select()
.from(targetHealthCheck)
.where(inArray(targetHealthCheck.targetId, targetIds))
: [];
const deletedResources = await trx
.delete(resources)
.where(inArray(resources.resourceId, resourceIds))
.returning();
const policyIds = deletedResources
.map((resource) => resource.defaultResourcePolicyId)
.filter((id): id is number => id != null);
if (policyIds.length > 0) {
await trx
.delete(resourcePolicies)
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
}
if (deletedResources.length > 0) {
logger.debug(`Deleted ${deletedResources.length} resources`);
}
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
for (const healthCheck of healthChecksToBeRemoved) {
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
if (resourceId == null) {
continue;
}
const existing = healthChecksByResourceId.get(resourceId) ?? [];
existing.push(healthCheck);
healthChecksByResourceId.set(resourceId, existing);
}
return deletedResources.map((deletedResource) => ({
deletedResource,
targetsToBeRemoved:
targetsByResourceId.get(deletedResource.resourceId) ?? [],
healthChecksToBeRemoved:
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
}));
}
export async function performDeleteResource(
resourceId: number,
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult | null> {
const [result] = await performDeleteResources([resourceId], trx);
return result ?? null;
}
export async function runResourceDeleteSideEffects(
result: DeleteResourceResult
): Promise<void> {
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
result;
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
);
}
if (site.pubKey && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await removeTargets(
newt.newtId,
[],
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
}
-126
View File
@@ -1,126 +0,0 @@
import { and, eq, sql } from "drizzle-orm";
import {
db,
siteNetworks,
siteResources,
targets,
type SiteResource,
type Transaction
} from "@server/db";
import {
performDeleteResources,
runResourceDeleteSideEffects,
type DeleteResourceResult
} from "@server/lib/deleteResource";
import {
performDeleteSiteResources,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import logger from "@server/logger";
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
export type DeleteSiteAssociatedResourcesSideEffects = {
resources: DeleteResourceResult[];
siteResources: SiteResource[];
};
export async function getResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
return rows.map((row) => row.resourceId);
}
export async function getSiteResourceIdsForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
);
return rows.map((row) => row.siteResourceId);
}
export async function getAssociatedResourceCountForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number> {
const [publicCountResult, privateCountResult] = await Promise.all([
trx
.select({
count: sql<number>`count(distinct ${targets.resourceId})`
})
.from(targets)
.where(eq(targets.siteId, siteId)),
trx
.select({
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
})
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
]);
return (
Number(publicCountResult[0]?.count ?? 0) +
Number(privateCountResult[0]?.count ?? 0)
);
}
export function exceedsSiteAssociatedResourceDeleteLimit(
resourceCount: number
): boolean {
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
sideEffects: DeleteSiteAssociatedResourcesSideEffects
): Promise<void> {
for (const result of sideEffects.resources) {
await runResourceDeleteSideEffects(result);
}
for (const removed of sideEffects.siteResources) {
runSiteResourceDeleteSideEffects(removed);
}
}
-53
View File
@@ -1,53 +0,0 @@
import { inArray } from "drizzle-orm";
import {
db,
siteResources,
type SiteResource,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
export async function performDeleteSiteResources(
siteResourceIds: number[],
trx: Transaction | typeof db = db
): Promise<SiteResource[]> {
if (siteResourceIds.length === 0) {
return [];
}
const removedSiteResources = await trx
.delete(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds))
.returning();
if (removedSiteResources.length > 0) {
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
}
return removedSiteResources;
}
export async function performDeleteSiteResource(
siteResourceId: number,
trx: Transaction | typeof db = db
): Promise<SiteResource | null> {
const [removedSiteResource] = await performDeleteSiteResources(
[siteResourceId],
trx
);
return removedSiteResource ?? null;
}
export function runSiteResourceDeleteSideEffects(
removedSiteResource: SiteResource
): void {
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
(err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
err
);
}
);
}
+1 -5
View File
@@ -19,11 +19,7 @@ export async function verifyExitNodeOrgAccess(
export async function listExitNodes(
orgId: string,
filterOnline = false,
noCloud = false,
// Accepted for parity with the enterprise implementation (used there for
// site-label filtering of remote exit nodes). The OSS build has no remote
// exit nodes, so it is unused here.
siteId?: number
noCloud = false
) {
// TODO: pick which nodes to send and ping better than just all of them that are not remote
const allExitNodes = await db
+24 -49
View File
@@ -1,55 +1,30 @@
import { db, exitNodes, Transaction } from "@server/db";
import { db, exitNodes } from "@server/db";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
/**
* Reserves the next available exit node subnet.
*
* Exit node subnets must never overlap with one another - regardless of
* which org(s) they belong to - since HA exit nodes can end up routing for
* the same org. This acquires a lock that the caller MUST release (via the
* returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
export async function getNextAvailableSubnet(): Promise<string> {
// Get all existing subnets from routes table
const existingAddresses = await db
.select({
address: exitNodes.address
})
.from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
// Get all existing subnets from routes table
const existingAddresses = await trx
.select({
address: exitNodes.address
})
.from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return subnet;
}
+121 -159
View File
@@ -327,145 +327,127 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
export async function getNextAvailableClientSubnet(
orgId: string,
transaction: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = `client-subnet-allocation:${orgId}`;
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
): Promise<string> {
return await lockManager.withLock(
`client-subnet-allocation:${orgId}`,
async () => {
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
try {
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction
.select({
address: clients.subnet
})
.from(clients)
.where(
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
);
const addresses = [
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32`
)
].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return subnet;
}
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction
.select({
address: clients.subnet
})
.from(clients)
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
const addresses = [
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")[0]}/32`
)
].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
);
}
export async function getNextAvailableAliasAddress(
orgId: string,
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = `alias-address-allocation:${orgId}`;
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
): Promise<string> {
return await lockManager.withLock(
`alias-address-allocation:${orgId}`,
async () => {
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
try {
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
if (!org.utilitySubnet) {
throw new Error(
`Organization with ID ${orgId} has no utility subnet defined`
);
}
const existingAddresses = await trx
.select({
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId)
)
);
const addresses = [
...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32`
),
// reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(
addresses,
32,
org.utilitySubnet
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr
subnet = subnet.split("/")[0];
return subnet;
}
if (!org.utilitySubnet) {
throw new Error(
`Organization with ID ${orgId} has no utility subnet defined`
);
}
const existingAddresses = await trx
.select({
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId)
)
);
const addresses = [
...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32`
),
// reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr
subnet = subnet.split("/")[0];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
);
}
export async function getNextAvailableOrgSubnet(): Promise<{
value: string;
release: () => Promise<void>;
}> {
const lockKey = "org-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
export async function getNextAvailableOrgSubnet(): Promise<string> {
return await lockManager.withLock("org-subnet-allocation", async () => {
const existingAddresses = await db
.select({
subnet: orgs.subnet
@@ -484,11 +466,8 @@ export async function getNextAvailableOrgSubnet(): Promise<{
throw new Error("No available subnets remaining in space");
}
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
return subnet;
});
}
export function generateRemoteSubnets(
@@ -496,15 +475,13 @@ export function generateRemoteSubnets(
): string[] {
const remoteSubnets = allSiteResources
.filter((sr) => {
if (!sr.destination) return false;
if (sr.mode === "cidr") {
// check if its a valid CIDR using zod
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
const parseResult = cidrSchema.safeParse(sr.destination);
return parseResult.success;
}
if (sr.mode === "host" || sr.mode === "ssh") {
if (sr.mode === "host") {
// check if its a valid IP using zod
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
const parseResult = ipSchema.safeParse(sr.destination);
@@ -514,12 +491,12 @@ export function generateRemoteSubnets(
})
.map((sr) => {
if (sr.mode === "cidr") return sr.destination;
if (sr.mode === "host" || sr.mode === "ssh") {
if (sr.mode === "host") {
return `${sr.destination}/32`;
}
return ""; // This should never be reached due to filtering, but satisfies TypeScript
})
.filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe
// remove duplicates
return Array.from(new Set(remoteSubnets));
}
@@ -531,7 +508,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
.filter(
(sr) =>
sr.aliasAddress &&
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
((sr.alias && sr.mode == "host") ||
(sr.fullDomain && sr.mode == "http"))
)
.map((sr) => ({
@@ -577,10 +554,6 @@ export function generateSubnetProxyTargets(
continue;
}
if (!siteResource.destination) {
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -588,7 +561,7 @@ export function generateSubnetProxyTargets(
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
if (siteResource.mode == "host") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -608,7 +581,7 @@ export function generateSubnetProxyTargets(
targets.push({
sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination!,
rewriteTo: destination,
portRange,
disableIcmp
});
@@ -616,7 +589,7 @@ export function generateSubnetProxyTargets(
} else if (siteResource.mode == "cidr") {
targets.push({
sourcePrefix: clientPrefix,
destPrefix: siteResource.destination!,
destPrefix: siteResource.destination,
portRange,
disableIcmp
});
@@ -669,12 +642,7 @@ export async function generateSubnetProxyTargetV2(
return;
}
if (!siteResource.destination) {
// ssh can have no destination
return;
}
const targets: SubnetProxyTargetV2[] = [];
let targets: SubnetProxyTargetV2[] = [];
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -682,7 +650,7 @@ export async function generateSubnetProxyTargetV2(
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
if (siteResource.mode == "host") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -703,7 +671,7 @@ export async function generateSubnetProxyTargetV2(
targets.push({
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination!,
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
@@ -712,7 +680,7 @@ export async function generateSubnetProxyTargetV2(
} else if (siteResource.mode == "cidr") {
targets.push({
sourcePrefixes: [],
destPrefix: siteResource.destination!,
destPrefix: siteResource.destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
@@ -770,7 +738,7 @@ export async function generateSubnetProxyTargetV2(
protocol: siteResource.ssl ? "https" : "http",
httpTargets: [
{
destAddr: siteResource.destination!,
destAddr: siteResource.destination,
destPort: siteResource.destinationPort,
scheme: siteResource.scheme
}
@@ -905,13 +873,7 @@ export const portRangeStringSchema = z
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
}
)
.openapi({
type: "string",
description:
'Port range string. Use "*" for all ports, a comma-separated list of ports, or ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.',
example: "80,443,8000-9000"
});
);
/**
* Parses a port range string into an array of port range objects
+18 -138
View File
@@ -1,87 +1,28 @@
import { randomUUID } from "crypto";
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager {
private clearExpiredLocalLock(lockKey: string): void {
const current = localLocks.get(lockKey);
if (current && current.expiresAt <= Date.now()) {
localLocks.delete(lockKey);
}
}
/**
* Acquire a local in-process lock using an optimistic Map-based check.
* Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock
* @param ttlMs - Time to live in milliseconds
* @returns Promise<string | null> - a token identifying this specific acquisition
* (truthy) on success, or null if the lock could not be acquired.
* @returns Promise<boolean> - true if lock acquired, false otherwise
*/
async acquireLock(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 3,
retryDelayMs: number = 100
): Promise<string | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
const token = `${instanceId}:${randomUUID()}`;
localLocks.set(lockKey, {
owner: token,
expiresAt: Date.now() + ttlMs
});
return token;
}
// The lock is currently held -- possibly by a different, unrelated
// caller in this same process. We intentionally do NOT treat
// same-process holders as automatically reentrant here: two
// independent logical operations (e.g. two different API requests)
// running concurrently in the same process must not both believe
// they hold the lock, or their writes under it can interleave
// unguarded. Just retry with backoff like any other contended lock.
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return null;
ttlMs: number = 30000
): Promise<boolean> {
return true;
}
/**
* Release a lock previously acquired via acquireLock/acquireLockWithRetry.
* Release a lock using Lua script to ensure atomicity
* @param lockKey - Unique identifier for the lock
* @param token - the exact token returned by the acquisition being released.
* Required so a caller whose TTL already expired can't delete a
* different, currently-active holder's lock.
*/
async releaseLock(lockKey: string, token: string): Promise<void> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === token) {
localLocks.delete(lockKey);
}
}
async releaseLock(lockKey: string): Promise<void> {}
/**
* Force release a lock regardless of owner (use with caution)
* @param lockKey - Unique identifier for the lock
*/
async forceReleaseLock(lockKey: string): Promise<void> {
localLocks.delete(lockKey);
}
async forceReleaseLock(lockKey: string): Promise<void> {}
/**
* Check if a lock exists and get its info
@@ -94,44 +35,16 @@ export class LockManager {
ttl: number;
owner?: string;
}> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
return { exists: false, ownedByMe: false, ttl: 0 };
}
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
ttl,
owner: existing.owner.split(":")[0]
};
return { exists: true, ownedByMe: true, ttl: 0 };
}
/**
* Extend the TTL of an existing lock, provided the token matches the
* acquisition currently holding it.
* Extend the TTL of an existing lock owned by this worker
* @param lockKey - Unique identifier for the lock
* @param ttlMs - New TTL in milliseconds
* @param token - the token returned by the acquisition being extended
* @returns Promise<boolean> - true if extended successfully
*/
async extendLock(
lockKey: string,
ttlMs: number,
token: string
): Promise<boolean> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== token) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
return true;
}
@@ -141,34 +54,15 @@ export class LockManager {
* @param ttlMs - Time to live in milliseconds
* @param maxRetries - Maximum number of retry attempts
* @param baseDelayMs - Base delay between retries in milliseconds
* @returns Promise<string | null> - token if acquired, null otherwise
* @returns Promise<boolean> - true if lock acquired
*/
async acquireLockWithRetry(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<string | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) {
return acquired;
}
if (attempt < maxRetries) {
const delay =
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return null;
): Promise<boolean> {
return true;
}
/**
@@ -183,16 +77,16 @@ export class LockManager {
fn: () => Promise<T>,
ttlMs: number = 30000
): Promise<T> {
const token = await this.acquireLock(lockKey, ttlMs);
const acquired = await this.acquireLock(lockKey, ttlMs);
if (!token) {
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
try {
return await fn();
} finally {
await this.releaseLock(lockKey, token);
await this.releaseLock(lockKey);
}
}
@@ -205,21 +99,7 @@ export class LockManager {
activeLocksCount: number;
locksOwnedByMe: number;
}> {
const now = Date.now();
for (const [key, value] of localLocks.entries()) {
if (value.expiresAt <= now) {
localLocks.delete(key);
}
}
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner.startsWith(`${instanceId}:`)) {
locksOwnedByMe++;
}
}
return { activeLocksCount: localLocks.size, locksOwnedByMe };
return { activeLocksCount: 0, locksOwnedByMe: 0 };
}
/**

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