Compare commits

..

1 Commits

Author SHA1 Message Date
Owen 49001f6dda Fix not detecting rc release in sign and package 2026-01-21 15:45:20 -08:00
192 changed files with 6014 additions and 5357 deletions
+12 -2
View File
@@ -44,9 +44,19 @@ updates:
schedule: schedule:
interval: "daily" interval: "daily"
groups: groups:
patch-updates: dev-patch-updates:
dependency-type: "development"
update-types: update-types:
- "patch" - "patch"
minor-updates: dev-minor-updates:
dependency-type: "development"
update-types: update-types:
- "minor" - "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
+6 -69
View File
@@ -442,7 +442,7 @@ jobs:
# Determine if this is an RC release # Determine if this is an RC release
IS_RC="false" IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then if echo "$TAG" | grep -qE "rc[0-9]+$"; then
IS_RC="true" IS_RC="true"
fi fi
@@ -482,77 +482,14 @@ jobs:
echo "==> cosign sign (key) --recursive ${REF}" echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}" echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}" echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then cosign verify \
VERIFIED_INDEX_KEYLESS=true --certificate-oidc-issuer "${issuer}" \
else --certificate-identity-regexp "${id_regex}" \
VERIFIED_INDEX_KEYLESS=false "${REF}" -o text
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
fi
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 10
fi
fi
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
done done
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24' node-version: '22'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+8 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24' node-version: '22'
- name: Copy config file - name: Copy config file
run: cp config/config.example.yml config/config.yml run: cp config/config.example.yml config/config.yml
@@ -34,7 +34,7 @@ jobs:
run: npm run set:oss run: npm run set:oss
- name: Generate database migrations - name: Generate database migrations
run: npm run db:generate run: npm run db:sqlite:generate
- name: Apply database migrations - name: Apply database migrations
run: npm run db:sqlite:push run: npm run db:sqlite:push
@@ -64,6 +64,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Copy config file
run: cp config/config.example.yml config/config.yml
- name: Build Docker image sqlite - name: Build Docker image sqlite
run: make dev-build-sqlite run: make dev-build-sqlite
@@ -73,5 +76,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Copy config file
run: cp config/config.example.yml config/config.yml
- name: Build Docker image pg - name: Build Docker image pg
run: make dev-build-pg run: make dev-build-pg
+3 -3
View File
@@ -4,13 +4,13 @@
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -19,4 +19,4 @@
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"editor.formatOnSave": true "editor.formatOnSave": true
} }
+50 -29
View File
@@ -1,43 +1,63 @@
FROM node:24-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app
ARG BUILD=oss
ARG DATABASE=sqlite
RUN apk add --no-cache python3 make g++
# COPY package.json package-lock.json ./
COPY package*.json ./
RUN npm ci
COPY . .
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run set:$DATABASE && \
npm run set:$BUILD && \
npm run db:generate && \
npm run build && \
npm run build:cli
# test to make sure the build output is there and error if not
RUN test -f dist/server.mjs
# Prune dev dependencies and clean up to prepare for copy to runner
RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-alpine AS runner
# OCI Image Labels - Build Args for dynamic values # OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev" ARG VERSION="dev"
ARG REVISION="" ARG REVISION=""
ARG CREATED="" ARG CREATED=""
ARG LICENSE="AGPL-3.0" ARG LICENSE="AGPL-3.0"
WORKDIR /app
ARG BUILD=oss
ARG DATABASE=sqlite
# Derive title and description based on BUILD type # Derive title and description based on BUILD type
ARG IMAGE_TITLE="Pangolin" ARG IMAGE_TITLE="Pangolin"
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
RUN apk add --no-cache curl tzdata python3 make g++
# COPY package.json package-lock.json ./
COPY package*.json ./
RUN npm ci
COPY . .
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
# Copy the appropriate TypeScript configuration based on build type
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
fi
# if the build is oss then remove the server/private directory
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
RUN mkdir -p dist
RUN npm run next:build
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
RUN if [ "$DATABASE" = "pg" ]; then \
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
else \
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
fi
# test to make sure the build output is there and error if not
RUN test -f dist/server.mjs
RUN npm run build:cli
# Prune dev dependencies and clean up to prepare for copy to runner
RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
# Only curl and tzdata needed at runtime - no build tools! # Only curl and tzdata needed at runtime - no build tools!
@@ -46,10 +66,11 @@ RUN apk add --no-cache curl tzdata
# Copy pre-built node_modules from builder (already pruned to production only) # Copy pre-built node_modules from builder (already pruned to production only)
# This includes the compiled native modules like better-sqlite3 # This includes the compiled native modules like better-sqlite3
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server/migrations ./dist/init COPY --from=builder /app/init ./dist/init
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY ./cli/wrapper.sh /usr/local/bin/pangctl COPY ./cli/wrapper.sh /usr/local/bin/pangctl
-8
View File
@@ -35,12 +35,6 @@
</div> </div>
<p align="center">
<a href="https://docs.pangolin.net/careers/join-us">
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
</a>
</p>
<p align="center"> <p align="center">
<strong> <strong>
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a> Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
@@ -80,8 +74,6 @@ Download the Pangolin client for your platform:
- [Mac](https://pangolin.net/downloads/mac) - [Mac](https://pangolin.net/downloads/mac)
- [Windows](https://pangolin.net/downloads/windows) - [Windows](https://pangolin.net/downloads/windows)
- [Linux](https://pangolin.net/downloads/linux) - [Linux](https://pangolin.net/downloads/linux)
- [iOS](https://pangolin.net/downloads/ios)
- [Android](https://pangolin.net/downloads/android)
## Get Started ## Get Started
+72
View File
@@ -0,0 +1,72 @@
import requests
import yaml
import json
import base64
# The file path for the YAML file to be read
# You can change this to the path of your YAML file
YAML_FILE_PATH = 'blueprint.yaml'
# The API endpoint and headers from the curl request
API_URL = 'http://api.pangolin.net/v1/org/test/blueprint'
HEADERS = {
'accept': '*/*',
'Authorization': 'Bearer <your_token_here>',
'Content-Type': 'application/json'
}
def convert_and_send(file_path, url, headers):
"""
Reads a YAML file, converts its content to a JSON payload,
and sends it via a PUT request to a specified URL.
"""
try:
# Read the YAML file content
with open(file_path, 'r') as file:
yaml_content = file.read()
# Parse the YAML string to a Python dictionary
# This will be used to ensure the YAML is valid before sending
parsed_yaml = yaml.safe_load(yaml_content)
# convert the parsed YAML to a JSON string
json_payload = json.dumps(parsed_yaml)
print("Converted JSON payload:")
print(json_payload)
# Encode the JSON string to Base64
encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
# Create the final payload with the base64 encoded data
final_payload = {
"blueprint": encoded_json
}
print("Sending the following Base64 encoded JSON payload:")
print(final_payload)
print("-" * 20)
# Make the PUT request with the base64 encoded payload
response = requests.put(url, headers=headers, json=final_payload)
# Print the API response for debugging
print(f"API Response Status Code: {response.status_code}")
print("API Response Content:")
print(response.text)
# Raise an exception for bad status codes (4xx or 5xx)
response.raise_for_status()
except FileNotFoundError:
print(f"Error: The file '{file_path}' was not found.")
except yaml.YAMLError as e:
print(f"Error parsing YAML file: {e}")
except requests.exceptions.RequestException as e:
print(f"An error occurred during the API request: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
# Run the function
if __name__ == "__main__":
convert_and_send(YAML_FILE_PATH, API_URL, HEADERS)
+70
View File
@@ -0,0 +1,70 @@
client-resources:
client-resource-nice-id-uno:
name: this is my resource
protocol: tcp
proxy-port: 3001
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
client-resource-nice-id-duce:
name: this is my resource
protocol: udp
proxy-port: 3000
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
proxy-resources:
resource-nice-id-uno:
name: this is my resource
protocol: http
full-domain: duce.test.example.com
host-header: example.com
tls-server-name: example.com
# auth:
# pincode: 123456
# password: sadfasdfadsf
# sso-enabled: true
# sso-roles:
# - Member
# sso-users:
# - owen@pangolin.net
# whitelist-users:
# - owen@pangolin.net
# auto-login-idp: 1
headers:
- name: X-Example-Header
value: example-value
- name: X-Another-Header
value: another-value
rules:
- action: allow
match: ip
value: 1.1.1.1
- action: deny
match: cidr
value: 2.2.2.2/32
- action: pass
match: path
value: /admin
targets:
- site: lively-yosemite-toad
path: /path
pathMatchType: prefix
hostname: localhost
method: http
port: 8000
- site: slim-alpine-chipmunk
hostname: localhost
path: /yoman
pathMatchType: exact
method: http
port: 8001
resource-nice-id-duce:
name: this is other resource
protocol: tcp
proxy-port: 3000
targets:
- site: lively-yosemite-toad
hostname: localhost
port: 3000
+2 -2
View File
@@ -7,8 +7,8 @@ services:
POSTGRES_DB: postgres # Default database name POSTGRES_DB: postgres # Default database name
POSTGRES_USER: postgres # Default user POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: password # Default password (change for production!) POSTGRES_PASSWORD: password # Default password (change for production!)
# volumes: volumes:
# - ./config/postgres:/var/lib/postgresql/data - ./config/postgres:/var/lib/postgresql/data
ports: ports:
- "5432:5432" # Map host port 5432 to container port 5432 - "5432:5432" # Map host port 5432 to container port 5432
restart: no restart: no
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from "drizzle-kit";
import path from "path";
const schema = [path.join("server", "db", "pg", "schema")];
export default defineConfig({
dialect: "postgresql",
schema: schema,
out: path.join("server", "migrations"),
verbose: true,
dbCredentials: {
url: process.env.DATABASE_URL as string
}
});
+1 -7
View File
@@ -6,12 +6,6 @@ import path from "path";
import fs from "fs"; import fs from "fs";
// import { glob } from "glob"; // import { glob } from "glob";
// Read default build type from server/build.ts
let build = "oss";
const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8");
const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/);
if (m) build = m[1];
const banner = ` const banner = `
// patch __dirname // patch __dirname
// import { fileURLToPath } from "url"; // import { fileURLToPath } from "url";
@@ -43,7 +37,7 @@ const argv = yargs(hideBin(process.argv))
describe: "Build type (oss, saas, enterprise)", describe: "Build type (oss, saas, enterprise)",
type: "string", type: "string",
choices: ["oss", "saas", "enterprise"], choices: ["oss", "saas", "enterprise"],
default: build default: "oss"
}) })
.help() .help()
.alias("help", "h").argv; .alias("help", "h").argv;
+11 -7
View File
@@ -6,8 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"crypto/rand" "math/rand"
"encoding/base64"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -593,12 +592,17 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
} }
func generateRandomSecretKey() string { func generateRandomSecretKey() string {
secret := make([]byte, 32) const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
_, err := rand.Read(secret) const length = 32
if err != nil {
panic(fmt.Sprintf("Failed to generate random secret key: %v", err)) var seededRand *rand.Rand = rand.New(
rand.NewSource(time.Now().UnixNano()))
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
} }
return base64.StdEncoding.EncodeToString(secret) return string(b)
} }
func getPublicIP() string { func getPublicIP() string {
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Активирана защитна стена.", "firewallEnabled": "Активирана защитна стена.",
"autoUpdatesEnabled": "Активирани автоматични актуализации.", "autoUpdatesEnabled": "Активирани автоматични актуализации.",
"tpmAvailable": "TPM е на разположение.", "tpmAvailable": "TPM е на разположение.",
"windowsDefenderEnabled": "Windows Defender е активиран.",
"macosSipEnabled": "Protection на системната цялост (SIP).", "macosSipEnabled": "Protection на системната цялост (SIP).",
"macosGatekeeperEnabled": "Gatekeeper.", "macosGatekeeperEnabled": "Gatekeeper.",
"macosFirewallStealthMode": "Скрит режим на защитната стена.", "macosFirewallStealthMode": "Скрит режим на защитната стена.",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Firewall povolen", "firewallEnabled": "Firewall povolen",
"autoUpdatesEnabled": "Automatické aktualizace povoleny", "autoUpdatesEnabled": "Automatické aktualizace povoleny",
"tpmAvailable": "TPM k dispozici", "tpmAvailable": "TPM k dispozici",
"windowsDefenderEnabled": "Okna byla povolena",
"macosSipEnabled": "Ochrana systémové integrity (SIP)", "macosSipEnabled": "Ochrana systémové integrity (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Režim neviditelnosti firewallu", "macosFirewallStealthMode": "Režim neviditelnosti firewallu",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Firewall aktiviert", "firewallEnabled": "Firewall aktiviert",
"autoUpdatesEnabled": "Automatische Updates aktiviert", "autoUpdatesEnabled": "Automatische Updates aktiviert",
"tpmAvailable": "TPM verfügbar", "tpmAvailable": "TPM verfügbar",
"windowsDefenderEnabled": "Windows Defender aktiviert",
"macosSipEnabled": "Schutz der Systemintegrität (SIP)", "macosSipEnabled": "Schutz der Systemintegrität (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth-Modus", "macosFirewallStealthMode": "Firewall Stealth-Modus",
+14 -70
View File
@@ -55,7 +55,7 @@
"siteDescription": "Create and manage sites to enable connectivity to private networks", "siteDescription": "Create and manage sites to enable connectivity to private networks",
"sitesBannerTitle": "Connect Any Network", "sitesBannerTitle": "Connect Any Network",
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.", "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
"sitesBannerButtonText": "Install Site Connector", "sitesBannerButtonText": "Install Site",
"approvalsBannerTitle": "Approve or Deny Device Access", "approvalsBannerTitle": "Approve or Deny Device Access",
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.", "approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
"approvalsBannerButtonText": "Learn More", "approvalsBannerButtonText": "Learn More",
@@ -79,8 +79,8 @@
"siteConfirmCopy": "I have copied the config", "siteConfirmCopy": "I have copied the config",
"searchSitesProgress": "Search sites...", "searchSitesProgress": "Search sites...",
"siteAdd": "Add Site", "siteAdd": "Add Site",
"siteInstallNewt": "Install Site", "siteInstallNewt": "Install Newt",
"siteInstallNewtDescription": "Install the site connector for your system", "siteInstallNewtDescription": "Get Newt running on your system",
"WgConfiguration": "WireGuard Configuration", "WgConfiguration": "WireGuard Configuration",
"WgConfigurationDescription": "Use the following configuration to connect to the network", "WgConfigurationDescription": "Use the following configuration to connect to the network",
"operatingSystem": "Operating System", "operatingSystem": "Operating System",
@@ -1404,10 +1404,10 @@
"billingUsageLimitsOverview": "Usage Limits Overview", "billingUsageLimitsOverview": "Usage Limits Overview",
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
"billingDataUsage": "Data Usage", "billingDataUsage": "Data Usage",
"billingSites": "Sites", "billingOnlineTime": "Site Online Time",
"billingUsers": "Users", "billingUsers": "Active Users",
"billingDomains": "Domains", "billingDomains": "Active Domains",
"billingRemoteExitNodes": "Remote Nodes", "billingRemoteExitNodes": "Active Self-hosted Nodes",
"billingNoLimitConfigured": "No limit configured", "billingNoLimitConfigured": "No limit configured",
"billingEstimatedPeriod": "Estimated Billing Period", "billingEstimatedPeriod": "Estimated Billing Period",
"billingIncludedUsage": "Included Usage", "billingIncludedUsage": "Included Usage",
@@ -1432,18 +1432,10 @@
"billingFailedToGetPortalUrl": "Failed to get portal URL", "billingFailedToGetPortalUrl": "Failed to get portal URL",
"billingPortalError": "Portal Error", "billingPortalError": "Portal Error",
"billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.",
"billingSInfo": "How many sites you can use", "billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.",
"billingUsersInfo": "How many users you can use", "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.",
"billingDomainInfo": "How many domains you can use", "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.",
"billingRemoteExitNodesInfo": "How many remote nodes you can use", "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.",
"billingLicenseKeys": "License Keys",
"billingLicenseKeysDescription": "Manage your license key subscriptions",
"billingLicenseSubscription": "License Subscription",
"billingInactive": "Inactive",
"billingLicenseItem": "License Item",
"billingQuantity": "Quantity",
"billingTotal": "total",
"billingModifyLicenses": "Modify License Subscription",
"domainNotFound": "Domain Not Found", "domainNotFound": "Domain Not Found",
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
"failed": "Failed", "failed": "Failed",
@@ -1520,27 +1512,6 @@
"resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortRequired": "Port number is required for non-HTTP resources",
"resourcePortNotAllowed": "Port number should not be set for HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources",
"billingPricingCalculatorLink": "Pricing Calculator", "billingPricingCalculatorLink": "Pricing Calculator",
"billingYourPlan": "Your Plan",
"billingViewOrModifyPlan": "View or modify your current plan",
"billingViewPlanDetails": "View Plan Details",
"billingUsageAndLimits": "Usage and Limits",
"billingViewUsageAndLimits": "View your plan's limits and current usage",
"billingCurrentUsage": "Current Usage",
"billingMaximumLimits": "Maximum Limits",
"billingRemoteNodes": "Remote Nodes",
"billingUnlimited": "Unlimited",
"billingPaidLicenseKeys": "Paid License Keys",
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
"billingCurrentKeys": "Current Keys",
"billingModifyCurrentPlan": "Modify Current Plan",
"billingConfirmUpgrade": "Confirm Upgrade",
"billingConfirmDowngrade": "Confirm Downgrade",
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
"billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.",
"billingPlanIncludes": "Plan Includes",
"billingProcessing": "Processing...",
"billingConfirmUpgradeButton": "Confirm Upgrade",
"billingConfirmDowngradeButton": "Confirm Downgrade",
"signUpTerms": { "signUpTerms": {
"IAgreeToThe": "I agree to the", "IAgreeToThe": "I agree to the",
"termsOfService": "terms of service", "termsOfService": "terms of service",
@@ -1565,8 +1536,8 @@
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
"selectSites": "Select sites", "selectSites": "Select sites",
"sitesDescription": "The client will have connectivity to the selected sites", "sitesDescription": "The client will have connectivity to the selected sites",
"clientInstallOlm": "Install Machine Client", "clientInstallOlm": "Install Olm",
"clientInstallOlmDescription": "Install the machine client for your system", "clientInstallOlmDescription": "Get Olm running on your system",
"clientOlmCredentials": "Credentials", "clientOlmCredentials": "Credentials",
"clientOlmCredentialsDescription": "This is how the client will authenticate with the server", "clientOlmCredentialsDescription": "This is how the client will authenticate with the server",
"olmEndpoint": "Endpoint", "olmEndpoint": "Endpoint",
@@ -2142,32 +2113,6 @@
} }
} }
}, },
"newPricingLicenseForm": {
"title": "Get a license",
"description": "Choose a plan and tell us how you plan to use Pangolin.",
"chooseTier": "Choose your plan",
"viewPricingLink": "See pricing, features, and limits",
"tiers": {
"starter": {
"title": "Starter",
"description": "Enterprise features, 25 users, 25 sites, and community support."
},
"scale": {
"title": "Scale",
"description": "Enterprise features, 50 users, 50 sites, and priority support."
}
},
"personalUseOnly": "Personal use only (free license — no checkout)",
"buttons": {
"continueToCheckout": "Continue to Checkout"
},
"toasts": {
"checkoutError": {
"title": "Checkout error",
"description": "Could not start checkout. Please try again."
}
}
},
"priority": "Priority", "priority": "Priority",
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.", "priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
"instanceName": "Instance Name", "instanceName": "Instance Name",
@@ -2267,7 +2212,6 @@
"actionLogsDescription": "View a history of actions performed in this organization", "actionLogsDescription": "View a history of actions performed in this organization",
"accessLogsDescription": "View access auth requests for resources in this organization", "accessLogsDescription": "View access auth requests for resources in this organization",
"licenseRequiredToUse": "An Enterprise license is required to use this feature.", "licenseRequiredToUse": "An Enterprise license is required to use this feature.",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature.",
"certResolver": "Certificate Resolver", "certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.", "certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver", "selectCertResolver": "Select Certificate Resolver",
@@ -2566,7 +2510,7 @@
"firewallEnabled": "Firewall Enabled", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Auto Updates Enabled", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM Available", "tpmAvailable": "TPM Available",
"windowsAntivirusEnabled": "Antivirus Enabled", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)", "macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth Mode", "macosFirewallStealthMode": "Firewall Stealth Mode",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Cortafuegos activado", "firewallEnabled": "Cortafuegos activado",
"autoUpdatesEnabled": "Actualizaciones automáticas habilitadas", "autoUpdatesEnabled": "Actualizaciones automáticas habilitadas",
"tpmAvailable": "TPM disponible", "tpmAvailable": "TPM disponible",
"windowsDefenderEnabled": "Windows Defender activado",
"macosSipEnabled": "Protección de integridad del sistema (SIP)", "macosSipEnabled": "Protección de integridad del sistema (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Sigilo Firewall", "macosFirewallStealthMode": "Modo Sigilo Firewall",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Pare-feu activé", "firewallEnabled": "Pare-feu activé",
"autoUpdatesEnabled": "Mises à jour automatiques activées", "autoUpdatesEnabled": "Mises à jour automatiques activées",
"tpmAvailable": "TPM disponible", "tpmAvailable": "TPM disponible",
"windowsDefenderEnabled": "Windows Defender activé",
"macosSipEnabled": "Protection contre l'intégrité du système (SIP)", "macosSipEnabled": "Protection contre l'intégrité du système (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Mode furtif du pare-feu", "macosFirewallStealthMode": "Mode furtif du pare-feu",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Firewall Abilitato", "firewallEnabled": "Firewall Abilitato",
"autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati", "autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati",
"tpmAvailable": "TPM Disponibile", "tpmAvailable": "TPM Disponibile",
"windowsDefenderEnabled": "Windows Defender Abilitato",
"macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)", "macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo Del Firewall", "macosFirewallStealthMode": "Modo Furtivo Del Firewall",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "방화벽 활성화", "firewallEnabled": "방화벽 활성화",
"autoUpdatesEnabled": "자동 업데이트 활성화", "autoUpdatesEnabled": "자동 업데이트 활성화",
"tpmAvailable": "TPM 사용 가능", "tpmAvailable": "TPM 사용 가능",
"windowsDefenderEnabled": "Windows Defender 활성화",
"macosSipEnabled": "시스템 무결성 보호 (SIP)", "macosSipEnabled": "시스템 무결성 보호 (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "방화벽 스텔스 모드", "macosFirewallStealthMode": "방화벽 스텔스 모드",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Brannmur aktivert", "firewallEnabled": "Brannmur aktivert",
"autoUpdatesEnabled": "Automatiske oppdateringer aktivert", "autoUpdatesEnabled": "Automatiske oppdateringer aktivert",
"tpmAvailable": "TPM tilgjengelig", "tpmAvailable": "TPM tilgjengelig",
"windowsDefenderEnabled": "Windows svarer aktivert",
"macosSipEnabled": "System Integritetsbeskyttelse (SIP)", "macosSipEnabled": "System Integritetsbeskyttelse (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Brannmur Usynlig Modus", "macosFirewallStealthMode": "Brannmur Usynlig Modus",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Firewall ingeschakeld", "firewallEnabled": "Firewall ingeschakeld",
"autoUpdatesEnabled": "Auto Updates Ingeschakeld", "autoUpdatesEnabled": "Auto Updates Ingeschakeld",
"tpmAvailable": "TPM beschikbaar", "tpmAvailable": "TPM beschikbaar",
"windowsDefenderEnabled": "Windows Verdediger ingeschakeld",
"macosSipEnabled": "Systeemintegriteitsbescherming (SIP)", "macosSipEnabled": "Systeemintegriteitsbescherming (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Verberg Modus", "macosFirewallStealthMode": "Firewall Verberg Modus",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Zapora włączona", "firewallEnabled": "Zapora włączona",
"autoUpdatesEnabled": "Automatyczne aktualizacje włączone", "autoUpdatesEnabled": "Automatyczne aktualizacje włączone",
"tpmAvailable": "TPM dostępne", "tpmAvailable": "TPM dostępne",
"windowsDefenderEnabled": "Obrońca Windows włączony",
"macosSipEnabled": "Ochrona integralności systemu (SIP)", "macosSipEnabled": "Ochrona integralności systemu (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Tryb Stealth zapory", "macosFirewallStealthMode": "Tryb Stealth zapory",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Firewall habilitado", "firewallEnabled": "Firewall habilitado",
"autoUpdatesEnabled": "Atualizações Automáticas Habilitadas", "autoUpdatesEnabled": "Atualizações Automáticas Habilitadas",
"tpmAvailable": "TPM disponível", "tpmAvailable": "TPM disponível",
"windowsDefenderEnabled": "Defensor do Windows habilitado",
"macosSipEnabled": "Proteção da Integridade do Sistema (SIP)", "macosSipEnabled": "Proteção da Integridade do Sistema (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo do Firewall", "macosFirewallStealthMode": "Modo Furtivo do Firewall",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Брандмауэр включен", "firewallEnabled": "Брандмауэр включен",
"autoUpdatesEnabled": "Автоматические обновления включены", "autoUpdatesEnabled": "Автоматические обновления включены",
"tpmAvailable": "Доступно TPM", "tpmAvailable": "Доступно TPM",
"windowsDefenderEnabled": "Защитник Windows включен",
"macosSipEnabled": "Защита целостности системы (SIP)", "macosSipEnabled": "Защита целостности системы (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Стилс-режим брандмауэра", "macosFirewallStealthMode": "Стилс-режим брандмауэра",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "Güvenlik Duvarı Etkin", "firewallEnabled": "Güvenlik Duvarı Etkin",
"autoUpdatesEnabled": "Otomatik Güncellemeler Etkin", "autoUpdatesEnabled": "Otomatik Güncellemeler Etkin",
"tpmAvailable": "TPM Mevcut", "tpmAvailable": "TPM Mevcut",
"windowsDefenderEnabled": "Windows Defender Etkin",
"macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)", "macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu", "macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu",
+1
View File
@@ -2510,6 +2510,7 @@
"firewallEnabled": "防火墙已启用", "firewallEnabled": "防火墙已启用",
"autoUpdatesEnabled": "启用自动更新", "autoUpdatesEnabled": "启用自动更新",
"tpmAvailable": "TPM 可用", "tpmAvailable": "TPM 可用",
"windowsDefenderEnabled": "Windows Defender 已启用",
"macosSipEnabled": "系统完整性保护 (SIP)", "macosSipEnabled": "系统完整性保护 (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "防火墙隐形模式", "macosFirewallStealthMode": "防火墙隐形模式",
+2164 -30
View File
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -12,26 +12,24 @@
"license": "SEE LICENSE IN LICENSE AND README.md", "license": "SEE LICENSE IN LICENSE AND README.md",
"scripts": { "scripts": {
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts", "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts", "db:pg:push": "npx tsx server/db/pg/migrate.ts",
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts", "db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
"db:clear-migrations": "rm -rf server/migrations", "db:clear-migrations": "rm -rf server/migrations",
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json", "set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json", "set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", "set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts", "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts", "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
"build:next": "next build", "next:build": "next build",
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
"email": "email dev --dir server/emails/templates --port 3005", "email": "email dev --dir server/emails/templates --port 3005",
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
"format:check": "prettier --check .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
@@ -77,7 +75,9 @@
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
"cookie": "1.1.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cookies": "0.9.1",
"cors": "2.8.5", "cors": "2.8.5",
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"d3": "7.9.0", "d3": "7.9.0",
@@ -90,6 +90,7 @@
"glob": "13.0.0", "glob": "13.0.0",
"helmet": "8.1.0", "helmet": "8.1.0",
"http-errors": "2.0.1", "http-errors": "2.0.1",
"i": "0.3.7",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.9.2", "ioredis": "5.9.2",
"jmespath": "0.16.0", "jmespath": "0.16.0",
@@ -103,7 +104,10 @@
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "7.0.11", "nodemailer": "7.0.11",
"npm": "11.7.0",
"nprogress": "0.2.0",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.17.1", "pg": "8.17.1",
"posthog-node": "5.23.0", "posthog-node": "5.23.0",
@@ -114,6 +118,7 @@
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.71.1", "react-hook-form": "7.71.1",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"rebuild": "0.1.2",
"recharts": "2.15.4", "recharts": "2.15.4",
"reodotdev": "1.0.0", "reodotdev": "1.0.0",
"resend": "6.8.0", "resend": "6.8.0",
+1 -5
View File
@@ -82,14 +82,11 @@ export const subscriptions = pgTable("subscriptions", {
canceledAt: bigint("canceledAt", { mode: "number" }), canceledAt: bigint("canceledAt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }), updatedAt: bigint("updatedAt", { mode: "number" }),
version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" })
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
}); });
export const subscriptionItems = pgTable("subscriptionItems", { export const subscriptionItems = pgTable("subscriptionItems", {
subscriptionItemId: serial("subscriptionItemId").primaryKey(), subscriptionItemId: serial("subscriptionItemId").primaryKey(),
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }),
subscriptionId: varchar("subscriptionId", { length: 255 }) subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull() .notNull()
.references(() => subscriptions.subscriptionId, { .references(() => subscriptions.subscriptionId, {
@@ -97,7 +94,6 @@ export const subscriptionItems = pgTable("subscriptionItems", {
}), }),
planId: varchar("planId", { length: 255 }).notNull(), planId: varchar("planId", { length: 255 }).notNull(),
priceId: varchar("priceId", { length: 255 }), priceId: varchar("priceId", { length: 255 }),
featureId: varchar("featureId", { length: 255 }),
meterId: varchar("meterId", { length: 255 }), meterId: varchar("meterId", { length: 255 }),
unitAmount: real("unitAmount"), unitAmount: real("unitAmount"),
tiers: text("tiers"), tiers: text("tiers"),
+2 -2
View File
@@ -778,7 +778,7 @@ export const currentFingerprint = pgTable("currentFingerprint", {
// Windows-specific posture check information // Windows-specific posture check information
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled") windowsDefenderEnabled: boolean("windowsDefenderEnabled")
.notNull() .notNull()
.default(false), .default(false),
@@ -830,7 +830,7 @@ export const fingerprintSnapshots = pgTable("fingerprintSnapshots", {
// Windows-specific posture check information // Windows-specific posture check information
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled") windowsDefenderEnabled: boolean("windowsDefenderEnabled")
.notNull() .notNull()
.default(false), .default(false),
+1 -4
View File
@@ -70,9 +70,7 @@ export const subscriptions = sqliteTable("subscriptions", {
canceledAt: integer("canceledAt"), canceledAt: integer("canceledAt"),
createdAt: integer("createdAt").notNull(), createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt"), updatedAt: integer("updatedAt"),
version: integer("version"), billingCycleAnchor: integer("billingCycleAnchor")
billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // tier1, tier2, tier3, or license
}); });
export const subscriptionItems = sqliteTable("subscriptionItems", { export const subscriptionItems = sqliteTable("subscriptionItems", {
@@ -86,7 +84,6 @@ export const subscriptionItems = sqliteTable("subscriptionItems", {
}), }),
planId: text("planId").notNull(), planId: text("planId").notNull(),
priceId: text("priceId"), priceId: text("priceId"),
featureId: text("featureId"),
meterId: text("meterId"), meterId: text("meterId"),
unitAmount: real("unitAmount"), unitAmount: real("unitAmount"),
tiers: text("tiers"), tiers: text("tiers"),
+2 -2
View File
@@ -475,7 +475,7 @@ export const currentFingerprint = sqliteTable("currentFingerprint", {
// Windows-specific posture check information // Windows-specific posture check information
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", { windowsDefenderEnabled: integer("windowsDefenderEnabled", {
mode: "boolean" mode: "boolean"
}) })
.notNull() .notNull()
@@ -549,7 +549,7 @@ export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", {
// Windows-specific posture check information // Windows-specific posture check information
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", { windowsDefenderEnabled: integer("windowsDefenderEnabled", {
mode: "boolean" mode: "boolean"
}) })
.notNull() .notNull()
@@ -1,118 +0,0 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailInfoSection,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
import CopyCodeBox from "./components/CopyCodeBox";
import ButtonLink from "./components/ButtonLink";
type EnterpriseEditionKeyGeneratedProps = {
keyValue: string;
personalUseOnly: boolean;
users: number;
sites: number;
modifySubscriptionLink?: string;
};
export const EnterpriseEditionKeyGenerated = ({
keyValue,
personalUseOnly,
users,
sites,
modifySubscriptionLink
}: EnterpriseEditionKeyGeneratedProps) => {
const previewText = personalUseOnly
? "Your Enterprise Edition key for personal use is ready"
: "Thank you for your purchase — your Enterprise Edition key is ready";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
{personalUseOnly ? (
<EmailText>
Your Enterprise Edition license key has been
generated. Qualifying users can use the
Enterprise Edition for free for{" "}
<strong>personal use only</strong>.
</EmailText>
) : (
<>
<EmailText>
Thank you for your purchase. Your Enterprise
Edition license key is ready. Below are the
terms of your license.
</EmailText>
<EmailInfoSection
title="License details"
items={[
{
label: "Licensed users",
value: users
},
{
label: "Licensed sites",
value: sites
}
]}
/>
{modifySubscriptionLink && (
<EmailSection>
<ButtonLink
href={modifySubscriptionLink}
>
Modify subscription
</ButtonLink>
</EmailSection>
)}
</>
)}
<EmailSection>
<EmailText>Your license key:</EmailText>
<CopyCodeBox
text={keyValue}
hint="Copy this key and use it when activating Enterprise Edition on your Pangolin host."
/>
</EmailSection>
<EmailText>
If you need to purchase additional license keys or
modify your existing license, please reach out to
our support team at{" "}
<a
href="mailto:support@pangolin.net"
className="text-primary font-medium"
>
support@pangolin.net
</a>
.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default EnterpriseEditionKeyGenerated;
@@ -1,14 +1,6 @@
import React from "react"; import React from "react";
const DEFAULT_HINT = "Copy and paste this code when prompted"; export default function CopyCodeBox({ text }: { text: string }) {
export default function CopyCodeBox({
text,
hint
}: {
text: string;
hint?: string;
}) {
return ( return (
<div className="inline-block"> <div className="inline-block">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto"> <div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
@@ -16,7 +8,9 @@ export default function CopyCodeBox({
{text} {text}
</span> </span>
</div> </div>
<p className="text-xs text-gray-500 mt-2">{hint ?? DEFAULT_HINT}</p> <p className="text-xs text-gray-500 mt-2">
Copy and paste this code when prompted
</p>
</div> </div>
); );
} }
+5 -7
View File
@@ -105,13 +105,11 @@ function getOpenApiDocumentation() {
servers: [{ url: "/v1" }] servers: [{ url: "/v1" }]
}); });
if (!process.env.DISABLE_GEN_OPENAPI) { // convert to yaml and save to file
// convert to yaml and save to file const outputPath = path.join(APP_PATH, "openapi.yaml");
const outputPath = path.join(APP_PATH, "openapi.yaml"); const yamlOutput = yaml.dump(generated);
const yamlOutput = yaml.dump(generated); fs.writeFileSync(outputPath, yamlOutput, "utf8");
fs.writeFileSync(outputPath, yamlOutput, "utf8"); logger.info(`OpenAPI documentation saved to ${outputPath}`);
logger.info(`OpenAPI documentation saved to ${outputPath}`);
}
return generated; return generated;
} }
+44 -116
View File
@@ -1,44 +1,30 @@
import Stripe from "stripe"; import Stripe from "stripe";
import { usageService } from "./usageService";
export enum FeatureId { export enum FeatureId {
SITE_UPTIME = "siteUptime",
USERS = "users", USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb", EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains", DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes", REMOTE_EXIT_NODES = "remoteExitNodes"
TIER1 = "tier1"
} }
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> { export const FeatureMeterIds: Record<FeatureId, string> = {
switch (featureId) { [FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU",
case FeatureId.USERS: [FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro",
return "Users"; [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW",
case FeatureId.SITES: [FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU",
return "Sites"; [FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE"
case FeatureId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case FeatureId.DOMAINS:
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.TIER1:
return "Home Lab";
default:
return featureId;
}
}
// this is from the old system
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<FeatureId, string>> = { export const FeatureMeterIdsSandbox: Record<FeatureId, string> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" [FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu",
[FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au",
[FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ",
[FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts",
[FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K"
}; };
export function getFeatureMeterId(featureId: FeatureId): string | undefined { export function getFeatureMeterId(featureId: FeatureId): string {
if ( if (
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
@@ -57,103 +43,45 @@ export function getFeatureIdByMetricId(
)?.[0]; )?.[0];
} }
export type FeaturePriceSet = Partial<Record<FeatureId, string>>; export type FeaturePriceSet = {
[key in Exclude<FeatureId, FeatureId.DOMAINS>]: string;
export const homeLabFeaturePriceSet: FeaturePriceSet = { } & {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" [FeatureId.DOMAINS]?: string; // Optional since domains are not billed
}; };
export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = { export const standardFeaturePriceSet: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" // Free tier matches the freeLimitSet
[FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF",
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea",
[FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk",
// [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC",
[FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h"
}; };
export function getHomeLabFeaturePriceSet(): FeaturePriceSet { export const standardFeaturePriceSetSandbox: FeaturePriceSet = {
// Free tier matches the freeLimitSet
[FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU",
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF",
[FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0",
// [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b",
[FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL"
};
export function getStandardFeaturePriceSet(): FeaturePriceSet {
if ( if (
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
) { ) {
return homeLabFeaturePriceSet; return standardFeaturePriceSet;
} else { } else {
return homeLabFeaturePriceSetSandbox; return standardFeaturePriceSetSandbox;
} }
} }
export const tier2FeaturePriceSet: FeaturePriceSet = { export function getLineItems(
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" featurePriceSet: FeaturePriceSet
}; ): Stripe.Checkout.SessionCreateParams.LineItem[] {
return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { price: priceId
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }));
};
export function getStarterFeaturePriceSet(): FeaturePriceSet {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
) {
return tier2FeaturePriceSet;
} else {
return tier2FeaturePriceSetSandbox;
}
}
export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
export function getScaleFeaturePriceSet(): FeaturePriceSet {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
) {
return tier3FeaturePriceSet;
} else {
return tier3FeaturePriceSetSandbox;
}
}
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
// Check all feature price sets
const allPriceSets = [
getHomeLabFeaturePriceSet(),
getStarterFeaturePriceSet(),
getScaleFeaturePriceSet()
];
for (const priceSet of allPriceSets) {
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
([_, price]) => price === priceId
);
if (entry) {
return entry[0];
}
}
return undefined;
}
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
quantity = 1;
}
return {
price: priceId,
quantity: quantity
};
});
} }
-37
View File
@@ -1,37 +0,0 @@
export enum LicenseId {
SMALL_LICENSE = "small_license",
BIG_LICENSE = "big_license"
}
export type LicensePriceSet = {
[key in LicenseId]: string;
};
export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
};
export const licensePriceSetSandbox: LicensePriceSet = {
// Free license matches the freeLimitSet
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
};
export function getLicensePriceSet(
environment?: string,
sandbox_mode?: boolean
): LicensePriceSet {
if (
(process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true") ||
(environment === "prod" && sandbox_mode !== true)
) {
// THIS GETS LOADED CLIENT SIDE AND SERVER SIDE
return licensePriceSet;
} else {
return licensePriceSetSandbox;
}
}
+29 -46
View File
@@ -1,67 +1,50 @@
import { FeatureId } from "./features"; import { FeatureId } from "./features";
export type LimitSet = Partial<{ export type LimitSet = {
[key in FeatureId]: { [key in FeatureId]: {
value: number | null; // null indicates no limit value: number | null; // null indicates no limit
description?: string; description?: string;
}; };
}>; };
export const sandboxLimitSet: LimitSet = { export const sandboxLimitSet: LimitSet = {
[FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, [FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" }, [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" }
}; };
export const freeLimitSet: LimitSet = { export const freeLimitSet: LimitSet = {
[FeatureId.USERS]: { value: 5, description: "Starter limit" }, [FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days
[FeatureId.SITES]: { value: 5, description: "Starter limit" }, [FeatureId.USERS]: { value: 3, description: "Free tier limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" }, [FeatureId.EGRESS_DATA_MB]: {
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" }, value: 25000,
description: "Free tier limit"
}, // 25 GB
[FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
}; };
export const tier1LimitSet: LimitSet = { export const subscribedLimitSet: LimitSet = {
[FeatureId.USERS]: { value: 7, description: "Home limit" }, [FeatureId.SITE_UPTIME]: {
[FeatureId.SITES]: { value: 10, description: "Home limit" }, value: 2232000,
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" }, description: "Contact us to increase soft limit."
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, }, // 50 sites up for 31 days
};
export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: { [FeatureId.USERS]: {
value: 100, value: 150,
description: "Team limit" description: "Contact us to increase soft limit."
},
[FeatureId.SITES]: {
value: 50,
description: "Team limit"
}, },
[FeatureId.EGRESS_DATA_MB]: {
value: 12000000,
description: "Contact us to increase soft limit."
}, // 12000 GB
[FeatureId.DOMAINS]: { [FeatureId.DOMAINS]: {
value: 50, value: 25,
description: "Team limit" description: "Contact us to increase soft limit."
}, },
[FeatureId.REMOTE_EXIT_NODES]: { [FeatureId.REMOTE_EXIT_NODES]: {
value: 3, value: 5,
description: "Team limit" description: "Contact us to increase soft limit."
}, }
};
export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: {
value: 500,
description: "Business limit"
},
[FeatureId.SITES]: {
value: 250,
description: "Business limit"
},
[FeatureId.DOMAINS]: {
value: 100,
description: "Business limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
value: 20,
description: "Business limit"
},
}; };
-38
View File
@@ -1,38 +0,0 @@
import { Tier } from "@server/types/Tiers";
export enum TierFeature {
OrgOidc = "orgOidc",
CustomAuthenticationDomain = "customAuthenticationDomain",
DeviceApprovals = "deviceApprovals",
LoginPageBranding = "loginPageBranding",
LogExport = "logExport",
AccessLogs = "accessLogs",
ActionLogs = "actionLogs",
RotateCredentials = "rotateCredentials",
MaintencePage = "maintencePage",
DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement",
SessionDurationPolicies = "sessionDurationPolicies",
PasswordExpirationPolicies = "passwordExpirationPolicies"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.CustomAuthenticationDomain]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
[TierFeature.LoginPageBranding]: ["tier1", "tier3", "enterprise"],
[TierFeature.LogExport]: ["tier3", "enterprise"],
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
[TierFeature.TwoFactorEnforcement]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SessionDurationPolicies]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.PasswordExpirationPolicies]: ["tier1", "tier2", "tier3", "enterprise"]
};
+34
View File
@@ -0,0 +1,34 @@
export enum TierId {
STANDARD = "standard"
}
export type TierPriceSet = {
[key in TierId]: string;
};
export const tierPriceSet: TierPriceSet = {
// Free tier matches the freeLimitSet
[TierId.STANDARD]: "price_1RrQ9cD3Ee2Ir7Wmqdy3KBa0"
};
export const tierPriceSetSandbox: TierPriceSet = {
// Free tier matches the freeLimitSet
// when matching tier the keys closer to 0 index are matched first so list the tiers in descending order of value
[TierId.STANDARD]: "price_1RrAYJDCpkOb237By2s1P32m"
};
export function getTierPriceSet(
environment?: string,
sandbox_mode?: boolean
): TierPriceSet {
if (
(process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true") ||
(environment === "prod" && sandbox_mode !== true)
) {
// THIS GETS LOADED CLIENT SIDE AND SERVER SIDE
return tierPriceSet;
} else {
return tierPriceSetSandbox;
}
}
+443 -102
View File
@@ -1,6 +1,8 @@
import { eq, sql, and } from "drizzle-orm"; import { eq, sql, and } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand } from "@aws-sdk/client-s3";
import * as fs from "fs/promises";
import * as path from "path";
import { import {
db, db,
usage, usage,
@@ -18,7 +20,6 @@ import { sendToClient } from "#dynamic/routers/ws";
import { build } from "@server/build"; import { build } from "@server/build";
import { s3Client } from "@server/lib/s3"; import { s3Client } from "@server/lib/s3";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
import privateConfig from "@server/private/lib/config";
interface StripeEvent { interface StripeEvent {
identifier?: string; identifier?: string;
@@ -31,7 +32,11 @@ interface StripeEvent {
} }
export function noop() { export function noop() {
if (build !== "saas") { if (
build !== "saas" ||
!process.env.S3_BUCKET ||
!process.env.LOCAL_FILE_PATH
) {
return true; return true;
} }
return false; return false;
@@ -39,48 +44,31 @@ export function noop() {
export class UsageService { export class UsageService {
private bucketName: string | undefined; private bucketName: string | undefined;
private events: StripeEvent[] = []; private currentEventFile: string | null = null;
private lastUploadTime: number = Date.now(); private currentFileStartTime: number = 0;
private isUploading: boolean = false; private eventsDir: string | undefined;
private uploadingFiles: Set<string> = new Set();
constructor() { constructor() {
if (noop()) { if (noop()) {
return; return;
} }
// this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket;
// this.eventsDir = privateConfig.getRawPrivateConfig().stripe?.localFilePath;
this.bucketName = process.env.S3_BUCKET || undefined; this.bucketName = process.env.S3_BUCKET || undefined;
this.eventsDir = process.env.LOCAL_FILE_PATH || undefined;
if ( // Ensure events directory exists
// Only set up event uploading if usage reporting is enabled and bucket name is configured this.initializeEventsDirectory().then(() => {
privateConfig.getRawPrivateConfig().flags.usage_reporting && this.uploadPendingEventFilesOnStartup();
this.bucketName });
) {
// Periodically check and upload events
setInterval(() => {
this.checkAndUploadEvents().catch((err) => {
logger.error("Error in periodic event upload:", err);
});
}, 30000); // every 30 seconds
// Handle graceful shutdown on SIGTERM // Periodically check for old event files to upload
process.on("SIGTERM", async () => { setInterval(() => {
logger.info( this.uploadOldEventFiles().catch((err) => {
"SIGTERM received, uploading events before shutdown..." logger.error("Error in periodic event file upload:", err);
);
await this.forceUpload();
logger.info("Events uploaded, proceeding with shutdown");
}); });
}, 30000); // every 30 seconds
// Handle SIGINT as well (Ctrl+C)
process.on("SIGINT", async () => {
logger.info(
"SIGINT received, uploading events before shutdown..."
);
await this.forceUpload();
logger.info("Events uploaded, proceeding with shutdown");
process.exit(0);
});
}
} }
/** /**
@@ -90,6 +78,85 @@ export class UsageService {
return Math.round(value * 100000000000) / 100000000000; // 11 decimal places return Math.round(value * 100000000000) / 100000000000; // 11 decimal places
} }
private async initializeEventsDirectory(): Promise<void> {
if (!this.eventsDir) {
logger.warn(
"Stripe local file path is not configured, skipping events directory initialization."
);
return;
}
try {
await fs.mkdir(this.eventsDir, { recursive: true });
} catch (error) {
logger.error("Failed to create events directory:", error);
}
}
private async uploadPendingEventFilesOnStartup(): Promise<void> {
if (!this.eventsDir || !this.bucketName) {
logger.warn(
"Stripe local file path or bucket name is not configured, skipping leftover event file upload."
);
return;
}
try {
const files = await fs.readdir(this.eventsDir);
for (const file of files) {
if (file.endsWith(".json")) {
const filePath = path.join(this.eventsDir, file);
try {
const fileContent = await fs.readFile(
filePath,
"utf-8"
);
const events = JSON.parse(fileContent);
if (Array.isArray(events) && events.length > 0) {
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: file,
Body: fileContent,
ContentType: "application/json"
});
await s3Client.send(uploadCommand);
// Check if file still exists before unlinking
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`Startup file ${file} was already deleted`
);
}
logger.info(
`Uploaded leftover event file ${file} to S3 with ${events.length} events`
);
} else {
// Remove empty file
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`Empty startup file ${file} was already deleted`
);
}
}
} catch (err) {
logger.error(
`Error processing leftover event file ${file}:`,
err
);
}
}
}
} catch (error) {
logger.error("Failed to scan for leftover event files");
}
}
public async add( public async add(
orgId: string, orgId: string,
featureId: FeatureId, featureId: FeatureId,
@@ -139,9 +206,7 @@ export class UsageService {
} }
// Log event for Stripe // Log event for Stripe
if (privateConfig.getRawPrivateConfig().flags.usage_reporting) { await this.logStripeEvent(featureId, value, customerId);
await this.logStripeEvent(featureId, value, customerId);
}
return usage || null; return usage || null;
} catch (error: any) { } catch (error: any) {
@@ -221,7 +286,7 @@ export class UsageService {
return new Date(date * 1000).toISOString().split("T")[0]; return new Date(date * 1000).toISOString().split("T")[0];
} }
async updateCount( async updateDaily(
orgId: string, orgId: string,
featureId: FeatureId, featureId: FeatureId,
value?: number, value?: number,
@@ -247,6 +312,8 @@ export class UsageService {
value = this.truncateValue(value); value = this.truncateValue(value);
} }
const today = this.getTodayDateString();
let currentUsage: Usage | null = null; let currentUsage: Usage | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
@@ -260,34 +327,66 @@ export class UsageService {
.limit(1); .limit(1);
if (currentUsage) { if (currentUsage) {
await trx const lastUpdateDate = this.getDateString(
.update(usage) currentUsage.updatedAt
.set({ );
instantaneousValue: value, const currentRunningTotal = currentUsage.latestValue;
updatedAt: Math.floor(Date.now() / 1000) const lastDailyValue = currentUsage.instantaneousValue || 0;
})
.where(eq(usage.usageId, usageId)); if (value == undefined || value === null) {
value = currentUsage.instantaneousValue || 0;
}
if (lastUpdateDate === today) {
// Same day update: replace the daily value
// Remove old daily value from running total, add new value
const newRunningTotal = this.truncateValue(
currentRunningTotal - lastDailyValue + value
);
await trx
.update(usage)
.set({
latestValue: newRunningTotal,
instantaneousValue: value,
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(usage.usageId, usageId));
} else {
// New day: add to running total
const newRunningTotal = this.truncateValue(
currentRunningTotal + value
);
await trx
.update(usage)
.set({
latestValue: newRunningTotal,
instantaneousValue: value,
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(usage.usageId, usageId));
}
} else { } else {
// First record for this meter // First record for this meter
const meterId = getFeatureMeterId(featureId); const meterId = getFeatureMeterId(featureId);
const truncatedValue = this.truncateValue(value || 0);
await trx.insert(usage).values({ await trx.insert(usage).values({
usageId, usageId,
featureId, featureId,
orgId, orgId,
meterId, meterId,
instantaneousValue: value || 0, instantaneousValue: truncatedValue,
latestValue: value || 0, latestValue: truncatedValue,
updatedAt: Math.floor(Date.now() / 1000) updatedAt: Math.floor(Date.now() / 1000)
}); });
} }
}); });
if (privateConfig.getRawPrivateConfig().flags.usage_reporting) { await this.logStripeEvent(featureId, value || 0, customerId);
await this.logStripeEvent(featureId, value || 0, customerId);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to update count usage for ${orgId}/${featureId}:`, `Failed to update daily usage for ${orgId}/${featureId}:`,
error error
); );
} }
@@ -351,58 +450,121 @@ export class UsageService {
} }
}; };
this.addEventToMemory(event); await this.writeEventToFile(event);
await this.checkAndUploadEvents(); await this.checkAndUploadFile();
} }
private addEventToMemory(event: StripeEvent): void { private async writeEventToFile(event: StripeEvent): Promise<void> {
if (!this.bucketName) { if (!this.eventsDir || !this.bucketName) {
logger.warn( logger.warn(
"S3 bucket name is not configured, skipping event storage." "Stripe local file path or bucket name is not configured, skipping event file write."
); );
return; return;
} }
this.events.push(event); if (!this.currentEventFile) {
} this.currentEventFile = this.generateEventFileName();
this.currentFileStartTime = Date.now();
private async checkAndUploadEvents(): Promise<void> {
const now = Date.now();
const timeSinceLastUpload = now - this.lastUploadTime;
// Check if at least 1 minute has passed since last upload
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
await this.uploadEventsToS3();
}
}
private async uploadEventsToS3(): Promise<void> {
if (!this.bucketName) {
logger.warn(
"S3 bucket name is not configured, skipping S3 upload."
);
return;
} }
if (this.events.length === 0) { const filePath = path.join(this.eventsDir, this.currentEventFile);
return;
}
// Check if already uploading
if (this.isUploading) {
logger.debug("Already uploading events, skipping");
return;
}
this.isUploading = true;
try { try {
// Take a snapshot of current events and clear the array let events: StripeEvent[] = [];
const eventsToUpload = [...this.events];
this.events = [];
this.lastUploadTime = Date.now();
const fileName = this.generateEventFileName(); // Try to read existing file
const fileContent = JSON.stringify(eventsToUpload, null, 2); try {
const fileContent = await fs.readFile(filePath, "utf-8");
events = JSON.parse(fileContent);
} catch (error) {
// File doesn't exist or is empty, start with empty array
events = [];
}
// Add new event
events.push(event);
// Write back to file
await fs.writeFile(filePath, JSON.stringify(events, null, 2));
} catch (error) {
logger.error("Failed to write event to file:", error);
}
}
private async checkAndUploadFile(): Promise<void> {
if (!this.currentEventFile) {
return;
}
const now = Date.now();
const fileAge = now - this.currentFileStartTime;
// Check if file is at least 1 minute old
if (fileAge >= 60000) {
// 60 seconds
await this.uploadFileToS3();
}
}
private async uploadFileToS3(): Promise<void> {
if (!this.bucketName || !this.eventsDir) {
logger.warn(
"Stripe local file path or bucket name is not configured, skipping S3 upload."
);
return;
}
if (!this.currentEventFile) {
return;
}
const fileName = this.currentEventFile;
const filePath = path.join(this.eventsDir, fileName);
// Check if this file is already being uploaded
if (this.uploadingFiles.has(fileName)) {
logger.debug(
`File ${fileName} is already being uploaded, skipping`
);
return;
}
// Mark file as being uploaded
this.uploadingFiles.add(fileName);
try {
// Check if file exists before trying to read it
try {
await fs.access(filePath);
} catch (error) {
logger.debug(
`File ${fileName} does not exist, may have been already processed`
);
this.uploadingFiles.delete(fileName);
// Reset current file if it was this file
if (this.currentEventFile === fileName) {
this.currentEventFile = null;
this.currentFileStartTime = 0;
}
return;
}
// Check if file exists and has content
const fileContent = await fs.readFile(filePath, "utf-8");
const events = JSON.parse(fileContent);
if (events.length === 0) {
// No events to upload, just clean up
try {
await fs.unlink(filePath);
} catch (unlinkError) {
// File may have been already deleted
logger.debug(
`File ${fileName} was already deleted during cleanup`
);
}
this.currentEventFile = null;
this.uploadingFiles.delete(fileName);
return;
}
// Upload to S3 // Upload to S3
const uploadCommand = new PutObjectCommand({ const uploadCommand = new PutObjectCommand({
@@ -414,15 +576,29 @@ export class UsageService {
await s3Client.send(uploadCommand); await s3Client.send(uploadCommand);
// Clean up local file - check if it still exists before unlinking
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
// File may have been already deleted by another process
logger.debug(
`File ${fileName} was already deleted during upload`
);
}
logger.info( logger.info(
`Uploaded ${fileName} to S3 with ${eventsToUpload.length} events` `Uploaded ${fileName} to S3 with ${events.length} events`
); );
// Reset for next file
this.currentEventFile = null;
this.currentFileStartTime = 0;
} catch (error) { } catch (error) {
logger.error("Failed to upload events to S3:", error); logger.error(`Failed to upload ${fileName} to S3:`, error);
// Note: Events are lost if upload fails. In a production system,
// you might want to add the events back to the array or implement retry logic
} finally { } finally {
this.isUploading = false; // Always remove from uploading set
this.uploadingFiles.delete(fileName);
} }
} }
@@ -507,16 +683,129 @@ export class UsageService {
} }
} }
public async getUsageDaily(
orgId: string,
featureId: FeatureId
): Promise<Usage | null> {
if (noop()) {
return null;
}
await this.updateDaily(orgId, featureId); // Ensure daily usage is updated
return this.getUsage(orgId, featureId);
}
public async forceUpload(): Promise<void> { public async forceUpload(): Promise<void> {
if (this.events.length > 0) { await this.uploadFileToS3();
// Force upload regardless of time }
this.lastUploadTime = 0; // Reset to force upload
await this.uploadEventsToS3(); /**
* Scan the events directory for files older than 1 minute and upload them if not empty.
*/
private async uploadOldEventFiles(): Promise<void> {
if (!this.eventsDir || !this.bucketName) {
logger.warn(
"Stripe local file path or bucket name is not configured, skipping old event file upload."
);
return;
}
try {
const files = await fs.readdir(this.eventsDir);
const now = Date.now();
for (const file of files) {
if (!file.endsWith(".json")) continue;
// Skip files that are already being uploaded
if (this.uploadingFiles.has(file)) {
logger.debug(
`Skipping file ${file} as it's already being uploaded`
);
continue;
}
const filePath = path.join(this.eventsDir, file);
try {
// Check if file still exists before processing
try {
await fs.access(filePath);
} catch (accessError) {
logger.debug(`File ${file} does not exist, skipping`);
continue;
}
const stat = await fs.stat(filePath);
const age = now - stat.mtimeMs;
if (age >= 90000) {
// 1.5 minutes - Mark as being uploaded
this.uploadingFiles.add(file);
try {
const fileContent = await fs.readFile(
filePath,
"utf-8"
);
const events = JSON.parse(fileContent);
if (Array.isArray(events) && events.length > 0) {
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: file,
Body: fileContent,
ContentType: "application/json"
});
await s3Client.send(uploadCommand);
// Check if file still exists before unlinking
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`File ${file} was already deleted during interval upload`
);
}
logger.info(
`Interval: Uploaded event file ${file} to S3 with ${events.length} events`
);
// If this was the current event file, reset it
if (this.currentEventFile === file) {
this.currentEventFile = null;
this.currentFileStartTime = 0;
}
} else {
// Remove empty file
try {
await fs.access(filePath);
await fs.unlink(filePath);
} catch (unlinkError) {
logger.debug(
`Empty file ${file} was already deleted`
);
}
}
} finally {
// Always remove from uploading set
this.uploadingFiles.delete(file);
}
}
} catch (err) {
logger.error(
`Interval: Error processing event file ${file}:`,
err
);
// Remove from uploading set on error
this.uploadingFiles.delete(file);
}
}
} catch (err) {
logger.error("Interval: Failed to scan for event files:", err);
} }
} }
public async checkLimitSet( public async checkLimitSet(
orgId: string, orgId: string,
kickSites = false,
featureId?: FeatureId, featureId?: FeatureId,
usage?: Usage, usage?: Usage,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
@@ -590,6 +879,58 @@ export class UsageService {
break; // Exit early if any limit is exceeded break; // Exit early if any limit is exceeded
} }
} }
// If any limits are exceeded, disconnect all sites for this organization
if (hasExceededLimits && kickSites) {
logger.warn(
`Disconnecting all sites for org ${orgId} due to exceeded limits`
);
// Get all sites for this organization
const orgSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, orgId));
// Mark all sites as offline and send termination messages
const siteUpdates = orgSites.map((site) => site.siteId);
if (siteUpdates.length > 0) {
// Send termination messages to newt sites
for (const site of orgSites) {
if (site.type === "newt") {
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
const payload = {
type: `newt/wg/terminate`,
data: {
reason: "Usage limits exceeded"
}
};
// Don't await to prevent blocking
await sendToClient(newt.newtId, payload).catch(
(error: any) => {
logger.error(
`Failed to send termination message to newt ${newt.newtId}:`,
error
);
}
);
}
}
}
logger.info(
`Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits`
);
}
}
} catch (error) { } catch (error) {
logger.error(`Error checking limits for org ${orgId}:`, error); logger.error(`Error checking limits for org ${orgId}:`, error);
} }
+15 -8
View File
@@ -31,9 +31,8 @@ import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db"; import { resourcePassword } from "@server/db";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "../isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { build } from "@server/build";
import { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
export type ProxyResourcesResults = { export type ProxyResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
@@ -213,8 +212,12 @@ export async function updateProxyResources(
} else { } else {
// Update existing resource // Update existing resource
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage); const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) { if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
} }
@@ -591,7 +594,7 @@ export async function updateProxyResources(
existingRule.action !== getRuleAction(rule.action) || existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() || existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !== existingRule.value !==
getRuleValue(rule.match.toUpperCase(), rule.value) || getRuleValue(rule.match.toUpperCase(), rule.value) ||
existingRule.priority !== intendedPriority existingRule.priority !== intendedPriority
) { ) {
validateRule(rule); validateRule(rule);
@@ -649,8 +652,12 @@ export async function updateProxyResources(
); );
} }
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage); const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) { if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
} }
+2 -4
View File
@@ -14,13 +14,12 @@ import {
} from "@server/db"; } from "@server/db";
import { getUniqueClientName } from "@server/db/names"; import { getUniqueClientName } from "@server/db/names";
import { getNextAvailableClientSubnet } from "@server/lib/ip"; import { getNextAvailableClientSubnet } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import logger from "@server/logger"; import logger from "@server/logger";
import { sendTerminateClient } from "@server/routers/client/terminate"; import { sendTerminateClient } from "@server/routers/client/terminate";
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
import { OlmErrorCodes } from "@server/routers/olm/error"; import { OlmErrorCodes } from "@server/routers/olm/error";
import { tierMatrix } from "./billing/tierMatrix";
export async function calculateUserClientsForOrgs( export async function calculateUserClientsForOrgs(
userId: string, userId: string,
@@ -190,8 +189,7 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId); const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await isLicensedOrSubscribed( const isOrgLicensed = await isLicensedOrSubscribed(
userOrg.orgId, userOrg.orgId
tierMatrix.deviceApprovals
); );
const requireApproval = const requireApproval =
build !== "oss" && build !== "oss" &&
-5
View File
@@ -107,11 +107,6 @@ export class Config {
process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path; process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path;
} }
process.env.DISABLE_ENTERPRISE_FEATURES = parsedConfig.flags
?.disable_enterprise_features
? "true"
: "false";
this.rawConfig = parsedConfig; this.rawConfig = parsedConfig;
} }
+1 -1
View File
@@ -182,7 +182,7 @@ export async function createUserAccountOrg(
const customerId = await createCustomer(orgId, userEmail); const customerId = await createCustomer(orgId, userEmail);
if (customerId) { if (customerId) {
await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
} }
return { return {
-3
View File
@@ -1,3 +0,0 @@
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
+15 -6
View File
@@ -1,8 +1,17 @@
import { Tier } from "@server/types/Tiers"; import { build } from "@server/build";
import license from "#dynamic/license/license";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function isLicensedOrSubscribed( export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
orgId: string, if (build === "enterprise") {
tiers: Tier[] return await license.isUnlocked();
): Promise<boolean> { }
return false;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return true;
} }
-8
View File
@@ -1,8 +0,0 @@
import { Tier } from "@server/types/Tiers";
export async function isSubscribed(
orgId: string,
tiers: Tier[]
): Promise<boolean> {
return false;
}
+6 -7
View File
@@ -3,10 +3,13 @@ import yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts"; import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod"; import { z } from "zod";
import stoi from "./stoi"; import stoi from "./stoi";
import { getEnvOrYaml } from "./getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
export const configSchema = z export const configSchema = z
.object({ .object({
app: z app: z
@@ -308,10 +311,7 @@ export const configSchema = z
.object({ .object({
smtp_host: z.string().optional(), smtp_host: z.string().optional(),
smtp_port: portSchema.optional(), smtp_port: portSchema.optional(),
smtp_user: z smtp_user: z.string().optional(),
.string()
.optional()
.transform(getEnvOrYaml("EMAIL_SMTP_USER")),
smtp_pass: z smtp_pass: z
.string() .string()
.optional() .optional()
@@ -331,8 +331,7 @@ export const configSchema = z
disable_local_sites: z.boolean().optional(), disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional(), disable_product_help_banners: z.boolean().optional()
disable_enterprise_features: z.boolean().optional()
}) })
.optional(), .optional(),
dns: z dns: z
+1 -7
View File
@@ -12,10 +12,6 @@ export type LicenseStatus = {
isLicenseValid: boolean; // Is the license key valid? isLicenseValid: boolean; // Is the license key valid?
hostId: string; // Host ID hostId: string; // Host ID
tier?: LicenseKeyTier; tier?: LicenseKeyTier;
maxSites?: number;
usedSites?: number;
maxUsers?: number;
usedUsers?: number;
}; };
export type LicenseKeyCache = { export type LicenseKeyCache = {
@@ -26,14 +22,12 @@ export type LicenseKeyCache = {
type?: LicenseKeyType; type?: LicenseKeyType;
tier?: LicenseKeyTier; tier?: LicenseKeyTier;
terminateAt?: Date; terminateAt?: Date;
quantity?: number;
quantity_2?: number;
}; };
export class License { export class License {
private serverSecret!: string; private serverSecret!: string;
constructor(private hostMeta: HostMeta) { } constructor(private hostMeta: HostMeta) {}
public async check(): Promise<LicenseStatus> { public async check(): Promise<LicenseStatus> {
return { return {
+17 -40
View File
@@ -11,59 +11,36 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { getTierPriceSet } from "@server/lib/billing/tiers";
import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription";
import { build } from "@server/build"; import { build } from "@server/build";
import { db, customers, subscriptions } from "@server/db";
import { Tier } from "@server/types/Tiers";
import { eq, and, ne } from "drizzle-orm";
export async function getOrgTierData( export async function getOrgTierData(
orgId: string orgId: string
): Promise<{ tier: Tier | null; active: boolean }> { ): Promise<{ tier: string | null; active: boolean }> {
let tier: Tier | null = null; let tier = null;
let active = false; let active = false;
if (build !== "saas") { if (build !== "saas") {
return { tier, active }; return { tier, active };
} }
try { const { subscription, items } = await getOrgSubscriptionData(orgId);
// Get customer for org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
if (customer) { if (items && items.length > 0) {
// Query for active subscriptions that are not license type const tierPriceSet = getTierPriceSet();
const [subscription] = await db // Iterate through tiers in order (earlier keys are higher tiers)
.select() for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
.from(subscriptions) // Check if any subscription item matches this tier's price ID
.where( const matchingItem = items.find((item) => item.priceId === priceId);
and( if (matchingItem) {
eq(subscriptions.customerId, customer.customerId), tier = tierId;
eq(subscriptions.status, "active"), break;
ne(subscriptions.type, "license")
)
)
.limit(1);
if (subscription) {
// Validate that subscription.type is one of the expected tier values
if (
subscription.type === "tier1" ||
subscription.type === "tier2" ||
subscription.type === "tier3"
) {
tier = subscription.type;
active = true;
}
} }
} }
} catch (error) {
// If org not found or error occurs, return null tier and inactive
// This is acceptable behavior as per the function signature
} }
if (subscription && subscription.status === "active") {
active = true;
}
return { tier, active }; return { tier, active };
} }
+10 -1
View File
@@ -19,6 +19,7 @@ import * as fs from "fs";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
let encryptionKeyPath = "";
let encryptionKeyHex = ""; let encryptionKeyHex = "";
let encryptionKey: Buffer; let encryptionKey: Buffer;
function loadEncryptData() { function loadEncryptData() {
@@ -26,7 +27,15 @@ function loadEncryptData() {
return; // already loaded return; // already loaded
} }
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key; encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex"); encryptionKey = Buffer.from(encryptionKeyHex, "hex");
} }
+2 -2
View File
@@ -13,6 +13,8 @@
import { build } from "@server/build"; import { build } from "@server/build";
import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db"; import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import license from "#private/license/license"; import license from "#private/license/license";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
@@ -78,8 +80,6 @@ export async function checkOrgAccessPolicy(
} }
} }
// TODO: check that the org is subscribed
// get the needed data // get the needed data
if (!props.org) { if (!props.org) {
+4 -1
View File
@@ -128,7 +128,10 @@ export class PrivateConfig {
if (this.rawPrivateConfig.stripe?.s3Bucket) { if (this.rawPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket; process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket;
} }
if (this.rawPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
this.rawPrivateConfig.stripe.localFilePath;
}
if (this.rawPrivateConfig.stripe?.s3Region) { if (this.rawPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region; process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region;
} }
@@ -1,32 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { build } from "@server/build";
import license from "#private/license/license";
import { isSubscribed } from "#private/lib/isSubscribed";
import { Tier } from "@server/types/Tiers";
export async function isLicensedOrSubscribed(
orgId: string,
tiers: Tier[]
): Promise<boolean> {
if (build === "enterprise") {
return await license.isUnlocked();
}
if (build === "saas") {
return isSubscribed(orgId, tiers);
}
return false;
}
-29
View File
@@ -1,29 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
import { Tier } from "@server/types/Tiers";
export async function isSubscribed(
orgId: string,
tiers: Tier[]
): Promise<boolean> {
if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId);
const isTier = (tier && tiers.includes(tier)) || false;
return active && isTier;
}
return false;
}
+13 -31
View File
@@ -17,7 +17,6 @@ import { privateConfigFilePath1 } from "@server/lib/consts";
import { z } from "zod"; import { z } from "zod";
import { colorsSchema } from "@server/lib/colorsSchema"; import { colorsSchema } from "@server/lib/colorsSchema";
import { build } from "@server/build"; import { build } from "@server/build";
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
@@ -33,29 +32,19 @@ export const privateConfigSchema = z.object({
}), }),
server: z server: z
.object({ .object({
encryption_key: z encryption_key_path: z
.string() .string()
.optional() .optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")), .default("./config/encryption.pem")
resend_api_key: z .pipe(z.string().min(8)),
.string() resend_api_key: z.string().optional(),
.optional() reo_client_id: z.string().optional(),
.transform(getEnvOrYaml("RESEND_API_KEY")), fossorial_api_key: z.string().optional()
reo_client_id: z
.string()
.optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
}) })
.optional() .optional()
.prefault({}), .default({
encryption_key_path: "./config/encryption.pem"
}),
redis: z redis: z
.object({ .object({
host: z.string(), host: z.string(),
@@ -95,8 +84,7 @@ export const privateConfigSchema = z.object({
.object({ .object({
enable_redis: z.boolean().optional().default(false), enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional().default(false)
usage_reporting: z.boolean().optional().default(false)
}) })
.optional() .optional()
.prefault({}), .prefault({}),
@@ -169,17 +157,11 @@ export const privateConfigSchema = z.object({
.optional(), .optional(),
stripe: z stripe: z
.object({ .object({
secret_key: z secret_key: z.string(),
.string() webhook_secret: z.string(),
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
s3Bucket: z.string(), s3Bucket: z.string(),
s3Region: z.string().default("us-east-1"), s3Region: z.string().default("us-east-1"),
localFilePath: z.string().optional() localFilePath: z.string()
}) })
.optional() .optional()
}); });
+6 -45
View File
@@ -11,12 +11,12 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { db, HostMeta, sites, users } from "@server/db"; import { db, HostMeta } from "@server/db";
import { hostMeta, licenseKey } from "@server/db"; import { hostMeta, licenseKey } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt"; import { validateJWT } from "./licenseJwt";
import { count, eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import moment from "moment"; import moment from "moment";
import { encrypt, decrypt } from "@server/lib/crypto"; import { encrypt, decrypt } from "@server/lib/crypto";
import { import {
@@ -54,7 +54,6 @@ type TokenPayload = {
type: LicenseKeyType; type: LicenseKeyType;
tier: LicenseKeyTier; tier: LicenseKeyTier;
quantity: number; quantity: number;
quantity_2: number;
terminateAt: string; // ISO terminateAt: string; // ISO
iat: number; // Issued at iat: number; // Issued at
}; };
@@ -141,20 +140,10 @@ LQIDAQAB
}; };
} }
// Count used sites and users for license comparison
const [siteCountRes] = await db
.select({ value: count() })
.from(sites);
const [userCountRes] = await db
.select({ value: count() })
.from(users);
const status: LicenseStatus = { const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId, hostId: this.hostMeta.hostMetaId,
isHostLicensed: true, isHostLicensed: true,
isLicenseValid: false, isLicenseValid: false
usedSites: siteCountRes?.value ?? 0,
usedUsers: userCountRes?.value ?? 0
}; };
this.checkInProgress = true; this.checkInProgress = true;
@@ -162,8 +151,6 @@ LQIDAQAB
try { try {
if (!this.doRecheck && this.statusCache.has(this.statusKey)) { if (!this.doRecheck && this.statusCache.has(this.statusKey)) {
const res = this.statusCache.get("status") as LicenseStatus; const res = this.statusCache.get("status") as LicenseStatus;
res.usedSites = status.usedSites;
res.usedUsers = status.usedUsers;
return res; return res;
} }
logger.debug("Checking license status..."); logger.debug("Checking license status...");
@@ -206,9 +193,7 @@ LQIDAQAB
type: payload.type, type: payload.type,
tier: payload.tier, tier: payload.tier,
iat: new Date(payload.iat * 1000), iat: new Date(payload.iat * 1000),
terminateAt: new Date(payload.terminateAt), terminateAt: new Date(payload.terminateAt)
quantity: payload.quantity,
quantity_2: payload.quantity_2
}); });
if (payload.type === "host") { if (payload.type === "host") {
@@ -307,8 +292,6 @@ LQIDAQAB
cached.tier = payload.tier; cached.tier = payload.tier;
cached.iat = new Date(payload.iat * 1000); cached.iat = new Date(payload.iat * 1000);
cached.terminateAt = new Date(payload.terminateAt); cached.terminateAt = new Date(payload.terminateAt);
cached.quantity = payload.quantity;
cached.quantity_2 = payload.quantity_2;
// Encrypt the updated token before storing // Encrypt the updated token before storing
const encryptedKey = encrypt( const encryptedKey = encrypt(
@@ -334,7 +317,7 @@ LQIDAQAB
} }
} }
// Compute host status: quantity = users, quantity_2 = sites // Compute host status
for (const key of keys) { for (const key of keys) {
const cached = newCache.get(key.licenseKey)!; const cached = newCache.get(key.licenseKey)!;
@@ -346,28 +329,6 @@ LQIDAQAB
if (!cached.valid) { if (!cached.valid) {
continue; continue;
} }
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
if (
cached.quantity_2 !== undefined &&
cached.quantity_2 >= 0
) {
status.maxSites =
(status.maxSites ?? 0) + cached.quantity_2;
}
if (cached.quantity !== undefined && cached.quantity >= 0) {
status.maxUsers = (status.maxUsers ?? 0) + cached.quantity;
}
}
// Invalidate license if over user or site limits
if (
(status.maxSites !== undefined &&
(status.usedSites ?? 0) > status.maxSites) ||
(status.maxUsers !== undefined &&
(status.usedUsers ?? 0) > status.maxUsers)
) {
status.isLicenseValid = false;
} }
// Invalidate old cache and set new cache // Invalidate old cache and set new cache
@@ -541,7 +502,7 @@ LQIDAQAB
// Calculate exponential backoff delay // Calculate exponential backoff delay
const retryDelay = Math.floor( const retryDelay = Math.floor(
initialRetryDelay * initialRetryDelay *
Math.pow(exponentialFactor, attempt - 1) Math.pow(exponentialFactor, attempt - 1)
); );
logger.debug( logger.debug(
@@ -16,61 +16,46 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build"; import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing"; import { getOrgTierData } from "#private/lib/billing";
import { Tier } from "@server/types/Tiers";
export function verifyValidSubscription(tiers: Tier[]) {
return async function (
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
if (build != "saas") {
return next();
}
const orgId =
req.params.orgId ||
req.body.orgId ||
req.query.orgId ||
req.userOrgId;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required to verify subscription"
)
);
}
const { tier, active } = await getOrgTierData(orgId);
const isTier = tiers.includes(tier || "");
if (!active) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Organization does not have an active subscription"
)
);
}
if (!isTier) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Organization subscription tier does not have access to this feature"
)
);
}
export async function verifyValidSubscription(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (build != "saas") {
return next(); return next();
} catch (e) { }
const orgId = req.params.orgId || req.body.orgId || req.query.orgId || req.userOrgId;
if (!orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.BAD_REQUEST,
"Error verifying subscription" "Organization ID is required to verify subscription"
) )
); );
} }
};
const tier = await getOrgTierData(orgId);
if (!tier.active) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Organization does not have an active subscription"
)
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying subscription"
)
);
}
} }
@@ -19,6 +19,8 @@ import { fromError } from "zod-validation-error";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build"; import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { import {
approvals, approvals,
clients, clients,
@@ -219,6 +221,19 @@ export async function listApprovals(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const approvalsList = await queryApprovals( const approvalsList = await queryApprovals(
orgId.toString(), orgId.toString(),
limit, limit,
@@ -17,7 +17,10 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import { approvals, clients, db, orgs, type Approval } from "@server/db"; import { approvals, clients, db, orgs, type Approval } from "@server/db";
import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { and, eq, type InferInsertModel } from "drizzle-orm"; import { and, eq, type InferInsertModel } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Request, Response } from "express";
@@ -61,6 +64,20 @@ export async function processPendingApproval(
} }
const { orgId, approvalId } = parsedParams.data; const { orgId, approvalId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const updateData = parsedBody.data; const updateData = parsedBody.data;
const approval = await db const approval = await db
+1
View File
@@ -13,3 +13,4 @@
export * from "./transferSession"; export * from "./transferSession";
export * from "./getSessionTransferToken"; export * from "./getSessionTransferToken";
export * from "./quickStart";
+585
View File
@@ -0,0 +1,585 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import {
account,
db,
domainNamespaces,
domains,
exitNodes,
newts,
newtSessions,
orgs,
passwordResetTokens,
Resource,
resourcePassword,
resourcePincode,
resources,
resourceWhitelist,
roleResources,
roles,
roleSites,
sites,
targetHealthCheck,
targets,
userResources,
userSites
} from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { users } from "@server/db";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import { eq, and, sql } from "drizzle-orm";
import moment from "moment";
import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
import { sendEmail } from "@server/emails";
import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart";
import { alphabet, generateRandomString } from "oslo/crypto";
import { createDate, TimeSpan } from "oslo";
import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names";
import { pickPort } from "@server/routers/target/helpers";
import { addTargets } from "@server/routers/newt/targets";
import { isTargetValid } from "@server/lib/validators";
import { listExitNodes } from "#private/lib/exitNodes";
const bodySchema = z.object({
email: z.email().toLowerCase(),
ip: z.string().refine(isTargetValid),
method: z.enum(["http", "https"]),
port: z.int().min(1).max(65535),
pincode: z
.string()
.regex(/^\d{6}$/)
.optional(),
password: z.string().min(4).max(100).optional(),
enableWhitelist: z.boolean().optional().default(true),
animalId: z.string() // This is actually the secret key for the backend
});
export type QuickStartBody = z.infer<typeof bodySchema>;
export type QuickStartResponse = {
newtId: string;
newtSecret: string;
resourceUrl: string;
completeSignUpLink: string;
};
const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22";
export async function quickStart(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
email,
ip,
method,
port,
pincode,
password,
enableWhitelist,
animalId
} = parsedBody.data;
try {
const tokenValidation = validateTokenOnApi(animalId);
if (!tokenValidation.isValid) {
logger.warn(
`Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}`
);
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired token"
)
);
}
if (animalId === DEMO_UBO_KEY) {
if (email !== "mehrdad@getubo.com") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid email for demo Ubo key"
)
);
}
const [existing] = await db
.select()
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (existing) {
// delete the user if it already exists
await db.delete(users).where(eq(users.userId, existing.userId));
const orgId = `org_${existing.userId}`;
await db.delete(orgs).where(eq(orgs.orgId, orgId));
}
}
const tempPassword = generateId(15);
const passwordHash = await hashPassword(tempPassword);
const userId = generateId(15);
// TODO: see if that user already exists?
// Create the sandbox user
const existing = await db
.select()
.from(users)
.where(
and(eq(users.email, email), eq(users.type, UserType.Internal))
);
if (existing && existing.length > 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
)
);
}
let newtId: string;
let secret: string;
let fullDomain: string;
let resource: Resource;
let completeSignUpLink: string;
await db.transaction(async (trx) => {
await trx.insert(users).values({
userId: userId,
type: UserType.Internal,
username: email,
email: email,
passwordHash,
dateCreated: moment().toISOString()
});
// create user"s account
await trx.insert(account).values({
userId
});
});
const { success, error, org } = await createUserAccountOrg(
userId,
email
);
if (!success) {
if (error) {
throw new Error(error);
}
throw new Error("Failed to create user account and organization");
}
if (!org) {
throw new Error("Failed to create user account and organization");
}
const orgId = org.orgId;
await db.transaction(async (trx) => {
const token = generateRandomString(
8,
alphabet("0-9", "A-Z", "a-z")
);
await trx
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, userId));
const tokenHash = await hashPassword(token);
await trx.insert(passwordResetTokens).values({
userId: userId,
email: email,
tokenHash,
expiresAt: createDate(new TimeSpan(7, "d")).getTime()
});
// // Create the sandbox newt
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
// if (!newClientAddress) {
// throw new Error("No available subnet found");
// }
// const clientAddress = newClientAddress.split("/")[0];
newtId = generateId(15);
secret = generateId(48);
// Create the sandbox site
const siteNiceId = await getUniqueSiteName(orgId);
const siteName = `First Site`;
// pick a random exit node
const exitNodesList = await listExitNodes(orgId);
// select a random exit node
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
throw new Error("No exit nodes available");
}
const [newSite] = await trx
.insert(sites)
.values({
orgId,
exitNodeId: randomExitNode.exitNodeId,
name: siteName,
niceId: siteNiceId,
// address: clientAddress,
type: "newt",
dockerSocketEnabled: true
})
.returning();
const siteId = newSite.siteId;
const adminRole = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
throw new Error("Admin role not found");
}
await trx.insert(roleSites).values({
roleId: adminRole[0].roleId,
siteId: newSite.siteId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the site
await trx.insert(userSites).values({
userId: req.user?.userId!,
siteId: newSite.siteId
});
}
// add the peer to the exit node
const secretHash = await hashPassword(secret!);
await trx.insert(newts).values({
newtId: newtId!,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
const [randomNamespace] = await trx
.select()
.from(domainNamespaces)
.orderBy(sql`RANDOM()`)
.limit(1);
if (!randomNamespace) {
throw new Error("No domain namespace available");
}
const [randomNamespaceDomain] = await trx
.select()
.from(domains)
.where(eq(domains.domainId, randomNamespace.domainId))
.limit(1);
if (!randomNamespaceDomain) {
throw new Error("No domain found for the namespace");
}
const resourceNiceId = await getUniqueResourceName(orgId);
// Create sandbox resource
const subdomain = `${resourceNiceId}-${generateId(5)}`;
fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`;
const resourceName = `First Resource`;
const newResource = await trx
.insert(resources)
.values({
niceId: resourceNiceId,
fullDomain,
domainId: randomNamespaceDomain.domainId,
orgId,
name: resourceName,
subdomain,
http: true,
protocol: "tcp",
ssl: true,
sso: false,
emailWhitelistEnabled: enableWhitelist
})
.returning();
await trx.insert(roleResources).values({
roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,
resourceId: newResource[0].resourceId
});
}
resource = newResource[0];
// Create the sandbox target
const { internalPort, targetIps } = await pickPort(siteId!, trx);
if (!internalPort) {
throw new Error("No available internal port");
}
const newTarget = await trx
.insert(targets)
.values({
resourceId: resource.resourceId,
siteId: siteId!,
internalPort,
ip,
method,
port,
enabled: true
})
.returning();
const newHealthcheck = await trx
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: false
})
.returning();
// add the new target to the targetIps array
targetIps.push(`${ip}/32`);
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, siteId!))
.limit(1);
await addTargets(
newt.newtId,
newTarget,
newHealthcheck,
resource.protocol
);
// Set resource pincode if provided
if (pincode) {
await trx
.delete(resourcePincode)
.where(
eq(resourcePincode.resourceId, resource!.resourceId)
);
const pincodeHash = await hashPassword(pincode);
await trx.insert(resourcePincode).values({
resourceId: resource!.resourceId,
pincodeHash,
digitLength: 6
});
}
// Set resource password if provided
if (password) {
await trx
.delete(resourcePassword)
.where(
eq(resourcePassword.resourceId, resource!.resourceId)
);
const passwordHash = await hashPassword(password);
await trx.insert(resourcePassword).values({
resourceId: resource!.resourceId,
passwordHash
});
}
// Set resource OTP if whitelist is enabled
if (enableWhitelist) {
await trx.insert(resourceWhitelist).values({
email,
resourceId: resource!.resourceId
});
}
completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`;
// Store token for email outside transaction
await sendEmail(
WelcomeQuickStart({
username: email,
link: completeSignUpLink,
fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`,
resourceMethod: method,
resourceHostname: ip,
resourcePort: port,
resourceUrl: `https://${fullDomain}`,
cliCommand: `newt --id ${newtId} --secret ${secret}`
}),
{
to: email,
from: config.getNoReplyEmail(),
subject: `Access your Pangolin dashboard and resources`
}
);
});
return response<QuickStartResponse>(res, {
data: {
newtId: newtId!,
newtSecret: secret!,
resourceUrl: `https://${fullDomain!}`,
completeSignUpLink: completeSignUpLink!
},
success: true,
error: false,
message: "Quick start completed successfully",
status: HttpCode.OK
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
)
);
} else {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to do quick start"
)
);
}
}
}
const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501";
/**
* Validates a token received from the frontend.
* @param {string} token The validation token from the request.
* @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid.
*/
const validateTokenOnApi = (
token: string
): { isValid: boolean; message: string } => {
if (token === DEMO_UBO_KEY) {
// Special case for demo UBO key
return { isValid: true, message: "Demo UBO key is valid." };
}
if (!token) {
return { isValid: false, message: "Error: No token provided." };
}
try {
// 1. Decode the base64 string
const decodedB64 = atob(token);
// 2. Reverse the character code manipulation
const deobfuscated = decodedB64
.split("")
.map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift
.join("");
// 3. Split the data to get the original secret and timestamp
const parts = deobfuscated.split("|");
if (parts.length !== 2) {
throw new Error("Invalid token format.");
}
const receivedKey = parts[0];
const tokenTimestamp = parseInt(parts[1], 10);
// 4. Check if the secret key matches
if (receivedKey !== BACKEND_SECRET_KEY) {
return { isValid: false, message: "Invalid token: Key mismatch." };
}
// 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks
const now = Date.now();
const timeDifference = now - tokenTimestamp;
if (timeDifference > 30000) {
// 30 seconds
return { isValid: false, message: "Invalid token: Expired." };
}
if (timeDifference < 0) {
// Timestamp is in the future
return {
isValid: false,
message: "Invalid token: Timestamp is in the future."
};
}
// If all checks pass, the token is valid
return { isValid: true, message: "Token is valid!" };
} catch (error) {
// This will catch errors from atob (if not valid base64) or other issues.
return {
isValid: false,
message: `Error: ${(error as Error).message}`
};
}
};
@@ -1,266 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { customers, db, subscriptions, subscriptionItems } from "@server/db";
import { eq, and, or } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe";
import {
getHomeLabFeaturePriceSet,
getScaleFeaturePriceSet,
getStarterFeaturePriceSet,
getLineItems,
FeatureId,
type FeaturePriceSet
} from "@server/lib/billing";
const changeTierSchema = z.strictObject({
orgId: z.string()
});
const changeTierBodySchema = z.strictObject({
tier: z.enum(["tier1", "tier2", "tier3"])
});
export async function changeTier(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = changeTierSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = changeTierBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { tier } = parsedBody.data;
// Get the customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
if (!customer) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
}
// Get the active subscription for this customer
const [subscription] = await db
.select()
.from(subscriptions)
.where(
and(
eq(subscriptions.customerId, customer.customerId),
eq(subscriptions.status, "active"),
or(
eq(subscriptions.type, "tier1"),
eq(subscriptions.type, "tier2"),
eq(subscriptions.type, "tier3")
)
)
)
.limit(1);
if (!subscription) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No active subscription found for this organization"
)
);
}
// Get the target tier's price set
let targetPriceSet: FeaturePriceSet;
if (tier === "tier1") {
targetPriceSet = getHomeLabFeaturePriceSet();
} else if (tier === "tier2") {
targetPriceSet = getStarterFeaturePriceSet();
} else if (tier === "tier3") {
targetPriceSet = getScaleFeaturePriceSet();
} else {
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier"));
}
// Get current subscription items from our database
const currentItems = await db
.select()
.from(subscriptionItems)
.where(
eq(
subscriptionItems.subscriptionId,
subscription.subscriptionId
)
);
if (currentItems.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No subscription items found"
)
);
}
// Retrieve the full subscription from Stripe to get item IDs
const stripeSubscription = await stripe!.subscriptions.retrieve(
subscription.subscriptionId
);
// Determine if we're switching between different products
// tier1 uses TIER1 product, tier2/tier3 use USERS product
const currentTier = subscription.type;
const switchingProducts =
(currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) ||
((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1");
let updatedSubscription;
if (switchingProducts) {
// When switching between different products, we need to:
// 1. Delete old subscription items
// 2. Add new subscription items
logger.info(
`Switching products from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
);
// Build array to delete all existing items and add new ones
const itemsToUpdate: any[] = [];
// Mark all existing items for deletion
for (const stripeItem of stripeSubscription.items.data) {
itemsToUpdate.push({
id: stripeItem.id,
deleted: true
});
}
// Add new items for the target tier
const newLineItems = await getLineItems(targetPriceSet, orgId);
for (const lineItem of newLineItems) {
itemsToUpdate.push(lineItem);
}
updatedSubscription = await stripe!.subscriptions.update(
subscription.subscriptionId,
{
items: itemsToUpdate,
proration_behavior: "create_prorations"
}
);
} else {
// Same product, different price tier (tier2 <-> tier3)
// We can simply update the price
logger.info(
`Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}`
);
const itemsToUpdate = stripeSubscription.items.data.map(
(stripeItem) => {
// Find the corresponding item in our database
const dbItem = currentItems.find(
(item) => item.priceId === stripeItem.price.id
);
if (!dbItem) {
// Keep the existing item unchanged if we can't find it
return {
id: stripeItem.id,
price: stripeItem.price.id,
quantity: stripeItem.quantity
};
}
// Map to the corresponding feature in the new tier
const newPriceId = targetPriceSet[FeatureId.USERS];
if (newPriceId) {
return {
id: stripeItem.id,
price: newPriceId,
quantity: stripeItem.quantity
};
}
// If no mapping found, keep existing
return {
id: stripeItem.id,
price: stripeItem.price.id,
quantity: stripeItem.quantity
};
}
);
updatedSubscription = await stripe!.subscriptions.update(
subscription.subscriptionId,
{
items: itemsToUpdate,
proration_behavior: "create_prorations"
}
);
}
logger.info(
`Successfully changed tier to ${tier} for org ${orgId}, subscription ${subscription.subscriptionId}`
);
return response<{ subscriptionId: string; newTier: string }>(res, {
data: {
subscriptionId: updatedSubscription.id,
newTier: tier
},
success: true,
error: false,
message: "Tier change successful",
status: HttpCode.OK
});
} catch (error) {
logger.error("Error changing tier:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while changing tier"
)
);
}
}
@@ -22,18 +22,13 @@ import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing"; import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService"; import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
import Stripe from "stripe";
const createCheckoutSessionSchema = z.strictObject({ const createCheckoutSessionSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const createCheckoutSessionBodySchema = z.strictObject({
tier: z.enum(["tier1", "tier2", "tier3"]),
});
export async function createCheckoutSession( export async function createCheckoutSession(
req: Request, req: Request,
res: Response, res: Response,
@@ -52,18 +47,6 @@ export async function createCheckoutSession(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { tier } = parsedBody.data;
// check if we already have a customer for this org // check if we already have a customer for this org
const [customer] = await db const [customer] = await db
.select() .select()
@@ -82,25 +65,18 @@ export async function createCheckoutSession(
); );
} }
let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
if (tier === "tier1") {
lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId);
} else if (tier === "tier2") {
lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId);
} else if (tier === "tier3") {
lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId);
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")
);
}
logger.debug(`Line items: ${JSON.stringify(lineItems)}`)
const session = await stripe!.checkout.sessions.create({ const session = await stripe!.checkout.sessions.create({
client_reference_id: orgId, // So we can look it up the org later on the webhook client_reference_id: orgId, // So we can look it up the org later on the webhook
billing_address_collection: "required", billing_address_collection: "required",
line_items: lineItems, line_items: [
{
price: standardTierPrice, // Use the standard tier
quantity: 1
},
...getLineItems(getStandardFeaturePriceSet())
], // Start with the standard feature set that matches the free limits
customer: customer.customerId, customer: customer.customerId,
mode: "subscription", mode: "subscription",
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
@@ -111,7 +87,7 @@ export async function createCheckoutSession(
data: session.url, data: session.url,
success: true, success: true,
error: false, error: false,
message: "Checkout session created successfully", message: "Organization created successfully",
status: HttpCode.CREATED status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {
@@ -37,7 +37,18 @@ const getOrgSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
export async function getOrgSubscriptions( registry.registerPath({
method: "get",
path: "/org/{orgId}/billing/subscription",
description: "Get an organization",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrgSubscription(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
@@ -55,9 +66,12 @@ export async function getOrgSubscriptions(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
let subscriptions = null; let subscriptionData = null;
let itemsData: SubscriptionItem[] = [];
try { try {
subscriptions = await getOrgSubscriptionsData(orgId); const { subscription, items } = await getOrgSubscriptionData(orgId);
subscriptionData = subscription;
itemsData = items;
} catch (err) { } catch (err) {
if ((err as Error).message === "Not found") { if ((err as Error).message === "Not found") {
return next( return next(
@@ -72,7 +86,8 @@ export async function getOrgSubscriptions(
return response<GetOrgSubscriptionResponse>(res, { return response<GetOrgSubscriptionResponse>(res, {
data: { data: {
subscriptions subscription: subscriptionData,
items: itemsData
}, },
success: true, success: true,
error: false, error: false,
@@ -87,9 +102,9 @@ export async function getOrgSubscriptions(
} }
} }
export async function getOrgSubscriptionsData( export async function getOrgSubscriptionData(
orgId: string orgId: string
): Promise<Array<{ subscription: Subscription; items: SubscriptionItem[] }>> { ): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
const org = await db const org = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -107,21 +122,21 @@ export async function getOrgSubscriptionsData(
.where(eq(customers.orgId, orgId)) .where(eq(customers.orgId, orgId))
.limit(1); .limit(1);
const subscriptionsWithItems: Array<{ let subscription = null;
subscription: Subscription; let items: SubscriptionItem[] = [];
items: SubscriptionItem[];
}> = [];
if (customer.length > 0) { if (customer.length > 0) {
// Get all subscriptions for customer // Get subscription for customer
const subs = await db const subs = await db
.select() .select()
.from(subscriptions) .from(subscriptions)
.where(eq(subscriptions.customerId, customer[0].customerId)); .where(eq(subscriptions.customerId, customer[0].customerId))
.limit(1);
for (const subscription of subs) { if (subs.length > 0) {
// Get subscription items for each subscription subscription = subs[0];
const items = await db // Get subscription items
items = await db
.select() .select()
.from(subscriptionItems) .from(subscriptionItems)
.where( .where(
@@ -130,13 +145,8 @@ export async function getOrgSubscriptionsData(
subscription.subscriptionId subscription.subscriptionId
) )
); );
subscriptionsWithItems.push({
subscription,
items
});
} }
} }
return subscriptionsWithItems; return { subscription, items };
} }
+12 -6
View File
@@ -78,10 +78,16 @@ export async function getOrgUsage(
// Get usage for org // Get usage for org
const usageData = []; const usageData = [];
const sites = await usageService.getUsage(orgId, FeatureId.SITES); const siteUptime = await usageService.getUsage(
const users = await usageService.getUsage(orgId, FeatureId.USERS); orgId,
const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); FeatureId.SITE_UPTIME
const remoteExitNodes = await usageService.getUsage( );
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
const domains = await usageService.getUsageDaily(
orgId,
FeatureId.DOMAINS
);
const remoteExitNodes = await usageService.getUsageDaily(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES FeatureId.REMOTE_EXIT_NODES
); );
@@ -90,8 +96,8 @@ export async function getOrgUsage(
FeatureId.EGRESS_DATA_MB FeatureId.EGRESS_DATA_MB
); );
if (sites) { if (siteUptime) {
usageData.push(sites); usageData.push(siteUptime);
} }
if (users) { if (users) {
usageData.push(users); usageData.push(users);
@@ -1,62 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
getLicensePriceSet,
} from "@server/lib/billing/licenses";
import {
getHomeLabFeaturePriceSet,
getStarterFeaturePriceSet,
getScaleFeaturePriceSet,
} from "@server/lib/billing/features";
import Stripe from "stripe";
import { Tier } from "@server/types/Tiers";
export type SubscriptionType = Tier | "license";
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null {
// Determine subscription type by checking subscription items
if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) {
return null;
}
for (const item of fullSubscription.items.data) {
const priceId = item.price.id;
// Check if price ID matches any license price
const licensePrices = Object.values(getLicensePriceSet());
if (licensePrices.includes(priceId)) {
return "license";
}
// Check if price ID matches home lab tier
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
if (homeLabPrices.includes(priceId)) {
return "tier1";
}
// Check if price ID matches tier2 tier
const tier2Prices = Object.values(getStarterFeaturePriceSet());
if (tier2Prices.includes(priceId)) {
return "tier2";
}
// Check if price ID matches tier3 tier
const tier3Prices = Object.values(getScaleFeaturePriceSet());
if (tier3Prices.includes(priceId)) {
return "tier3";
}
}
return null;
}
@@ -25,13 +25,6 @@ import logger from "@server/logger";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
import { sendEmail } from "@server/emails";
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
import config from "@server/lib/config";
import { getFeatureIdByPriceId } from "@server/lib/billing/features";
export async function handleSubscriptionCreated( export async function handleSubscriptionCreated(
subscription: Stripe.Subscription subscription: Stripe.Subscription
@@ -60,8 +53,6 @@ export async function handleSubscriptionCreated(
return; return;
} }
const type = getSubType(fullSubscription);
const newSubscription = { const newSubscription = {
subscriptionId: subscription.id, subscriptionId: subscription.id,
customerId: subscription.customer as string, customerId: subscription.customer as string,
@@ -69,9 +60,7 @@ export async function handleSubscriptionCreated(
canceledAt: subscription.canceled_at canceledAt: subscription.canceled_at
? subscription.canceled_at ? subscription.canceled_at
: null, : null,
createdAt: subscription.created, createdAt: subscription.created
type: type,
version: 1 // we are hardcoding the initial version when the subscription is created, and then we will increment it on every update
}; };
await db.insert(subscriptions).values(newSubscription); await db.insert(subscriptions).values(newSubscription);
@@ -92,15 +81,10 @@ export async function handleSubscriptionCreated(
name = product.name || null; name = product.name || null;
} }
// Get the feature ID from the price ID
const featureId = getFeatureIdByPriceId(item.price.id);
return { return {
stripeSubscriptionItemId: item.id,
subscriptionId: subscription.id, subscriptionId: subscription.id,
planId: item.plan.id, planId: item.plan.id,
priceId: item.price.id, priceId: item.price.id,
featureId: featureId || null,
meterId: item.plan.meter, meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0, unitAmount: item.price.unit_amount || 0,
currentPeriodStart: item.current_period_start, currentPeriodStart: item.current_period_start,
@@ -139,142 +123,24 @@ export async function handleSubscriptionCreated(
return; return;
} }
if (type === "tier1" || type === "tier2" || type === "tier3") { await handleSubscriptionLifesycle(customer.orgId, subscription.status);
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status,
type
);
const [orgUserRes] = await db const [orgUserRes] = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
.where( .where(
and( and(
eq(userOrgs.orgId, customer.orgId), eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true) eq(userOrgs.isOwner, true)
)
) )
.innerJoin(users, eq(userOrgs.userId, users.userId)); )
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) { if (orgUserRes) {
const email = orgUserRes.user.email; const email = orgUserRes.user.email;
if (email) { if (email) {
moveEmailToAudience(email, AudienceIds.Subscribed); moveEmailToAudience(email, AudienceIds.Subscribed);
}
}
} else if (type === "license") {
logger.debug(
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
);
// Retrieve the client_reference_id from the checkout session
let licenseId: string | null = null;
try {
const sessions = await stripe!.checkout.sessions.list({
subscription: subscription.id,
limit: 1
});
if (sessions.data.length > 0) {
licenseId = sessions.data[0].client_reference_id || null;
}
if (!licenseId) {
logger.error(
`No client_reference_id found for subscription ${subscription.id}`
);
return;
}
logger.debug(
`Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}`
);
// Determine users and sites based on license type
const priceSet = getLicensePriceSet();
const subscriptionPriceId =
fullSubscription.items.data[0]?.price.id;
let numUsers: number;
let numSites: number;
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) {
numUsers = 25;
numSites = 25;
} else if (
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
) {
numUsers = 50;
numSites = 50;
} else {
logger.error(
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
);
return;
}
logger.debug(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
);
const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseId),
paidFor: true,
users: numUsers,
sites: numSites
})
}
);
const data = await response.json();
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (customer.email) {
logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
);
await sendEmail(
EnterpriseEditionKeyGenerated({
keyValue: data.data.licenseKey,
personalUseOnly: false,
users: numUsers,
sites: numSites,
modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing`
}),
{
to: customer.email,
from: config.getNoReplyEmail(),
subject:
"Your Enterprise Edition license key is ready"
}
);
} else {
logger.error(
`No email found for customer ${customer.customerId} to send license key.`
);
}
return data;
} catch (error) {
console.error("Error creating new license:", error);
throw error;
} }
} }
} catch (error) { } catch (error) {
@@ -24,22 +24,11 @@ import { eq, and } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import stripe from "#private/lib/stripe";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionDeleted( export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription subscription: Stripe.Subscription
): Promise<void> { ): Promise<void> {
try { try {
// Fetch the subscription from Stripe with expanded price.tiers
const fullSubscription = await stripe!.subscriptions.retrieve(
subscription.id,
{
expand: ["items.data.price.tiers"]
}
);
const [existingSubscription] = await db const [existingSubscription] = await db
.select() .select()
.from(subscriptions) .from(subscriptions)
@@ -75,63 +64,24 @@ export async function handleSubscriptionDeleted(
return; return;
} }
const type = getSubType(fullSubscription); await handleSubscriptionLifesycle(customer.orgId, subscription.status);
if (type == "tier1" || type == "tier2" || type == "tier3") {
logger.debug(
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
await handleSubscriptionLifesycle( const [orgUserRes] = await db
customer.orgId, .select()
subscription.status, .from(userOrgs)
type .where(
); and(
eq(userOrgs.orgId, customer.orgId),
const [orgUserRes] = await db eq(userOrgs.isOwner, true)
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
) )
.innerJoin(users, eq(userOrgs.userId, users.userId)); )
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) { if (orgUserRes) {
const email = orgUserRes.user.email; const email = orgUserRes.user.email;
if (email) { if (email) {
moveEmailToAudience(email, AudienceIds.Churned); moveEmailToAudience(email, AudienceIds.Churned);
}
}
} else if (type === "license") {
logger.debug(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId,
})
}
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
} }
} }
} catch (error) { } catch (error) {
@@ -23,11 +23,9 @@ import {
} from "@server/db"; } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import { getFeatureIdByMetricId } from "@server/lib/billing/features";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionUpdated( export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription, subscription: Stripe.Subscription,
@@ -58,14 +56,12 @@ export async function handleSubscriptionUpdated(
} }
// get the customer // get the customer
const [customer] = await db const [existingCustomer] = await db
.select() .select()
.from(customers) .from(customers)
.where(eq(customers.customerId, subscription.customer as string)) .where(eq(customers.customerId, subscription.customer as string))
.limit(1); .limit(1);
const type = getSubType(fullSubscription);
await db await db
.update(subscriptions) .update(subscriptions)
.set({ .set({
@@ -74,47 +70,30 @@ export async function handleSubscriptionUpdated(
? subscription.canceled_at ? subscription.canceled_at
: null, : null,
updatedAt: Math.floor(Date.now() / 1000), updatedAt: Math.floor(Date.now() / 1000),
billingCycleAnchor: subscription.billing_cycle_anchor, billingCycleAnchor: subscription.billing_cycle_anchor
type: type
}) })
.where(eq(subscriptions.subscriptionId, subscription.id)); .where(eq(subscriptions.subscriptionId, subscription.id));
await handleSubscriptionLifesycle(
existingCustomer.orgId,
subscription.status
);
// Upsert subscription items // Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) { if (Array.isArray(fullSubscription.items?.data)) {
// First, get existing items to preserve featureId when there's no match const itemsToUpsert = fullSubscription.items.data.map((item) => ({
const existingItems = await db subscriptionId: subscription.id,
.select() planId: item.plan.id,
.from(subscriptionItems) priceId: item.price.id,
.where(eq(subscriptionItems.subscriptionId, subscription.id)); meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0,
const itemsToUpsert = fullSubscription.items.data.map((item) => { currentPeriodStart: item.current_period_start,
// Try to get featureId from price currentPeriodEnd: item.current_period_end,
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null; tiers: item.price.tiers
? JSON.stringify(item.price.tiers)
// If no match, try to preserve existing featureId : null,
if (!featureId) { interval: item.plan.interval
const existingItem = existingItems.find( }));
(ei) => ei.stripeSubscriptionItemId === item.id
);
featureId = existingItem?.featureId || null;
}
return {
stripeSubscriptionItemId: item.id,
subscriptionId: subscription.id,
planId: item.plan.id,
priceId: item.price.id,
featureId: featureId,
meterId: item.plan.meter,
unitAmount: item.price.unit_amount || 0,
currentPeriodStart: item.current_period_start,
currentPeriodEnd: item.current_period_end,
tiers: item.price.tiers
? JSON.stringify(item.price.tiers)
: null,
interval: item.plan.interval
};
});
if (itemsToUpsert.length > 0) { if (itemsToUpsert.length > 0) {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx
@@ -162,20 +141,20 @@ export async function handleSubscriptionUpdated(
// This item has cycled // This item has cycled
const meterId = item.plan.meter; const meterId = item.plan.meter;
if (!meterId) { if (!meterId) {
logger.debug( logger.warn(
`No meterId found for subscription item ${item.id}. Skipping usage reset.` `No meterId found for subscription item ${item.id}. Skipping usage reset.`
); );
continue; continue;
} }
const featureId = getFeatureIdByMetricId(meterId); const featureId = getFeatureIdByMetricId(meterId);
if (!featureId) { if (!featureId) {
logger.debug( logger.warn(
`No featureId found for meterId ${meterId}. Skipping usage reset.` `No featureId found for meterId ${meterId}. Skipping usage reset.`
); );
continue; continue;
} }
const orgId = customer.orgId; const orgId = existingCustomer.orgId;
if (!orgId) { if (!orgId) {
logger.warn( logger.warn(
@@ -257,45 +236,6 @@ export async function handleSubscriptionUpdated(
} }
} }
// --- end usage update --- // --- end usage update ---
if (type === "tier1" || type === "tier2" || type === "tier3") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status,
type
);
} else if (type === "license") {
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId
})
}
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
}
}
}
} }
} catch (error) { } catch (error) {
logger.error( logger.error(
+1 -2
View File
@@ -13,7 +13,6 @@
export * from "./createCheckoutSession"; export * from "./createCheckoutSession";
export * from "./createPortalSession"; export * from "./createPortalSession";
export * from "./getOrgSubscriptions"; export * from "./getOrgSubscription";
export * from "./getOrgUsage"; export * from "./getOrgUsage";
export * from "./internalGetOrgTier"; export * from "./internalGetOrgTier";
export * from "./changeTier";
@@ -13,66 +13,38 @@
import { import {
freeLimitSet, freeLimitSet,
tier1LimitSet,
tier2LimitSet,
tier3LimitSet,
limitsService, limitsService,
LimitSet subscribedLimitSet
} from "@server/lib/billing"; } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { SubscriptionType } from "./hooks/getSubType"; import logger from "@server/logger";
function getLimitSetForSubscriptionType(
subType: SubscriptionType | null
): LimitSet {
switch (subType) {
case "tier1":
return tier1LimitSet;
case "tier2":
return tier2LimitSet;
case "tier3":
return tier3LimitSet;
case "license":
// License subscriptions use tier2 limits by default
// This can be adjusted based on your business logic
return tier2LimitSet;
default:
return freeLimitSet;
}
}
export async function handleSubscriptionLifesycle( export async function handleSubscriptionLifesycle(
orgId: string, orgId: string,
status: string, status: string
subType: SubscriptionType | null
) { ) {
switch (status) { switch (status) {
case "active": case "active":
const activeLimitSet = getLimitSetForSubscriptionType(subType); await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet);
await limitsService.applyLimitSetToOrg(orgId, activeLimitSet); await usageService.checkLimitSet(orgId, true);
await usageService.checkLimitSet(orgId);
break; break;
case "canceled": case "canceled":
// Subscription canceled - revert to free tier
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId); await usageService.checkLimitSet(orgId, true);
break; break;
case "past_due": case "past_due":
// Payment past due - keep current limits but notify customer // Optionally handle past due status, e.g., notify customer
// Limits will revert to free tier if it becomes unpaid
break; break;
case "unpaid": case "unpaid":
// Subscription unpaid - revert to free tier
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId); await usageService.checkLimitSet(orgId, true);
break; break;
case "incomplete": case "incomplete":
// Payment incomplete - give them time to complete payment // Optionally handle incomplete status, e.g., notify customer
break; break;
case "incomplete_expired": case "incomplete_expired":
// Payment never completed - revert to free tier
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
await usageService.checkLimitSet(orgId); await usageService.checkLimitSet(orgId, true);
break; break;
default: default:
break; break;
+26 -34
View File
@@ -52,7 +52,6 @@ import {
authenticated as a, authenticated as a,
authRouter as aa authRouter as aa
} from "@server/routers/external"; } from "@server/routers/external";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export const authenticated = a; export const authenticated = a;
export const unauthenticated = ua; export const unauthenticated = ua;
@@ -77,7 +76,6 @@ unauthenticated.post(
authenticated.put( authenticated.put(
"/org/:orgId/idp/oidc", "/org/:orgId/idp/oidc",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createIdp), verifyUserHasAction(ActionsEnum.createIdp),
logActionAudit(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp),
@@ -87,7 +85,6 @@ authenticated.put(
authenticated.post( authenticated.post(
"/org/:orgId/idp/:idpId/oidc", "/org/:orgId/idp/:idpId/oidc",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.orgOidc),
verifyOrgAccess, verifyOrgAccess,
verifyIdpAccess, verifyIdpAccess,
verifyUserHasAction(ActionsEnum.updateIdp), verifyUserHasAction(ActionsEnum.updateIdp),
@@ -144,6 +141,23 @@ authenticated.post(
); );
if (build === "saas") { if (build === "saas") {
unauthenticated.post(
"/quick-start",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.path,
handler: (req, res, next) => {
const message = `We're too busy right now. Please try again later.`;
return next(
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
);
},
store: createStore()
}),
auth.quickStart
);
authenticated.post( authenticated.post(
"/org/:orgId/billing/create-checkout-session", "/org/:orgId/billing/create-checkout-session",
verifyOrgAccess, verifyOrgAccess,
@@ -152,14 +166,6 @@ if (build === "saas") {
billing.createCheckoutSession billing.createCheckoutSession
); );
authenticated.post(
"/org/:orgId/billing/change-tier",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
billing.changeTier
);
authenticated.post( authenticated.post(
"/org/:orgId/billing/create-portal-session", "/org/:orgId/billing/create-portal-session",
verifyOrgAccess, verifyOrgAccess,
@@ -169,10 +175,10 @@ if (build === "saas") {
); );
authenticated.get( authenticated.get(
"/org/:orgId/billing/subscriptions", "/org/:orgId/billing/subscription",
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing), verifyUserHasAction(ActionsEnum.billing),
billing.getOrgSubscriptions billing.getOrgSubscription
); );
authenticated.get( authenticated.get(
@@ -194,14 +200,6 @@ if (build === "saas") {
generateLicense.generateNewLicense generateLicense.generateNewLicense
); );
authenticated.put(
"/org/:orgId/license/enterprise",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
generateLicense.generateNewEnterpriseLicense
);
authenticated.post( authenticated.post(
"/send-support-request", "/send-support-request",
rateLimit({ rateLimit({
@@ -280,7 +278,6 @@ authenticated.delete(
authenticated.put( authenticated.put(
"/org/:orgId/login-page", "/org/:orgId/login-page",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.customAuthenticationDomain),
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createLoginPage), verifyUserHasAction(ActionsEnum.createLoginPage),
logActionAudit(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage),
@@ -290,7 +287,6 @@ authenticated.put(
authenticated.post( authenticated.post(
"/org/:orgId/login-page/:loginPageId", "/org/:orgId/login-page/:loginPageId",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.customAuthenticationDomain),
verifyOrgAccess, verifyOrgAccess,
verifyLoginPageAccess, verifyLoginPageAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage), verifyUserHasAction(ActionsEnum.updateLoginPage),
@@ -319,7 +315,6 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/approvals", "/org/:orgId/approvals",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.deviceApprovals),
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listApprovals), verifyUserHasAction(ActionsEnum.listApprovals),
logActionAudit(ActionsEnum.listApprovals), logActionAudit(ActionsEnum.listApprovals),
@@ -336,7 +331,6 @@ authenticated.get(
authenticated.put( authenticated.put(
"/org/:orgId/approvals/:approvalId", "/org/:orgId/approvals/:approvalId",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.deviceApprovals),
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateApprovals), verifyUserHasAction(ActionsEnum.updateApprovals),
logActionAudit(ActionsEnum.updateApprovals), logActionAudit(ActionsEnum.updateApprovals),
@@ -346,7 +340,6 @@ authenticated.put(
authenticated.get( authenticated.get(
"/org/:orgId/login-page-branding", "/org/:orgId/login-page-branding",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.loginPageBranding),
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getLoginPage), verifyUserHasAction(ActionsEnum.getLoginPage),
logActionAudit(ActionsEnum.getLoginPage), logActionAudit(ActionsEnum.getLoginPage),
@@ -356,7 +349,6 @@ authenticated.get(
authenticated.put( authenticated.put(
"/org/:orgId/login-page-branding", "/org/:orgId/login-page-branding",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.loginPageBranding),
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage), verifyUserHasAction(ActionsEnum.updateLoginPage),
logActionAudit(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage),
@@ -433,7 +425,7 @@ authenticated.post(
authenticated.get( authenticated.get(
"/org/:orgId/logs/action", "/org/:orgId/logs/action",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.actionLogs), verifyValidSubscription,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs), verifyUserHasAction(ActionsEnum.exportLogs),
logs.queryActionAuditLogs logs.queryActionAuditLogs
@@ -442,7 +434,7 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/logs/action/export", "/org/:orgId/logs/action/export",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.logExport), verifyValidSubscription,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs), verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs),
@@ -452,7 +444,7 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/logs/access", "/org/:orgId/logs/access",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.accessLogs), verifyValidSubscription,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs), verifyUserHasAction(ActionsEnum.exportLogs),
logs.queryAccessAuditLogs logs.queryAccessAuditLogs
@@ -461,7 +453,7 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/logs/access/export", "/org/:orgId/logs/access/export",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.logExport), verifyValidSubscription,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs), verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs),
@@ -472,7 +464,7 @@ authenticated.post(
"/re-key/:clientId/regenerate-client-secret", "/re-key/:clientId/regenerate-client-secret",
verifyClientAccess, // this is first to set the org id verifyClientAccess, // this is first to set the org id
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials), verifyValidSubscription,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateClientSecret reKey.reGenerateClientSecret
); );
@@ -481,7 +473,7 @@ authenticated.post(
"/re-key/:siteId/regenerate-site-secret", "/re-key/:siteId/regenerate-site-secret",
verifySiteAccess, // this is first to set the org id verifySiteAccess, // this is first to set the org id
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials), verifyValidSubscription,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateSiteSecret reKey.reGenerateSiteSecret
); );
@@ -489,7 +481,7 @@ authenticated.post(
authenticated.put( authenticated.put(
"/re-key/:orgId/regenerate-remote-exit-node-secret", "/re-key/:orgId/regenerate-remote-exit-node-secret",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials), verifyValidSubscription,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateExitNodeSecret reKey.reGenerateExitNodeSecret
@@ -1,149 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import privateConfig from "#private/lib/config";
import { createNewLicense } from "./generateNewLicense";
import config from "@server/lib/config";
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
import stripe from "#private/lib/stripe";
import { customers, db } from "@server/db";
import { fromError } from "zod-validation-error";
import z from "zod";
import { eq } from "drizzle-orm";
import { log } from "winston";
const generateNewEnterpriseLicenseParamsSchema = z.strictObject({
orgId: z.string()
});
export async function generateNewEnterpriseLicense(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required"
)
);
}
logger.debug(`Generating new license for orgId: ${orgId}`);
const licenseData = req.body;
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid tier specified. Must be either 'big_license' or 'small_license'."
)
);
}
const apiResponse = await createNewLicense(orgId, licenseData);
// Check if the API call was successful
if (!apiResponse.success || apiResponse.error) {
return next(
createHttpError(
apiResponse.status || HttpCode.BAD_REQUEST,
apiResponse.message || "Failed to create license from Fossorial API"
)
);
}
const keyId = apiResponse?.data?.licenseKey?.id;
if (!keyId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Fossorial API did not return a valid license key ID"
)
);
}
// check if we already have a customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
// If we don't have a customer, create one
if (!customer) {
// error
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
}
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier]
const session = await stripe!.checkout.sessions.create({
client_reference_id: keyId.toString(),
billing_address_collection: "required",
line_items: [
{
price: tierPrice, // Use the standard tier
quantity: 1
},
], // Start with the standard feature set that matches the free limits
customer: customer.customerId,
mode: "subscription",
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
});
return sendResponse<string>(res, {
data: session.url,
success: true,
error: false,
message: "License and checkout session created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while generating new license."
)
);
}
}
@@ -19,40 +19,10 @@ import { response as sendResponse } from "@server/lib/response";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
export interface CreateNewLicenseResponse { async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
data: Data
success: boolean
error: boolean
message: string
status: number
}
export interface Data {
licenseKey: LicenseKey
}
export interface LicenseKey {
id: number
instanceName: any
instanceId: string
licenseKey: string
tier: string
type: string
quantity: number
quantity_2: number
isValid: boolean
updatedAt: string
createdAt: string
expiresAt: string
paidFor: boolean
orgId: string
metadata: string
}
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
try { try {
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
{ {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -65,8 +35,9 @@ export async function createNewLicense(orgId: string, licenseData: any): Promise
} }
); );
const data: CreateNewLicenseResponse = await response.json(); const data = await response.json();
logger.debug("Fossorial API response:", { data });
return data; return data;
} catch (error) { } catch (error) {
console.error("Error creating new license:", error); console.error("Error creating new license:", error);
@@ -13,4 +13,3 @@
export * from "./listGeneratedLicenses"; export * from "./listGeneratedLicenses";
export * from "./generateNewLicense"; export * from "./generateNewLicense";
export * from "./generateNewEnterpriseLicense";
@@ -25,7 +25,7 @@ import {
async function fetchLicenseKeys(orgId: string): Promise<any> { async function fetchLicenseKeys(orgId: string): Promise<any> {
try { try {
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`, `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
{ {
method: "GET", method: "GET",
headers: { headers: {
+12 -3
View File
@@ -186,7 +186,7 @@ export type ResourceWithAuth = {
password: ResourcePassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org; org: Org
}; };
export type UserSessionWithUser = { export type UserSessionWithUser = {
@@ -270,6 +270,7 @@ hybridRouter.get(
} }
); );
let encryptionKeyPath = "";
let encryptionKeyHex = ""; let encryptionKeyHex = "";
let encryptionKey: Buffer; let encryptionKey: Buffer;
function loadEncryptData() { function loadEncryptData() {
@@ -277,8 +278,16 @@ function loadEncryptData() {
return; // already loaded return; // already loaded
} }
encryptionKeyHex = encryptionKeyPath =
privateConfig.getRawPrivateConfig().server.encryption_key; privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex"); encryptionKey = Buffer.from(encryptionKeyHex, "hex");
} }
@@ -30,7 +30,9 @@ import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#private/routers/certificates/createCertificate"; import { createCertificate } from "#private/routers/certificates/createCertificate";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
import { CreateLoginPageResponse } from "@server/routers/loginPage/types"; import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -74,6 +76,19 @@ export async function createLoginPage(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existing] = await db const [existing] = await db
.select() .select()
.from(loginPageOrg) .from(loginPageOrg)
@@ -25,7 +25,9 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -51,6 +53,18 @@ export async function deleteLoginPageBranding(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db const [existingLoginPageBranding] = await db
.select() .select()
@@ -25,7 +25,9 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -49,6 +51,19 @@ export async function getLoginPageBranding(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db const [existingLoginPageBranding] = await db
.select() .select()
.from(loginPageBranding) .from(loginPageBranding)
@@ -23,7 +23,9 @@ import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
import { createCertificate } from "#private/routers/certificates/createCertificate"; import { createCertificate } from "#private/routers/certificates/createCertificate";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types"; import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
const paramsSchema = z const paramsSchema = z
@@ -85,6 +87,18 @@ export async function updateLoginPage(
const { loginPageId, orgId } = parsedParams.data; const { loginPageId, orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPage] = await db const [existingLoginPage] = await db
.select() .select()
@@ -25,8 +25,10 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm"; import { eq, InferInsertModel } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build"; import { build } from "@server/build";
import config from "#private/lib/config"; import config from "@server/private/lib/config";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -35,55 +37,27 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
logoUrl: z logoUrl: z
.union([ .union([
z.literal(""), z.string().length(0),
z z.url().refine(
.url("Must be a valid URL") async (url) => {
.superRefine(async (url, ctx) => {
try { try {
const response = await fetch(url, { const response = await fetch(url);
method: "HEAD" return (
}).catch(() => { response.status === 200 &&
// If HEAD fails (CORS or method not allowed), try GET (
return fetch(url, { method: "GET" }); response.headers.get("content-type") ?? ""
}); ).startsWith("image/")
);
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) { } catch (error) {
let errorMessage = return false;
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
} }
}) },
{
error: "Invalid logo URL, must be a valid image URL"
}
)
]) ])
.transform((val) => (val === "" ? null : val)) .optional(),
.nullish(),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(), resourceTitle: z.string(),
@@ -104,7 +78,7 @@ export async function upsertLoginPageBranding(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedBody = await bodySchema.safeParseAsync(req.body); const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
return next( return next(
createHttpError( createHttpError(
@@ -126,12 +100,26 @@ export async function upsertLoginPageBranding(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
let updateData = parsedBody.data satisfies InferInsertModel< let updateData = parsedBody.data satisfies InferInsertModel<
typeof loginPageBranding typeof loginPageBranding
>; >;
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database if ((updateData.logoUrl ?? "").trim().length === 0) {
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates updateData.logoUrl = undefined;
}
if ( if (
build !== "saas" && build !== "saas" &&
@@ -24,6 +24,9 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { encrypt } from "@server/lib/crypto"; import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
@@ -106,6 +109,19 @@ export async function createOrgOidcIdp(
tags tags
} = parsedBody.data; } = parsedBody.data;
if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const key = config.getRawConfig().server.secret!; const key = config.getRawConfig().server.secret!;
const encryptedSecret = encrypt(clientSecret, key); const encryptedSecret = encrypt(clientSecret, key);
@@ -24,6 +24,9 @@ import { idp, idpOidcConfig } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto"; import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -111,6 +114,19 @@ export async function updateOrgOidcIdp(
tags tags
} = parsedBody.data; } = parsedBody.data;
if (build === "saas") {
const { tier, active } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
// Check if IDP exists and is of type OIDC // Check if IDP exists and is of type OIDC
const [existingIdp] = await db const [existingIdp] = await db
.select() .select()
@@ -85,7 +85,7 @@ export async function createRemoteExitNode(
if (usage) { if (usage) {
const rejectRemoteExitNodes = await usageService.checkLimitSet( const rejectRemoteExitNodes = await usageService.checkLimitSet(
orgId, orgId,
false,
FeatureId.REMOTE_EXIT_NODES, FeatureId.REMOTE_EXIT_NODES,
{ {
...usage, ...usage,
@@ -97,7 +97,7 @@ export async function createRemoteExitNode(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Remote node limit exceeded. Please upgrade your plan." "Remote exit node limit exceeded. Please upgrade your plan or contact us at support@pangolin.net"
) )
); );
} }
@@ -224,7 +224,7 @@ export async function createRemoteExitNode(
}); });
if (numExitNodeOrgs) { if (numExitNodeOrgs) {
await usageService.updateCount( await usageService.updateDaily(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES, FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length numExitNodeOrgs.length
@@ -106,7 +106,7 @@ export async function deleteRemoteExitNode(
}); });
if (numExitNodeOrgs) { if (numExitNodeOrgs) {
await usageService.updateCount( await usageService.updateDaily(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES, FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length numExitNodeOrgs.length
+9 -19
View File
@@ -1,6 +1,6 @@
import { db, orgs, requestAuditLog } from "@server/db"; import { db, orgs, requestAuditLog } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, lt, sql } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip"; import { stripPortFromHost } from "@server/lib/ip";
@@ -67,27 +67,17 @@ async function flushAuditLogs() {
const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length);
try { try {
// Use a transaction to ensure all inserts succeed or fail together // Batch insert logs in groups of 25 to avoid overwhelming the database
// This prevents index corruption from partial writes const BATCH_DB_SIZE = 25;
await db.transaction(async (tx) => { for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
// Batch insert logs in groups of 25 to avoid overwhelming the database const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
const BATCH_DB_SIZE = 25; await db.insert(requestAuditLog).values(batch);
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { }
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
await tx.insert(requestAuditLog).values(batch);
}
});
logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); logger.debug(`Flushed ${logsToWrite.length} audit logs to database`);
} catch (error) { } catch (error) {
logger.error("Error flushing audit logs:", error); logger.error("Error flushing audit logs:", error);
// On transaction error, put logs back at the front of the buffer to retry // On error, we lose these logs - consider a fallback strategy if needed
// but only if buffer isn't too large // (e.g., write to file, or put back in buffer with retry limit)
if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
auditLogBuffer.unshift(...logsToWrite);
logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`);
} else {
logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`);
}
} finally { } finally {
isFlushInProgress = false; isFlushInProgress = false;
// If buffer filled up while we were flushing, flush again // If buffer filled up while we were flushing, flush again
+7 -5
View File
@@ -18,6 +18,7 @@ import {
ResourcePassword, ResourcePassword,
ResourcePincode, ResourcePincode,
ResourceRule, ResourceRule,
resourceSessions
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -31,6 +32,7 @@ import { fromError } from "zod-validation-error";
import { getCountryCodeForIp } from "@server/lib/geoip"; import { getCountryCodeForIp } from "@server/lib/geoip";
import { getAsnForIp } from "@server/lib/asn"; import { getAsnForIp } from "@server/lib/asn";
import { getOrgTierData } from "#dynamic/lib/billing"; import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { import {
checkOrgAccessPolicy, checkOrgAccessPolicy,
@@ -38,8 +40,8 @@ import {
} from "#dynamic/lib/checkOrgAccessPolicy"; } from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit"; import { logRequestAudit } from "./logRequestAudit";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
import semver from "semver";
import { APP_VERSION } from "@server/lib/consts"; import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#private/lib/isSubscribed";
const verifyResourceSessionSchema = z.object({ const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(), sessions: z.record(z.string(), z.string()).optional(),
@@ -796,8 +798,8 @@ async function notAllowed(
) { ) {
let loginPage: LoginPage | null = null; let loginPage: LoginPage | null = null;
if (orgId) { if (orgId) {
const subscribed = await isSubscribed(orgId); const { tier } = await getOrgTierData(orgId); // returns null in oss
if (subscribed) { if (tier === TierId.STANDARD) {
loginPage = await getOrgLoginPage(orgId); loginPage = await getOrgLoginPage(orgId);
} }
} }
@@ -850,8 +852,8 @@ async function headerAuthChallenged(
) { ) {
let loginPage: LoginPage | null = null; let loginPage: LoginPage | null = null;
if (orgId) { if (orgId) {
const subscribed = await isSubscribed(orgId); const { tier } = await getOrgTierData(orgId); // returns null in oss
if (subscribed) { if (tier === TierId.STANDARD) {
loginPage = await getOrgLoginPage(orgId); loginPage = await getOrgLoginPage(orgId);
} }
} }
+2 -1
View File
@@ -1,7 +1,8 @@
import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db"; import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
export type GetOrgSubscriptionResponse = { export type GetOrgSubscriptionResponse = {
subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>; subscription: Subscription | null;
items: SubscriptionItem[];
}; };
export type GetOrgUsageResponse = { export type GetOrgUsageResponse = {
@@ -26,8 +26,7 @@ const applyBlueprintSchema = z
message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}` message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}`
}); });
} }
}), })
source: z.enum(["API", "UI", "CLI"]).optional()
}) })
.strict(); .strict();
@@ -85,7 +84,7 @@ export async function applyYAMLBlueprint(
); );
} }
const { blueprint: contents, name, source = "UI" } = parsedBody.data; const { blueprint: contents, name } = parsedBody.data;
logger.debug(`Received blueprint:`, contents); logger.debug(`Received blueprint:`, contents);
@@ -108,7 +107,7 @@ export async function applyYAMLBlueprint(
blueprint = await applyBlueprint({ blueprint = await applyBlueprint({
orgId, orgId,
name, name,
source, source: "UI",
configData: parsedConfig configData: parsedConfig
}); });
} catch (err) { } catch (err) {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Blueprint } from "@server/db"; import type { Blueprint } from "@server/db";
export type BlueprintSource = "API" | "UI" | "NEWT" | "CLI"; export type BlueprintSource = "API" | "UI" | "NEWT";
export type BlueprintData = Omit<Blueprint, "source"> & { export type BlueprintData = Omit<Blueprint, "source"> & {
source: BlueprintSource; source: BlueprintSource;
+6
View File
@@ -9,6 +9,9 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const archiveClientSchema = z.strictObject({ const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -74,6 +77,9 @@ export async function archiveClient(
.update(clients) .update(clients)
.set({ archived: true }) .set({ archived: true })
.where(eq(clients.clientId, clientId)); .where(eq(clients.clientId, clientId));
// Rebuild associations to clean up related data
await rebuildClientAssociationsFromClient(client, trx);
}); });
return response(res, { return response(res, {
+49 -123
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, olms, users } from "@server/db"; import { db, olms } from "@server/db";
import { clients, currentFingerprint } from "@server/db"; import { clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -12,8 +12,6 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const getClientSchema = z.strictObject({ const getClientSchema = z.strictObject({
clientId: z clientId: z
@@ -37,7 +35,6 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
currentFingerprint, currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId) eq(olms.olmId, currentFingerprint.olmId)
) )
.leftJoin(users, eq(clients.userId, users.userId))
.limit(1); .limit(1);
return res; return res;
} else if (niceId && orgId) { } else if (niceId && orgId) {
@@ -50,36 +47,25 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
currentFingerprint, currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId) eq(olms.olmId, currentFingerprint.olmId)
) )
.leftJoin(users, eq(clients.userId, users.userId))
.limit(1); .limit(1);
return res; return res;
} }
} }
type PostureData = { type PostureData = {
biometricsEnabled?: boolean | null | "-"; biometricsEnabled?: boolean | null;
diskEncrypted?: boolean | null | "-"; diskEncrypted?: boolean | null;
firewallEnabled?: boolean | null | "-"; firewallEnabled?: boolean | null;
autoUpdatesEnabled?: boolean | null | "-"; autoUpdatesEnabled?: boolean | null;
tpmAvailable?: boolean | null | "-"; tpmAvailable?: boolean | null;
windowsAntivirusEnabled?: boolean | null | "-"; windowsDefenderEnabled?: boolean | null;
macosSipEnabled?: boolean | null | "-"; macosSipEnabled?: boolean | null;
macosGatekeeperEnabled?: boolean | null | "-"; macosGatekeeperEnabled?: boolean | null;
macosFirewallStealthMode?: boolean | null | "-"; macosFirewallStealthMode?: boolean | null;
linuxAppArmorEnabled?: boolean | null | "-"; linuxAppArmorEnabled?: boolean | null;
linuxSELinuxEnabled?: boolean | null | "-"; linuxSELinuxEnabled?: boolean | null;
}; };
function maskPostureDataWithPlaceholder(posture: PostureData): PostureData {
const masked: PostureData = {};
for (const key of Object.keys(posture) as (keyof PostureData)[]) {
if (posture[key] !== undefined && posture[key] !== null) {
(masked as Record<keyof PostureData, "-">)[key] = "-";
}
}
return masked;
}
function getPlatformPostureData( function getPlatformPostureData(
platform: string | null | undefined, platform: string | null | undefined,
fingerprint: typeof currentFingerprint.$inferSelect | null fingerprint: typeof currentFingerprint.$inferSelect | null
@@ -89,123 +75,78 @@ function getPlatformPostureData(
const normalizedPlatform = platform?.toLowerCase() || "unknown"; const normalizedPlatform = platform?.toLowerCase() || "unknown";
const posture: PostureData = {}; const posture: PostureData = {};
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Antivirus status // Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Defender
if (normalizedPlatform === "windows") { if (normalizedPlatform === "windows") {
if ( if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted; posture.diskEncrypted = fingerprint.diskEncrypted;
} }
if ( if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled; posture.firewallEnabled = fingerprint.firewallEnabled;
} }
if ( if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
fingerprint.tpmAvailable !== null && posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
fingerprint.tpmAvailable !== undefined }
) { if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
posture.tpmAvailable = fingerprint.tpmAvailable; posture.tpmAvailable = fingerprint.tpmAvailable;
} }
if ( if (fingerprint.windowsDefenderEnabled !== null && fingerprint.windowsDefenderEnabled !== undefined) {
fingerprint.windowsAntivirusEnabled !== null && posture.windowsDefenderEnabled = fingerprint.windowsDefenderEnabled;
fingerprint.windowsAntivirusEnabled !== undefined
) {
posture.windowsAntivirusEnabled =
fingerprint.windowsAntivirusEnabled;
} }
} }
// macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode // macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode
else if (normalizedPlatform === "macos") { else if (normalizedPlatform === "macos") {
if ( if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted; posture.diskEncrypted = fingerprint.diskEncrypted;
} }
if ( if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
fingerprint.biometricsEnabled !== null &&
fingerprint.biometricsEnabled !== undefined
) {
posture.biometricsEnabled = fingerprint.biometricsEnabled; posture.biometricsEnabled = fingerprint.biometricsEnabled;
} }
if ( if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled; posture.firewallEnabled = fingerprint.firewallEnabled;
} }
if ( if (fingerprint.macosSipEnabled !== null && fingerprint.macosSipEnabled !== undefined) {
fingerprint.macosSipEnabled !== null &&
fingerprint.macosSipEnabled !== undefined
) {
posture.macosSipEnabled = fingerprint.macosSipEnabled; posture.macosSipEnabled = fingerprint.macosSipEnabled;
} }
if ( if (fingerprint.macosGatekeeperEnabled !== null && fingerprint.macosGatekeeperEnabled !== undefined) {
fingerprint.macosGatekeeperEnabled !== null &&
fingerprint.macosGatekeeperEnabled !== undefined
) {
posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled; posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled;
} }
if ( if (fingerprint.macosFirewallStealthMode !== null && fingerprint.macosFirewallStealthMode !== undefined) {
fingerprint.macosFirewallStealthMode !== null && posture.macosFirewallStealthMode = fingerprint.macosFirewallStealthMode;
fingerprint.macosFirewallStealthMode !== undefined
) {
posture.macosFirewallStealthMode =
fingerprint.macosFirewallStealthMode;
} }
if ( if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
fingerprint.autoUpdatesEnabled !== null &&
fingerprint.autoUpdatesEnabled !== undefined
) {
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled; posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
} }
} }
// Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability // Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability
else if (normalizedPlatform === "linux") { else if (normalizedPlatform === "linux") {
if ( if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted; posture.diskEncrypted = fingerprint.diskEncrypted;
} }
if ( if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled; posture.firewallEnabled = fingerprint.firewallEnabled;
} }
if ( if (fingerprint.linuxAppArmorEnabled !== null && fingerprint.linuxAppArmorEnabled !== undefined) {
fingerprint.linuxAppArmorEnabled !== null &&
fingerprint.linuxAppArmorEnabled !== undefined
) {
posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled; posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled;
} }
if ( if (fingerprint.linuxSELinuxEnabled !== null && fingerprint.linuxSELinuxEnabled !== undefined) {
fingerprint.linuxSELinuxEnabled !== null &&
fingerprint.linuxSELinuxEnabled !== undefined
) {
posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled; posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled;
} }
if ( if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
fingerprint.tpmAvailable !== null &&
fingerprint.tpmAvailable !== undefined
) {
posture.tpmAvailable = fingerprint.tpmAvailable; posture.tpmAvailable = fingerprint.tpmAvailable;
} }
} }
// iOS: Biometric configuration // iOS: Biometric configuration
else if (normalizedPlatform === "ios") { else if (normalizedPlatform === "ios") {
// none supported yet if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
} }
// Android: Screen lock, Biometric configuration, Hard drive encryption // Android: Screen lock, Biometric configuration, Hard drive encryption
else if (normalizedPlatform === "android") { else if (normalizedPlatform === "android") {
if ( if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
fingerprint.diskEncrypted !== null && posture.biometricsEnabled = fingerprint.biometricsEnabled;
fingerprint.diskEncrypted !== undefined }
) { if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
posture.diskEncrypted = fingerprint.diskEncrypted; posture.diskEncrypted = fingerprint.diskEncrypted;
} }
} }
@@ -220,9 +161,6 @@ export type GetClientResponse = NonNullable<
olmId: string | null; olmId: string | null;
agent: string | null; agent: string | null;
olmVersion: string | null; olmVersion: string | null;
userEmail: string | null;
userName: string | null;
userUsername: string | null;
fingerprint: { fingerprint: {
username: string | null; username: string | null;
hostname: string | null; hostname: string | null;
@@ -295,11 +233,9 @@ export async function getClient(
); );
} }
const isUserDevice = client.user !== null && client.user !== undefined;
// Replace name with device name if OLM exists // Replace name with device name if OLM exists
let clientName = client.clients.name; let clientName = client.clients.name;
if (client.olms && isUserDevice) { if (client.olms) {
const model = client.currentFingerprint?.deviceModel || null; const model = client.currentFingerprint?.deviceModel || null;
clientName = getUserDeviceName(model, client.clients.name); clientName = getUserDeviceName(model, client.clients.name);
} }
@@ -322,20 +258,13 @@ export async function getClient(
: null; : null;
// Build posture data if available (platform-specific) // Build posture data if available (platform-specific)
// Licensed: real values; not licensed: same keys but values set to "-" let postureData: PostureData | null = null;
const rawPosture = getPlatformPostureData( if (build !== "oss") {
client.currentFingerprint?.platform || null, postureData = getPlatformPostureData(
client.currentFingerprint client.currentFingerprint?.platform || null,
); client.currentFingerprint
const isOrgLicensed = await isLicensedOrSubscribed( );
client.clients.orgId, }
tierMatrix.devicePosture
);
const postureData: PostureData | null = rawPosture
? isOrgLicensed
? rawPosture
: maskPostureDataWithPlaceholder(rawPosture)
: null;
const data: GetClientResponse = { const data: GetClientResponse = {
...client.clients, ...client.clients,
@@ -343,9 +272,6 @@ export async function getClient(
olmId: client.olms ? client.olms.olmId : null, olmId: client.olms ? client.olms.olmId : null,
agent: client.olms?.agent || null, agent: client.olms?.agent || null,
olmVersion: client.olms?.version || null, olmVersion: client.olms?.version || null,
userEmail: client.user?.email ?? null,
userName: client.user?.name ?? null,
userUsername: client.user?.username ?? null,
fingerprint: fingerprintData, fingerprint: fingerprintData,
posture: postureData posture: postureData
}; };
+1 -4
View File
@@ -320,10 +320,7 @@ export async function listClients(
// Merge clients with their site associations and replace name with device name // Merge clients with their site associations and replace name with device name
const clientsWithSites = clientsList.map((client) => { const clientsWithSites = clientsList.map((client) => {
const model = client.deviceModel || null; const model = client.deviceModel || null;
let newName = client.name; const newName = getUserDeviceName(model, client.name);
if (filter === "user") {
newName = getUserDeviceName(model, client.name);
}
return { return {
...client, ...client,
name: newName, name: newName,
+2 -2
View File
@@ -131,7 +131,7 @@ export async function createOrgDomain(
} }
const rejectDomains = await usageService.checkLimitSet( const rejectDomains = await usageService.checkLimitSet(
orgId, orgId,
false,
FeatureId.DOMAINS, FeatureId.DOMAINS,
{ {
...usage, ...usage,
@@ -354,7 +354,7 @@ export async function createOrgDomain(
}); });
if (numOrgDomains) { if (numOrgDomains) {
await usageService.updateCount( await usageService.updateDaily(
orgId, orgId,
FeatureId.DOMAINS, FeatureId.DOMAINS,
numOrgDomains.length numOrgDomains.length
+1 -1
View File
@@ -86,7 +86,7 @@ export async function deleteAccountDomain(
}); });
if (numOrgDomains) { if (numOrgDomains) {
await usageService.updateCount( await usageService.updateDaily(
orgId, orgId,
FeatureId.DOMAINS, FeatureId.DOMAINS,
numOrgDomains.length numOrgDomains.length

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