Compare commits

..

14 Commits

Author SHA1 Message Date
Owen Schwartz 776f3ea59f New translations en-us.json (Norwegian Bokmal) 2026-01-20 17:48:42 -08:00
Owen Schwartz c6360a3f25 New translations en-us.json (Chinese Simplified) 2026-01-20 17:48:41 -08:00
Owen Schwartz 134758621a New translations en-us.json (Turkish) 2026-01-20 17:48:40 -08:00
Owen Schwartz ca6d06b5a4 New translations en-us.json (Russian) 2026-01-20 17:48:38 -08:00
Owen Schwartz 0572dc5a12 New translations en-us.json (Portuguese) 2026-01-20 17:48:37 -08:00
Owen Schwartz 888c5860ef New translations en-us.json (Polish) 2026-01-20 17:48:35 -08:00
Owen Schwartz 331cdfd1c1 New translations en-us.json (Dutch) 2026-01-20 17:48:34 -08:00
Owen Schwartz f41ffd95f3 New translations en-us.json (Korean) 2026-01-20 17:48:32 -08:00
Owen Schwartz 3c79bdb764 New translations en-us.json (Italian) 2026-01-20 17:48:31 -08:00
Owen Schwartz c32ac607d3 New translations en-us.json (German) 2026-01-20 17:48:30 -08:00
Owen Schwartz 4011ea9fa0 New translations en-us.json (Czech) 2026-01-20 17:48:28 -08:00
Owen Schwartz d7ebe3a114 New translations en-us.json (Bulgarian) 2026-01-20 17:48:27 -08:00
Owen Schwartz 53dd5836b5 New translations en-us.json (Spanish) 2026-01-20 17:48:26 -08:00
Owen Schwartz 42b7e8c843 New translations en-us.json (French) 2026-01-20 17:48:24 -08:00
135 changed files with 7761 additions and 7476 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"
+24 -87
View File
@@ -29,7 +29,7 @@ jobs:
permissions: write-all permissions: write-all
steps: steps:
- name: Configure AWS credentials - name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5 uses: aws-actions/configure-aws-credentials@v2
with: with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600 role-duration-seconds: 3600
@@ -264,7 +264,7 @@ jobs:
shell: bash shell: bash
- name: Install Go - name: Install Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with: with:
go-version: 1.24 go-version: 1.24
@@ -339,37 +339,37 @@ jobs:
TAG=${{ env.TAG }} TAG=${{ env.TAG }}
MAJOR_TAG=$(echo $TAG | cut -d. -f1) MAJOR_TAG=$(echo $TAG | cut -d. -f1)
MINOR_TAG=$(echo $TAG | cut -d. -f1,2) MINOR_TAG=$(echo $TAG | cut -d. -f1,2)
echo "Waiting for multi-arch manifests to be ready..." echo "Waiting for multi-arch manifests to be ready..."
sleep 30 sleep 30
# 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
if [ "$IS_RC" = "true" ]; then if [ "$IS_RC" = "true" ]; then
echo "RC release detected - copying version-specific tags only" echo "RC release detected - copying version-specific tags only"
# SQLite OSS # SQLite OSS
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
skopeo copy --all --retry-times 3 \ skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:$TAG \ docker://$DOCKERHUB_IMAGE:$TAG \
docker://$GHCR_IMAGE:$TAG docker://$GHCR_IMAGE:$TAG
# PostgreSQL OSS # PostgreSQL OSS
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}"
skopeo copy --all --retry-times 3 \ skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:postgresql-$TAG \ docker://$DOCKERHUB_IMAGE:postgresql-$TAG \
docker://$GHCR_IMAGE:postgresql-$TAG docker://$GHCR_IMAGE:postgresql-$TAG
# SQLite Enterprise # SQLite Enterprise
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}"
skopeo copy --all --retry-times 3 \ skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:ee-$TAG \ docker://$DOCKERHUB_IMAGE:ee-$TAG \
docker://$GHCR_IMAGE:ee-$TAG docker://$GHCR_IMAGE:ee-$TAG
# PostgreSQL Enterprise # PostgreSQL Enterprise
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}"
skopeo copy --all --retry-times 3 \ skopeo copy --all --retry-times 3 \
@@ -377,7 +377,7 @@ jobs:
docker://$GHCR_IMAGE:ee-postgresql-$TAG docker://$GHCR_IMAGE:ee-postgresql-$TAG
else else
echo "Regular release detected - copying all tags (latest, major, minor, full version)" echo "Regular release detected - copying all tags (latest, major, minor, full version)"
# SQLite OSS - all tags # SQLite OSS - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}"
@@ -385,7 +385,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \ docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \
docker://$GHCR_IMAGE:$TAG_SUFFIX docker://$GHCR_IMAGE:$TAG_SUFFIX
done done
# PostgreSQL OSS - all tags # PostgreSQL OSS - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}"
@@ -393,7 +393,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \ docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \
docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX
done done
# SQLite Enterprise - all tags # SQLite Enterprise - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}"
@@ -401,7 +401,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \ docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \
docker://$GHCR_IMAGE:ee-$TAG_SUFFIX docker://$GHCR_IMAGE:ee-$TAG_SUFFIX
done done
# PostgreSQL Enterprise - all tags # PostgreSQL Enterprise - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}" echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}"
@@ -410,7 +410,7 @@ jobs:
docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX
done done
fi fi
echo "All images copied successfully to GHCR!" echo "All images copied successfully to GHCR!"
shell: bash shell: bash
@@ -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,82 +482,19 @@ 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
done done
echo "All images signed and verified successfully!" echo "All images signed and verified successfully!"
shell: bash shell: bash
@@ -576,7 +513,7 @@ jobs:
permissions: write-all permissions: write-all
steps: steps:
- name: Configure AWS credentials - name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5 uses: aws-actions/configure-aws-credentials@v2
with: with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600 role-duration-seconds: 3600
+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version: '24' node-version: '22'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
permissions: write-all permissions: write-all
steps: steps:
- name: Configure AWS credentials - name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5 uses: aws-actions/configure-aws-credentials@v2
with: with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600 role-duration-seconds: 3600
@@ -69,7 +69,7 @@ jobs:
fi fi
- name: Configure AWS credentials - name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5 uses: aws-actions/configure-aws-credentials@v2
with: with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600 role-duration-seconds: 3600
@@ -110,7 +110,7 @@ jobs:
permissions: write-all permissions: write-all
steps: steps:
- name: Configure AWS credentials - name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5 uses: aws-actions/configure-aws-credentials@v2
with: with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600 role-duration-seconds: 3600
+9 -3
View File
@@ -17,9 +17,9 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install Node - name: Install Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.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
-123
View File
@@ -1,123 +0,0 @@
import { CommandModule } from "yargs";
import { db, clients, olms, currentFingerprint, userClients, approvals } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
type DeleteClientArgs = {
orgId: string;
niceId: string;
};
export const deleteClient: CommandModule<{}, DeleteClientArgs> = {
command: "delete-client",
describe:
"Delete a client and all associated data (OLMs, current fingerprint, userClients, approvals). Snapshots are preserved.",
builder: (yargs) => {
return yargs
.option("orgId", {
type: "string",
demandOption: true,
describe: "The organization ID"
})
.option("niceId", {
type: "string",
demandOption: true,
describe: "The client niceId (identifier)"
});
},
handler: async (argv: { orgId: string; niceId: string }) => {
try {
const { orgId, niceId } = argv;
console.log(
`Deleting client with orgId: ${orgId}, niceId: ${niceId}...`
);
// Find the client
const [client] = await db
.select()
.from(clients)
.where(and(eq(clients.orgId, orgId), eq(clients.niceId, niceId)))
.limit(1);
if (!client) {
console.error(
`Error: Client with orgId "${orgId}" and niceId "${niceId}" not found.`
);
process.exit(1);
}
const clientId = client.clientId;
console.log(`Found client with clientId: ${clientId}`);
// Find all OLMs associated with this client
const associatedOlms = await db
.select()
.from(olms)
.where(eq(olms.clientId, clientId));
console.log(`Found ${associatedOlms.length} OLM(s) associated with this client`);
// Delete in a transaction to ensure atomicity
await db.transaction(async (trx) => {
// Delete currentFingerprint entries for the associated OLMs
// Note: We delete these explicitly before deleting OLMs to ensure
// we have control, even though cascade would handle it
let fingerprintCount = 0;
if (associatedOlms.length > 0) {
const olmIds = associatedOlms.map((olm) => olm.olmId);
const deletedFingerprints = await trx
.delete(currentFingerprint)
.where(inArray(currentFingerprint.olmId, olmIds))
.returning();
fingerprintCount = deletedFingerprints.length;
}
console.log(`Deleted ${fingerprintCount} current fingerprint(s)`);
// Delete OLMs
// Note: OLMs have onDelete: "set null" for clientId, so we need to delete them explicitly
const deletedOlms = await trx
.delete(olms)
.where(eq(olms.clientId, clientId))
.returning();
console.log(`Deleted ${deletedOlms.length} OLM(s)`);
// Delete approvals
// Note: Approvals have onDelete: "cascade" but we delete explicitly for clarity
const deletedApprovals = await trx
.delete(approvals)
.where(eq(approvals.clientId, clientId))
.returning();
console.log(`Deleted ${deletedApprovals.length} approval(s)`);
// Delete userClients
// Note: userClients have onDelete: "cascade" but we delete explicitly for clarity
const deletedUserClients = await trx
.delete(userClients)
.where(eq(userClients.clientId, clientId))
.returning();
console.log(`Deleted ${deletedUserClients.length} userClient association(s)`);
// Finally, delete the client itself
const deletedClients = await trx
.delete(clients)
.where(eq(clients.clientId, clientId))
.returning();
console.log(`Deleted client: ${deletedClients[0]?.name || niceId}`);
});
console.log("\nClient deletion completed successfully!");
console.log("\nSummary:");
console.log(` - Client: ${niceId} (clientId: ${clientId})`);
console.log(` - Olm(s): ${associatedOlms.length}`);
console.log(` - Current fingerprints: deleted`);
console.log(` - Approvals: deleted`);
console.log(` - UserClients: deleted`);
console.log(` - Snapshots: preserved (not deleted)`);
process.exit(0);
} catch (error) {
console.error("Error deleting client:", error);
process.exit(1);
}
}
};
-2
View File
@@ -7,7 +7,6 @@ import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
import { clearExitNodes } from "./commands/clearExitNodes"; import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret"; import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -16,6 +15,5 @@ yargs(hideBin(process.argv))
.command(clearExitNodes) .command(clearExitNodes)
.command(rotateServerSecret) .command(rotateServerSecret)
.command(clearLicenseKeys) .command(clearLicenseKeys)
.command(deleteClient)
.demandCommand() .demandCommand()
.help().argv; .help().argv;
+18 -21
View File
@@ -1,30 +1,27 @@
# To see all available options, please visit the docs: # To see all available options, please visit the docs:
# https://docs.pangolin.net/ # https://docs.pangolin.net/self-host/advanced/config-file
gerbil:
start_port: 51820
base_endpoint: "{{.DashboardDomain}}"
app: app:
dashboard_url: "https://{{.DashboardDomain}}" dashboard_url: http://localhost:3002
log_level: "info" log_level: debug
telemetry:
anonymous_usage: true
domains: domains:
domain1: domain1:
base_domain: "{{.BaseDomain}}" base_domain: example.com
server: server:
secret: "{{.Secret}}" secret: my_secret_key
cors:
origins: ["https://{{.DashboardDomain}}"] gerbil:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] base_endpoint: example.com
allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false orgs:
block_size: 24
subnet_group: 100.90.137.0/20
flags: flags:
require_email_verification: false require_email_verification: false
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: false disable_user_create_org: true
allow_raw_resources: true allow_raw_resources: true
enable_integration_api: true
+3 -10
View File
@@ -21,8 +21,9 @@ http:
# Next.js router (handles everything except API and WebSocket paths) # Next.js router (handles everything except API and WebSocket paths)
next-router: next-router:
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`)"
service: next-service service: next-service
priority: 10
entryPoints: entryPoints:
- websecure - websecure
middlewares: middlewares:
@@ -34,6 +35,7 @@ http:
api-router: api-router:
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service service: api-service
priority: 100
entryPoints: entryPoints:
- websecure - websecure
middlewares: middlewares:
@@ -51,12 +53,3 @@ http:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://pangolin:3000" # API/WebSocket server - url: "http://pangolin:3000" # API/WebSocket server
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2
+5 -25
View File
@@ -3,52 +3,32 @@ api:
dashboard: true dashboard: true
providers: providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file: file:
filename: "/etc/traefik/dynamic_config.yml" directory: "/var/dynamic"
watch: true
experimental: experimental:
plugins: plugins:
badger: badger:
moduleName: "github.com/fosrl/badger" moduleName: "github.com/fosrl/badger"
version: "{{.BadgerVersion}}" version: "v1.3.0"
log: log:
level: "INFO" level: "DEBUG"
format: "common" format: "common"
maxSize: 100 maxSize: 100
maxBackups: 3 maxBackups: 3
maxAge: 3 maxAge: 3
compress: true compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "{{.LetsEncryptEmail}}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints: entryPoints:
web: web:
address: ":80" address: ":80"
websecure: websecure:
address: ":443" address: ":9443"
transport: transport:
respondingTimeouts: respondingTimeouts:
readTimeout: "30m" readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport: serversTransport:
insecureSkipVerify: true insecureSkipVerify: true
ping:
entryPoint: "web"
+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;
+2 -2
View File
@@ -3,8 +3,8 @@ module installer
go 1.24.0 go 1.24.0
require ( require (
golang.org/x/term v0.39.0 golang.org/x/term v0.38.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require golang.org/x/sys v0.40.0 // indirect require golang.org/x/sys v0.39.0 // indirect
+4 -4
View File
@@ -1,7 +1,7 @@
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+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 {
+13 -14
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.", "resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.",
"resourcesTableDestination": "Дестинация", "resourcesTableDestination": "Дестинация",
"resourcesTableAlias": "Псевдоним", "resourcesTableAlias": "Псевдоним",
"resourcesTableAliasAddress": "Адрес на псевдоним.",
"resourcesTableAliasAddressInfo": "Този адрес е част от подсистемата на организацията. Използва се за разрешаване на псевдонимни записи чрез вътрешно DNS разрешаване.",
"resourcesTableClients": "Клиенти", "resourcesTableClients": "Клиенти",
"resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.", "resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.",
"resourcesTableNoTargets": "Без цели", "resourcesTableNoTargets": "Без цели",
@@ -2491,8 +2489,8 @@
"logIn": "Вход", "logIn": "Вход",
"deviceInformation": "Информация за устройството", "deviceInformation": "Информация за устройството",
"deviceInformationDescription": "Информация за устройството и агента", "deviceInformationDescription": "Информация за устройството и агента",
"deviceSecurity": "Защита на устройството.", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Информация за състоянието на защитата на устройството.", "deviceSecurityDescription": "Device security posture information",
"platform": "Платформа", "platform": "Платформа",
"macosVersion": "Версия на macOS", "macosVersion": "Версия на macOS",
"windowsVersion": "Версия на Windows", "windowsVersion": "Версия на Windows",
@@ -2505,16 +2503,17 @@
"hostname": "Име на хост", "hostname": "Име на хост",
"firstSeen": "Видян за първи път", "firstSeen": "Видян за първи път",
"lastSeen": "Последно видян", "lastSeen": "Последно видян",
"biometricsEnabled": "Активирани биометрични данни.", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Криптиран диск.", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Активирана защитна стена.", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Активирани автоматични актуализации.", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM е на разположение.", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Protection на системната цялост (SIP).", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosGatekeeperEnabled": "Gatekeeper.", "macosSipEnabled": "System Integrity Protection (SIP)",
"macosFirewallStealthMode": "Скрит режим на защитната стена.", "macosGatekeeperEnabled": "Gatekeeper",
"linuxAppArmorEnabled": "AppArmor.", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxSELinuxEnabled": "SELinux.", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Разгледайте информация и настройки на устройството", "deviceSettingsDescription": "Разгледайте информация и настройки на устройството",
"devicePendingApprovalDescription": "Това устройство чака одобрение", "devicePendingApprovalDescription": "Това устройство чака одобрение",
"deviceBlockedDescription": "Това устройство е в момента блокирано. Няма да може да се свърже с никакви ресурси, освен ако не бъде деблокирано.", "deviceBlockedDescription": "Това устройство е в момента блокирано. Няма да може да се свърже с никакви ресурси, освен ако не бъде деблокирано.",
+11 -12
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.", "resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.",
"resourcesTableDestination": "Místo určení", "resourcesTableDestination": "Místo určení",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Adresa aliasu",
"resourcesTableAliasAddressInfo": "Tato adresa je součástí subsítě veřejných služeb organizace. Používá se k řešení záznamů aliasů pomocí interního rozlišení DNS.",
"resourcesTableClients": "Klienti", "resourcesTableClients": "Klienti",
"resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.", "resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.",
"resourcesTableNoTargets": "Žádné cíle", "resourcesTableNoTargets": "Žádné cíle",
@@ -2491,8 +2489,8 @@
"logIn": "Přihlásit se", "logIn": "Přihlásit se",
"deviceInformation": "Informace o zařízení", "deviceInformation": "Informace o zařízení",
"deviceInformationDescription": "Informace o zařízení a agentovi", "deviceInformationDescription": "Informace o zařízení a agentovi",
"deviceSecurity": "Zabezpečení zařízení", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Informace o bezpečnostní pozici zařízení", "deviceSecurityDescription": "Device security posture information",
"platform": "Platforma", "platform": "Platforma",
"macosVersion": "macOS verze", "macosVersion": "macOS verze",
"windowsVersion": "Verze Windows", "windowsVersion": "Verze Windows",
@@ -2505,15 +2503,16 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "První vidění", "firstSeen": "První vidění",
"lastSeen": "Naposledy viděno", "lastSeen": "Naposledy viděno",
"biometricsEnabled": "Biometrie povolena", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Šifrovaný disk", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Firewall povolen", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Automatické aktualizace povoleny", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM k dispozici", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Ochrana systémové integrity (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Režim neviditelnosti firewallu", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "Pancíř aplikace", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Zobrazit informace o zařízení a nastavení", "deviceSettingsDescription": "Zobrazit informace o zařízení a nastavení",
"devicePendingApprovalDescription": "Toto zařízení čeká na schválení", "devicePendingApprovalDescription": "Toto zařízení čeká na schválení",
+14 -15
View File
@@ -97,7 +97,7 @@
"siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren",
"siteSettingDescription": "Standorteinstellungen konfigurieren", "siteSettingDescription": "Standorteinstellungen konfigurieren",
"siteSetting": "{siteName} Einstellungen", "siteSetting": "{siteName} Einstellungen",
"siteNewtTunnel": "Newt Standort (empfohlen)", "siteNewtTunnel": "Neuer Standort (empfohlen)",
"siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.", "siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.",
"siteWg": "Einfacher WireGuard Tunnel", "siteWg": "Einfacher WireGuard Tunnel",
"siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.", "siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.",
@@ -107,7 +107,7 @@
"siteSeeAll": "Alle Standorte anzeigen", "siteSeeAll": "Alle Standorte anzeigen",
"siteTunnelDescription": "Legen Sie fest, wie Sie sich mit dem Standort verbinden möchten", "siteTunnelDescription": "Legen Sie fest, wie Sie sich mit dem Standort verbinden möchten",
"siteNewtCredentials": "Zugangsdaten", "siteNewtCredentials": "Zugangsdaten",
"siteNewtCredentialsDescription": "So wird sich der Standort mit dem Server authentifizieren", "siteNewtCredentialsDescription": "So wird sich die Seite mit dem Server authentifizieren",
"remoteNodeCredentialsDescription": "So wird sich der entfernte Node mit dem Server authentifizieren", "remoteNodeCredentialsDescription": "So wird sich der entfernte Node mit dem Server authentifizieren",
"siteCredentialsSave": "Anmeldedaten speichern", "siteCredentialsSave": "Anmeldedaten speichern",
"siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.", "siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.",
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.", "resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.",
"resourcesTableDestination": "Ziel", "resourcesTableDestination": "Ziel",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias-Adresse",
"resourcesTableAliasAddressInfo": "Diese Adresse ist Teil des Utility-Subnetzes der Organisation. Sie wird verwendet, um Alias-Einträge mit interner DNS-Auflösung aufzulösen.",
"resourcesTableClients": "Clients", "resourcesTableClients": "Clients",
"resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.", "resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.",
"resourcesTableNoTargets": "Keine Ziele", "resourcesTableNoTargets": "Keine Ziele",
@@ -2491,8 +2489,8 @@
"logIn": "Anmelden", "logIn": "Anmelden",
"deviceInformation": "Geräteinformationen", "deviceInformation": "Geräteinformationen",
"deviceInformationDescription": "Informationen über das Gerät und den Agent", "deviceInformationDescription": "Informationen über das Gerät und den Agent",
"deviceSecurity": "Gerätesicherheit", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Informationen zur Gerätesicherheit", "deviceSecurityDescription": "Device security posture information",
"platform": "Plattform", "platform": "Plattform",
"macosVersion": "macOS-Version", "macosVersion": "macOS-Version",
"windowsVersion": "Windows-Version", "windowsVersion": "Windows-Version",
@@ -2503,17 +2501,18 @@
"deviceModel": "Gerätemodell", "deviceModel": "Gerätemodell",
"serialNumber": "Seriennummer", "serialNumber": "Seriennummer",
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Zuerst gesehen", "firstSeen": "Erster Blick",
"lastSeen": "Zuletzt gesehen", "lastSeen": "Zuletzt gesehen",
"biometricsEnabled": "Biometrie aktiviert", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Festplatte verschlüsselt", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Firewall aktiviert", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Automatische Updates aktiviert", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM verfügbar", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Schutz der Systemintegrität (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth-Modus", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppRüstung", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Geräteinformationen und -einstellungen anzeigen", "deviceSettingsDescription": "Geräteinformationen und -einstellungen anzeigen",
"devicePendingApprovalDescription": "Dieses Gerät wartet auf Freigabe", "devicePendingApprovalDescription": "Dieses Gerät wartet auf Freigabe",
+1 -38
View File
@@ -1436,15 +1436,6 @@
"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.", "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": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", "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": "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.", "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",
"billingPricingCalculatorLink": "View Pricing Calculator",
"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",
@@ -1634,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "No internal resources found.", "resourcesTableNoInternalResourcesFound": "No internal resources found.",
"resourcesTableDestination": "Destination", "resourcesTableDestination": "Destination",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias Address",
"resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.",
"resourcesTableClients": "Clients", "resourcesTableClients": "Clients",
"resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.",
"resourcesTableNoTargets": "No targets", "resourcesTableNoTargets": "No targets",
@@ -2122,32 +2111,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",
@@ -2545,7 +2508,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",
+10 -11
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.", "resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.",
"resourcesTableDestination": "Destino", "resourcesTableDestination": "Destino",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Dirección del alias",
"resourcesTableAliasAddressInfo": "Esta dirección es parte de la subred de utilidad de la organización. Se utiliza para resolver registros de alias usando resolución DNS interna.",
"resourcesTableClients": "Clientes", "resourcesTableClients": "Clientes",
"resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.", "resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.",
"resourcesTableNoTargets": "Sin objetivos", "resourcesTableNoTargets": "Sin objetivos",
@@ -2491,8 +2489,8 @@
"logIn": "Iniciar sesión", "logIn": "Iniciar sesión",
"deviceInformation": "Información del dispositivo", "deviceInformation": "Información del dispositivo",
"deviceInformationDescription": "Información sobre el dispositivo y el agente", "deviceInformationDescription": "Información sobre el dispositivo y el agente",
"deviceSecurity": "Seguridad del dispositivo", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Información de postura de seguridad del dispositivo", "deviceSecurityDescription": "Device security posture information",
"platform": "Plataforma", "platform": "Plataforma",
"macosVersion": "versión macOS", "macosVersion": "versión macOS",
"windowsVersion": "Versión de Windows", "windowsVersion": "Versión de Windows",
@@ -2505,14 +2503,15 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Primer detectado", "firstSeen": "Primer detectado",
"lastSeen": "Último Visto", "lastSeen": "Último Visto",
"biometricsEnabled": "Biometría habilitada", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Disco cifrado", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Cortafuegos activado", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Actualizaciones automáticas habilitadas", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM disponible", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Protección de integridad del sistema (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Sigilo Firewall", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Ver información y ajustes del dispositivo", "deviceSettingsDescription": "Ver información y ajustes del dispositivo",
+11 -12
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.", "resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.",
"resourcesTableDestination": "Destination", "resourcesTableDestination": "Destination",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Adresse de l'alias",
"resourcesTableAliasAddressInfo": "Cette adresse fait partie du sous-réseau utilitaire de l'organisation. Elle est utilisée pour résoudre les enregistrements d'alias en utilisant une résolution DNS interne.",
"resourcesTableClients": "Clients", "resourcesTableClients": "Clients",
"resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.", "resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.",
"resourcesTableNoTargets": "Aucune cible", "resourcesTableNoTargets": "Aucune cible",
@@ -2491,8 +2489,8 @@
"logIn": "Se connecter", "logIn": "Se connecter",
"deviceInformation": "Informations sur l'appareil", "deviceInformation": "Informations sur l'appareil",
"deviceInformationDescription": "Informations sur l'appareil et l'agent", "deviceInformationDescription": "Informations sur l'appareil et l'agent",
"deviceSecurity": "Sécurité de l'appareil", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Informations sur la posture de sécurité de l'appareil", "deviceSecurityDescription": "Device security posture information",
"platform": "Plateforme", "platform": "Plateforme",
"macosVersion": "Version macOS", "macosVersion": "Version macOS",
"windowsVersion": "Version de Windows", "windowsVersion": "Version de Windows",
@@ -2505,15 +2503,16 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Première vue", "firstSeen": "Première vue",
"lastSeen": "Dernière vue", "lastSeen": "Dernière vue",
"biometricsEnabled": "biométrique activée", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Disque chiffré", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Pare-feu activé", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Mises à jour automatiques activées", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM disponible", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Protection contre l'intégrité du système (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Mode furtif du pare-feu", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "Armure d'application", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Afficher les informations et les paramètres de l'appareil", "deviceSettingsDescription": "Afficher les informations et les paramètres de l'appareil",
"devicePendingApprovalDescription": "Cet appareil est en attente d'approbation", "devicePendingApprovalDescription": "Cet appareil est en attente d'approbation",
+10 -11
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.", "resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.",
"resourcesTableDestination": "Destinazione", "resourcesTableDestination": "Destinazione",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Indirizzo Alias",
"resourcesTableAliasAddressInfo": "Questo indirizzo fa parte della subnet di utilità dell'organizzazione. È usato per risolvere i record alias usando la risoluzione DNS interna.",
"resourcesTableClients": "Client", "resourcesTableClients": "Client",
"resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.", "resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.",
"resourcesTableNoTargets": "Nessun obiettivo", "resourcesTableNoTargets": "Nessun obiettivo",
@@ -2491,8 +2489,8 @@
"logIn": "Log In", "logIn": "Log In",
"deviceInformation": "Informazioni Sul Dispositivo", "deviceInformation": "Informazioni Sul Dispositivo",
"deviceInformationDescription": "Informazioni sul dispositivo e sull'agente", "deviceInformationDescription": "Informazioni sul dispositivo e sull'agente",
"deviceSecurity": "Sicurezza Del Dispositivo", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Informazioni postura sicurezza dispositivo", "deviceSecurityDescription": "Device security posture information",
"platform": "Piattaforma", "platform": "Piattaforma",
"macosVersion": "versione macOS", "macosVersion": "versione macOS",
"windowsVersion": "Versione Windows", "windowsVersion": "Versione Windows",
@@ -2505,14 +2503,15 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Prima Visto", "firstSeen": "Prima Visto",
"lastSeen": "Visto L'Ultima", "lastSeen": "Visto L'Ultima",
"biometricsEnabled": "Biometria Abilitata", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Cifratura Del Disco", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Firewall Abilitato", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM Disponibile", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo Del Firewall", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Visualizza informazioni e impostazioni del dispositivo", "deviceSettingsDescription": "Visualizza informazioni e impostazioni del dispositivo",
+10 -11
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.", "resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.",
"resourcesTableDestination": "대상지", "resourcesTableDestination": "대상지",
"resourcesTableAlias": "별칭", "resourcesTableAlias": "별칭",
"resourcesTableAliasAddress": "별칭 주소",
"resourcesTableAliasAddressInfo": "이 주소는 조직의 유틸리티 서브넷의 일부로, 내부 DNS 해석을 사용하여 별칭 레코드를 해석하는 데 사용됩니다.",
"resourcesTableClients": "클라이언트", "resourcesTableClients": "클라이언트",
"resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.", "resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.",
"resourcesTableNoTargets": "대상 없음", "resourcesTableNoTargets": "대상 없음",
@@ -2491,8 +2489,8 @@
"logIn": "로그인", "logIn": "로그인",
"deviceInformation": "장치 정보", "deviceInformation": "장치 정보",
"deviceInformationDescription": "장치와 에이전트 정보", "deviceInformationDescription": "장치와 에이전트 정보",
"deviceSecurity": "디바이스 보안", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "디바이스 보안 상태 정보", "deviceSecurityDescription": "Device security posture information",
"platform": "플랫폼", "platform": "플랫폼",
"macosVersion": "macOS 버전", "macosVersion": "macOS 버전",
"windowsVersion": "Windows 버전", "windowsVersion": "Windows 버전",
@@ -2505,14 +2503,15 @@
"hostname": "호스트 이름", "hostname": "호스트 이름",
"firstSeen": "처음 발견됨", "firstSeen": "처음 발견됨",
"lastSeen": "마지막으로 발견됨", "lastSeen": "마지막으로 발견됨",
"biometricsEnabled": "생체 인식 활성화", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "디스크 암호화됨", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "방화벽 활성화", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "자동 업데이트 활성화", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM 사용 가능", "tpmAvailable": "TPM Available",
"macosSipEnabled": "시스템 무결성 보호 (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "방화벽 스텔스 모드", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "장치 정보 및 설정 보기", "deviceSettingsDescription": "장치 정보 및 설정 보기",
+11 -12
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.", "resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.",
"resourcesTableDestination": "Destinasjon", "resourcesTableDestination": "Destinasjon",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias adresse",
"resourcesTableAliasAddressInfo": "Denne adressen er en del av organisasjonens undernettverk. Den brukes til å løse aliasposter ved hjelp av intern DNS-oppløsning.",
"resourcesTableClients": "Klienter", "resourcesTableClients": "Klienter",
"resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.", "resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.",
"resourcesTableNoTargets": "Ingen mål", "resourcesTableNoTargets": "Ingen mål",
@@ -2491,8 +2489,8 @@
"logIn": "Logg inn", "logIn": "Logg inn",
"deviceInformation": "Enhetens informasjon", "deviceInformation": "Enhetens informasjon",
"deviceInformationDescription": "Informasjon om enheten og agenten", "deviceInformationDescription": "Informasjon om enheten og agenten",
"deviceSecurity": "Enhetens sikkerhet", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Sikkerhetsstillings informasjon om utstyr", "deviceSecurityDescription": "Device security posture information",
"platform": "Plattform", "platform": "Plattform",
"macosVersion": "macOS versjon", "macosVersion": "macOS versjon",
"windowsVersion": "Windows versjon", "windowsVersion": "Windows versjon",
@@ -2505,15 +2503,16 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Først sett", "firstSeen": "Først sett",
"lastSeen": "Sist sett", "lastSeen": "Sist sett",
"biometricsEnabled": "Biometri aktivert", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Disk kryptert", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Brannmur aktivert", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Automatiske oppdateringer aktivert", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM tilgjengelig", "tpmAvailable": "TPM Available",
"macosSipEnabled": "System Integritetsbeskyttelse (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Brannmur Usynlig Modus", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "Rustning", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Vis enhetsinformasjon og innstillinger", "deviceSettingsDescription": "Vis enhetsinformasjon og innstillinger",
"devicePendingApprovalDescription": "Denne enheten venter på godkjenning", "devicePendingApprovalDescription": "Denne enheten venter på godkjenning",
+11 -12
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.", "resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.",
"resourcesTableDestination": "Bestemming", "resourcesTableDestination": "Bestemming",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias adres",
"resourcesTableAliasAddressInfo": "Dit adres is onderdeel van het hulpprogramma subnet van de organisatie. Het wordt gebruikt om aliasrecords op te lossen met behulp van interne DNS-resolutie.",
"resourcesTableClients": "Clienten", "resourcesTableClients": "Clienten",
"resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.", "resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.",
"resourcesTableNoTargets": "Geen doelen", "resourcesTableNoTargets": "Geen doelen",
@@ -2491,8 +2489,8 @@
"logIn": "Log in", "logIn": "Log in",
"deviceInformation": "Apparaat informatie", "deviceInformation": "Apparaat informatie",
"deviceInformationDescription": "Informatie over het apparaat en de agent", "deviceInformationDescription": "Informatie over het apparaat en de agent",
"deviceSecurity": "Apparaat beveiliging", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Apparaat beveiligingsinformatie", "deviceSecurityDescription": "Device security posture information",
"platform": "Platform", "platform": "Platform",
"macosVersion": "macOS versie", "macosVersion": "macOS versie",
"windowsVersion": "Windows versie", "windowsVersion": "Windows versie",
@@ -2505,15 +2503,16 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Eerst gezien", "firstSeen": "Eerst gezien",
"lastSeen": "Laatst gezien op", "lastSeen": "Laatst gezien op",
"biometricsEnabled": "Biometrie ingeschakeld", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Schijf versleuteld", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Firewall ingeschakeld", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Auto Updates Ingeschakeld", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM beschikbaar", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Systeemintegriteitsbescherming (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Verberg Modus", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "Appharnas", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Apparaatinformatie en -instellingen bekijken", "deviceSettingsDescription": "Apparaatinformatie en -instellingen bekijken",
"devicePendingApprovalDescription": "Dit apparaat wacht op goedkeuring", "devicePendingApprovalDescription": "Dit apparaat wacht op goedkeuring",
+11 -12
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.", "resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.",
"resourcesTableDestination": "Miejsce docelowe", "resourcesTableDestination": "Miejsce docelowe",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Adres aliasu",
"resourcesTableAliasAddressInfo": "Ten adres jest częścią podsieci użyteczności organizacji. Jest używany do rozwiązywania rekordów aliasu przy użyciu wewnętrznej rozdzielczości DNS.",
"resourcesTableClients": "Klientami", "resourcesTableClients": "Klientami",
"resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.", "resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.",
"resourcesTableNoTargets": "Brak celów", "resourcesTableNoTargets": "Brak celów",
@@ -2491,8 +2489,8 @@
"logIn": "Zaloguj się", "logIn": "Zaloguj się",
"deviceInformation": "Informacje o urządzeniu", "deviceInformation": "Informacje o urządzeniu",
"deviceInformationDescription": "Informacje o urządzeniu i agentach", "deviceInformationDescription": "Informacje o urządzeniu i agentach",
"deviceSecurity": "Bezpieczeństwo urządzenia", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Informacje o bezpieczeństwie urządzenia", "deviceSecurityDescription": "Device security posture information",
"platform": "Platforma", "platform": "Platforma",
"macosVersion": "Wersja macOS", "macosVersion": "Wersja macOS",
"windowsVersion": "Wersja Windows", "windowsVersion": "Wersja Windows",
@@ -2505,15 +2503,16 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Widziany po raz pierwszy", "firstSeen": "Widziany po raz pierwszy",
"lastSeen": "Ostatnio widziane", "lastSeen": "Ostatnio widziane",
"biometricsEnabled": "Biometria włączona", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Dysk zaszyfrowany", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Zapora włączona", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Automatyczne aktualizacje włączone", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM dostępne", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Ochrona integralności systemu (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Tryb Stealth zapory", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "Zbroja aplikacji", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Wyświetl informacje o urządzeniu i ustawienia", "deviceSettingsDescription": "Wyświetl informacje o urządzeniu i ustawienia",
"devicePendingApprovalDescription": "To urządzenie czeka na zatwierdzenie", "devicePendingApprovalDescription": "To urządzenie czeka na zatwierdzenie",
+10 -11
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.", "resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.",
"resourcesTableDestination": "Destino", "resourcesTableDestination": "Destino",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Endereço do Pseudônimo",
"resourcesTableAliasAddressInfo": "Este endereço faz parte da sub-rede de utilitários da organização. É usado para resolver registros de alias usando resolução de DNS interno.",
"resourcesTableClients": "Clientes", "resourcesTableClients": "Clientes",
"resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.", "resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.",
"resourcesTableNoTargets": "Nenhum alvo", "resourcesTableNoTargets": "Nenhum alvo",
@@ -2491,8 +2489,8 @@
"logIn": "Iniciar sessão", "logIn": "Iniciar sessão",
"deviceInformation": "Informações do dispositivo", "deviceInformation": "Informações do dispositivo",
"deviceInformationDescription": "Informações sobre o dispositivo e o agente", "deviceInformationDescription": "Informações sobre o dispositivo e o agente",
"deviceSecurity": "Segurança do dispositivo", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Informações sobre postagem de segurança", "deviceSecurityDescription": "Device security posture information",
"platform": "Plataforma", "platform": "Plataforma",
"macosVersion": "Versão do macOS", "macosVersion": "Versão do macOS",
"windowsVersion": "Versão do Windows", "windowsVersion": "Versão do Windows",
@@ -2505,14 +2503,15 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Visto primeiro", "firstSeen": "Visto primeiro",
"lastSeen": "Visto por último", "lastSeen": "Visto por último",
"biometricsEnabled": "Biometria habilitada", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Disco criptografado", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Firewall habilitado", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Atualizações Automáticas Habilitadas", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM disponível", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Proteção da Integridade do Sistema (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo do Firewall", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Ver informações e configurações do dispositivo", "deviceSettingsDescription": "Ver informações e configurações do dispositivo",
+11 -12
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.", "resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.",
"resourcesTableDestination": "Пункт назначения", "resourcesTableDestination": "Пункт назначения",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Псевдоним адреса",
"resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.",
"resourcesTableClients": "Клиенты", "resourcesTableClients": "Клиенты",
"resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.", "resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.",
"resourcesTableNoTargets": "Нет ярлыков", "resourcesTableNoTargets": "Нет ярлыков",
@@ -2491,8 +2489,8 @@
"logIn": "Войти", "logIn": "Войти",
"deviceInformation": "Информация об устройстве", "deviceInformation": "Информация об устройстве",
"deviceInformationDescription": "Информация о устройстве и агенте", "deviceInformationDescription": "Информация о устройстве и агенте",
"deviceSecurity": "Безопасность устройства", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Информация о позе безопасности устройства", "deviceSecurityDescription": "Device security posture information",
"platform": "Платформа", "platform": "Платформа",
"macosVersion": "Версия macOS", "macosVersion": "Версия macOS",
"windowsVersion": "Версия Windows", "windowsVersion": "Версия Windows",
@@ -2505,15 +2503,16 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "Первый раз виден", "firstSeen": "Первый раз виден",
"lastSeen": "Последнее посещение", "lastSeen": "Последнее посещение",
"biometricsEnabled": "Включены биометрические данные", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Диск зашифрован", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Брандмауэр включен", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Автоматические обновления включены", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "Доступно TPM", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Защита целостности системы (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Стилс-режим брандмауэра", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "Броня", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Просмотр информации и настроек устройства", "deviceSettingsDescription": "Просмотр информации и настроек устройства",
"devicePendingApprovalDescription": "Это устройство ожидает одобрения", "devicePendingApprovalDescription": "Это устройство ожидает одобрения",
+10 -11
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.", "resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.",
"resourcesTableDestination": "Hedef", "resourcesTableDestination": "Hedef",
"resourcesTableAlias": "Takma Ad", "resourcesTableAlias": "Takma Ad",
"resourcesTableAliasAddress": "Alias Adresi",
"resourcesTableAliasAddressInfo": "Bu adres, kuruluşun yardımcı ağ alt bantının bir parçasıdır. Alias kayıtlarını çözümlemek için dahili DNS çözümlemesi kullanılır.",
"resourcesTableClients": "İstemciler", "resourcesTableClients": "İstemciler",
"resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.", "resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.",
"resourcesTableNoTargets": "Hedef yok", "resourcesTableNoTargets": "Hedef yok",
@@ -2491,8 +2489,8 @@
"logIn": "Giriş Yap", "logIn": "Giriş Yap",
"deviceInformation": "Cihaz Bilgisi", "deviceInformation": "Cihaz Bilgisi",
"deviceInformationDescription": "Cihaz ve temsilci hakkında bilgi", "deviceInformationDescription": "Cihaz ve temsilci hakkında bilgi",
"deviceSecurity": "Cihaz Güvenliği", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "Cihaz güvenliği durumu bilgisi", "deviceSecurityDescription": "Device security posture information",
"platform": "Platform", "platform": "Platform",
"macosVersion": "macOS Sürümü", "macosVersion": "macOS Sürümü",
"windowsVersion": "Windows Sürümü", "windowsVersion": "Windows Sürümü",
@@ -2505,14 +2503,15 @@
"hostname": "Ana Makine Adı", "hostname": "Ana Makine Adı",
"firstSeen": "İlk Görüldü", "firstSeen": "İlk Görüldü",
"lastSeen": "Son Görüldü", "lastSeen": "Son Görüldü",
"biometricsEnabled": "Biyometri Etkin", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "Disk Şifrelenmiş", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "Güvenlik Duvarı Etkin", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Otomatik Güncellemeler Etkin", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM Mevcut", "tpmAvailable": "TPM Available",
"macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "Cihaz bilgilerini ve ayarlarını görüntüleyin", "deviceSettingsDescription": "Cihaz bilgilerini ve ayarlarını görüntüleyin",
+10 -11
View File
@@ -1625,8 +1625,6 @@
"resourcesTableNoInternalResourcesFound": "未找到内部资源。", "resourcesTableNoInternalResourcesFound": "未找到内部资源。",
"resourcesTableDestination": "目标", "resourcesTableDestination": "目标",
"resourcesTableAlias": "Alias", "resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "别名地址",
"resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。",
"resourcesTableClients": "客户端", "resourcesTableClients": "客户端",
"resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。", "resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。",
"resourcesTableNoTargets": "没有目标", "resourcesTableNoTargets": "没有目标",
@@ -2491,8 +2489,8 @@
"logIn": "登录", "logIn": "登录",
"deviceInformation": "设备信息", "deviceInformation": "设备信息",
"deviceInformationDescription": "关于设备和代理的信息", "deviceInformationDescription": "关于设备和代理的信息",
"deviceSecurity": "设备安全", "deviceSecurity": "Device Security",
"deviceSecurityDescription": "设备安全态势信息", "deviceSecurityDescription": "Device security posture information",
"platform": "平台", "platform": "平台",
"macosVersion": "macOS 版本", "macosVersion": "macOS 版本",
"windowsVersion": "Windows 版本", "windowsVersion": "Windows 版本",
@@ -2505,14 +2503,15 @@
"hostname": "Hostname", "hostname": "Hostname",
"firstSeen": "第一次查看", "firstSeen": "第一次查看",
"lastSeen": "上次查看时间", "lastSeen": "上次查看时间",
"biometricsEnabled": "生物计已启用", "biometricsEnabled": "Biometrics Enabled",
"diskEncrypted": "磁盘加密", "diskEncrypted": "Disk Encrypted",
"firewallEnabled": "防火墙已启用", "firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "启用自动更新", "autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM 可用", "tpmAvailable": "TPM Available",
"macosSipEnabled": "系统完整性保护 (SIP)", "windowsDefenderEnabled": "Windows Defender Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "防火墙隐形模式", "macosFirewallStealthMode": "Firewall Stealth Mode",
"linuxAppArmorEnabled": "AppArmor", "linuxAppArmorEnabled": "AppArmor",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
"deviceSettingsDescription": "查看设备信息和设置", "deviceSettingsDescription": "查看设备信息和设置",
+2095 -2392
View File
File diff suppressed because it is too large Load Diff
+3048 -850
View File
File diff suppressed because it is too large Load Diff
+31 -24
View File
@@ -12,30 +12,30 @@
"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", "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"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:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:generate": "drizzle-kit generate --config=./drizzle.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:studio": "drizzle-kit studio --config=./drizzle.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": {
"@asteasolutions/zod-to-openapi": "8.4.0", "@asteasolutions/zod-to-openapi": "8.2.0",
"@aws-sdk/client-s3": "3.971.0", "@aws-sdk/client-s3": "3.955.0",
"@faker-js/faker": "10.2.0", "@faker-js/faker": "10.1.0",
"@headlessui/react": "2.2.9", "@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
@@ -75,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",
@@ -88,8 +90,9 @@
"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.8.2",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
@@ -97,26 +100,30 @@
"maxmind": "5.0.1", "maxmind": "5.0.1",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.9", "next": "15.5.9",
"next-intl": "4.7.0", "next-intl": "4.6.1",
"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.16.3",
"posthog-node": "5.23.0", "posthog-node": "5.17.4",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "9.13.0", "react-day-picker": "9.13.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.71.1", "react-hook-form": "7.68.0",
"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.6.0",
"semver": "7.7.3", "semver": "7.7.3",
"stripe": "20.2.0", "stripe": "20.1.0",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.4.0", "tailwind-merge": "3.4.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
@@ -126,10 +133,10 @@
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0", "winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.19.0", "ws": "8.18.3",
"yaml": "2.8.2", "yaml": "2.8.2",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "4.3.5", "zod": "4.2.1",
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -163,12 +170,12 @@
"esbuild": "0.27.2", "esbuild": "0.27.2",
"esbuild-node-externals": "1.20.1", "esbuild-node-externals": "1.20.1",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.8.0", "prettier": "3.7.4",
"react-email": "5.2.5", "react-email": "5.0.7",
"tailwindcss": "4.1.18", "tailwindcss": "4.1.18",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.53.1" "typescript-eslint": "8.49.0"
} }
} }
+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),
+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>
); );
} }
-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;
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const subscribedLimitSet: LimitSet = {
description: "Contact us to increase soft limit." description: "Contact us to increase soft limit."
}, // 12000 GB }, // 12000 GB
[FeatureId.DOMAINS]: { [FeatureId.DOMAINS]: {
value: 250, value: 25,
description: "Contact us to increase soft limit." description: "Contact us to increase soft limit."
}, },
[FeatureId.REMOTE_EXIT_NODES]: { [FeatureId.REMOTE_EXIT_NODES]: {
+12 -4
View File
@@ -31,7 +31,7 @@ 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 { build } from "@server/build"; import { build } from "@server/build";
export type ProxyResourcesResults = { export type ProxyResourcesResults = {
@@ -213,7 +213,11 @@ export async function updateProxyResources(
// Update existing resource // Update existing resource
const isLicensed = await isLicensedOrSubscribed(orgId); 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;
} }
@@ -590,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,7 +653,11 @@ export async function updateProxyResources(
} }
const isLicensed = await isLicensedOrSubscribed(orgId); 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;
} }
+1 -1
View File
@@ -14,7 +14,7 @@ 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";
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.15.0"; export const APP_VERSION = "1.14.0";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
-3
View File
@@ -1,3 +0,0 @@
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
+16 -2
View File
@@ -1,3 +1,17 @@
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(orgId: string): Promise<boolean> { export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
return false; if (build === "enterprise") {
} return await license.isUnlocked();
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return true;
}
+5 -5
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()
+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 {
+14 -24
View File
@@ -12,7 +12,7 @@
*/ */
import { getTierPriceSet } from "@server/lib/billing/tiers"; import { getTierPriceSet } from "@server/lib/billing/tiers";
import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions"; import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription";
import { build } from "@server/build"; import { build } from "@server/build";
export async function getOrgTierData( export async function getOrgTierData(
@@ -25,32 +25,22 @@ export async function getOrgTierData(
return { tier, active }; return { tier, active };
} }
// TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM const { subscription, items } = await getOrgSubscriptionData(orgId);
const subscriptionsWithItems = await getOrgSubscriptionsData(orgId); if (items && items.length > 0) {
const tierPriceSet = getTierPriceSet();
for (const { subscription, items } of subscriptionsWithItems) { // Iterate through tiers in order (earlier keys are higher tiers)
if (items && items.length > 0) { for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
const tierPriceSet = getTierPriceSet(); // Check if any subscription item matches this tier's price ID
// Iterate through tiers in order (earlier keys are higher tiers) const matchingItem = items.find((item) => item.priceId === priceId);
for (const [tierId, priceId] of Object.entries(tierPriceSet)) { if (matchingItem) {
// Check if any subscription item matches this tier's price ID tier = tierId;
const matchingItem = items.find((item) => item.priceId === priceId); break;
if (matchingItem) {
tier = tierId;
break;
}
} }
} }
}
if (subscription && subscription.status === "active") { if (subscription && subscription.status === "active") {
active = true; active = true;
}
// If we found a tier and active subscription, we can stop
if (tier && active) {
break;
}
} }
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");
} }
@@ -1,30 +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 { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "enterprise") {
return await license.isUnlocked();
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return false;
}
+11 -28
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(),
@@ -168,14 +157,8 @@ 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() localFilePath: z.string()
+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(
@@ -0,0 +1,51 @@
/*
* 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function verifyValidLicense(
req: Request,
res: Response,
next: NextFunction
) {
try {
if (build != "saas") {
return next();
}
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."
)
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying subscription"
)
);
}
}
@@ -19,7 +19,7 @@ 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 "#private/lib/billing"; import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import { import {
approvals, approvals,
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import { build } from "@server/build"; 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 "#private/lib/billing"; import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; 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";
@@ -29,7 +29,7 @@ const createCheckoutSessionSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
export async function createCheckoutSessionSAAS( export async function createCheckoutSession(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
@@ -87,7 +87,7 @@ export async function createCheckoutSessionSAAS(
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 };
} }
@@ -1,35 +0,0 @@
import {
getLicensePriceSet,
} from "@server/lib/billing/licenses";
import {
getTierPriceSet,
} from "@server/lib/billing/tiers";
import Stripe from "stripe";
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): "saas" | "license" {
// Determine subscription type by checking subscription items
let type: "saas" | "license" = "saas";
if (Array.isArray(fullSubscription.items?.data)) {
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)) {
type = "license";
break;
}
// Check if price ID matches any tier price (saas)
const tierPrices = Object.values(getTierPriceSet());
if (tierPrices.includes(priceId)) {
type = "saas";
break;
}
}
}
return type;
}
@@ -25,12 +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";
export async function handleSubscriptionCreated( export async function handleSubscriptionCreated(
subscription: Stripe.Subscription subscription: Stripe.Subscription
@@ -129,142 +123,24 @@ export async function handleSubscriptionCreated(
return; return;
} }
const type = getSubType(fullSubscription); await handleSubscriptionLifesycle(customer.orgId, subscription.status);
if (type === "saas") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
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,62 +64,24 @@ export async function handleSubscriptionDeleted(
return; return;
} }
const type = getSubType(fullSubscription); await handleSubscriptionLifesycle(customer.orgId, subscription.status);
if (type === "saas") {
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)
); .where(
and(
const [orgUserRes] = await db eq(userOrgs.orgId, customer.orgId),
.select() eq(userOrgs.isOwner, true)
.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) {
@@ -26,8 +26,6 @@ import logger from "@server/logger";
import { getFeatureIdByMetricId } 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,7 +56,7 @@ 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))
@@ -76,6 +74,11 @@ export async function handleSubscriptionUpdated(
}) })
.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)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({ const itemsToUpsert = fullSubscription.items.data.map((item) => ({
@@ -138,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(
@@ -233,45 +236,6 @@ export async function handleSubscriptionUpdated(
} }
} }
// --- end usage update --- // --- end usage update ---
const type = getSubType(fullSubscription);
if (type === "saas") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
} else {
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(
+2 -2
View File
@@ -11,8 +11,8 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
export * from "./createCheckoutSessionSAAS"; 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";
+4 -12
View File
@@ -159,11 +159,11 @@ if (build === "saas") {
); );
authenticated.post( authenticated.post(
"/org/:orgId/billing/create-checkout-session-saas", "/org/:orgId/billing/create-checkout-session",
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing), verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing), logActionAudit(ActionsEnum.billing),
billing.createCheckoutSessionSAAS billing.createCheckoutSession
); );
authenticated.post( authenticated.post(
@@ -175,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(
@@ -200,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({
@@ -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");
} }
@@ -37,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(),
@@ -106,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(
@@ -145,8 +117,9 @@ export async function upsertLoginPageBranding(
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" &&
+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, {
+44 -105
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,7 +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";
const getClientSchema = z.strictObject({ const getClientSchema = z.strictObject({
clientId: z clientId: z
@@ -36,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) {
@@ -49,7 +47,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;
} }
@@ -61,7 +58,7 @@ type PostureData = {
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;
@@ -78,123 +75,75 @@ 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 (
fingerprint.autoUpdatesEnabled !== null &&
fingerprint.autoUpdatesEnabled !== undefined
) {
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;
} }
} }
@@ -209,9 +158,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;
@@ -294,31 +240,27 @@ export async function getClient(
// Build fingerprint data if available // Build fingerprint data if available
const fingerprintData = client.currentFingerprint const fingerprintData = client.currentFingerprint
? { ? {
username: client.currentFingerprint.username || null, username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null, hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null, platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null, osVersion: client.currentFingerprint.osVersion || null,
kernelVersion: kernelVersion:
client.currentFingerprint.kernelVersion || null, client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null, arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null, deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null, serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null, firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null lastSeen: client.currentFingerprint.lastSeen || null
} }
: null; : null;
// Build posture data if available (platform-specific) // Build posture data if available (platform-specific)
// Only return posture data if org is licensed/subscribed
let postureData: PostureData | null = null; let postureData: PostureData | null = null;
const isOrgLicensed = await isLicensedOrSubscribed( if (build !== "oss") {
client.clients.orgId
);
if (isOrgLicensed) {
postureData = getPlatformPostureData( postureData = getPlatformPostureData(
client.currentFingerprint?.platform || null, client.currentFingerprint?.platform || null,
client.currentFingerprint client.currentFingerprint
); );
} }
const data: GetClientResponse = { const data: GetClientResponse = {
@@ -327,9 +269,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
}; };
+6 -2
View File
@@ -175,7 +175,10 @@ async function getSiteAssociations(clientIds: number[]) {
.where(inArray(clientSitesAssociationsCache.clientId, clientIds)); .where(inArray(clientSitesAssociationsCache.clientId, clientIds));
} }
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & { type ClientWithSites = Omit<
Awaited<ReturnType<typeof queryClients>>[0],
"deviceModel"
> & {
sites: Array<{ sites: Array<{
siteId: number; siteId: number;
siteName: string | null; siteName: string | null;
@@ -321,8 +324,9 @@ export async function listClients(
const clientsWithSites = clientsList.map((client) => { const clientsWithSites = clientsList.map((client) => {
const model = client.deviceModel || null; const model = client.deviceModel || null;
const newName = getUserDeviceName(model, client.name); const newName = getUserDeviceName(model, client.name);
const { deviceModel, ...clientWithoutDeviceModel } = client;
return { return {
...client, ...clientWithoutDeviceModel,
name: newName, name: newName,
sites: sitesByClient[client.clientId] || [] sites: sitesByClient[client.clientId] || []
}; };
-3
View File
@@ -6,8 +6,6 @@ export type GeneratedLicenseKey = {
createdAt: string; createdAt: string;
tier: string; tier: string;
type: string; type: string;
users: number;
sites: number;
}; };
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[]; export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
@@ -21,7 +19,6 @@ export type NewLicenseKey = {
tier: string; tier: string;
type: string; type: string;
quantity: number; quantity: number;
quantity_2: number;
isValid: boolean; isValid: boolean;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
+23 -1
View File
@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { olms } from "@server/db"; import { olms, clients } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -8,6 +8,9 @@ import response from "@server/lib/response";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -34,7 +37,26 @@ export async function archiveUserOlm(
const { olmId } = parsedParams.data; const { olmId } = parsedParams.data;
// Archive the OLM and disconnect associated clients in a transaction
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Find all clients associated with this OLM
const associatedClients = await trx
.select()
.from(clients)
.where(eq(clients.olmId, olmId));
// Disconnect clients from the OLM (set olmId to null)
for (const client of associatedClients) {
await trx
.update(clients)
.set({ olmId: null })
.where(eq(clients.clientId, client.clientId));
await rebuildClientAssociationsFromClient(client, trx);
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
}
// Archive the OLM (set archived to true)
await trx await trx
.update(olms) .update(olms)
.set({ archived: true }) .set({ archived: true })
+5 -5
View File
@@ -22,7 +22,7 @@ function fingerprintSnapshotHash(fingerprint: any, postures: any): string {
autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false, autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false,
tpmAvailable: postures.tpmAvailable ?? false, tpmAvailable: postures.tpmAvailable ?? false,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled ?? false, windowsDefenderEnabled: postures.windowsDefenderEnabled ?? false,
macosSipEnabled: postures.macosSipEnabled ?? false, macosSipEnabled: postures.macosSipEnabled ?? false,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false, macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false,
@@ -87,7 +87,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable, tpmAvailable: postures.tpmAvailable,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled, windowsDefenderEnabled: postures.windowsDefenderEnabled,
macosSipEnabled: postures.macosSipEnabled, macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -117,7 +117,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable, tpmAvailable: postures.tpmAvailable,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled, windowsDefenderEnabled: postures.windowsDefenderEnabled,
macosSipEnabled: postures.macosSipEnabled, macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -162,7 +162,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable, tpmAvailable: postures.tpmAvailable,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled, windowsDefenderEnabled: postures.windowsDefenderEnabled,
macosSipEnabled: postures.macosSipEnabled, macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -197,7 +197,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable, tpmAvailable: postures.tpmAvailable,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled, windowsDefenderEnabled: postures.windowsDefenderEnabled,
macosSipEnabled: postures.macosSipEnabled, macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
+2 -24
View File
@@ -13,7 +13,6 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error"; import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { handleFingerprintInsertion } from "./fingerprintingUtils";
@@ -47,12 +46,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
logger.debug("Handling fingerprint insertion for olm register...", {
olmId: olm.olmId,
fingerprint,
postures
});
await handleFingerprintInsertion(olm, fingerprint, postures); await handleFingerprintInsertion(olm, fingerprint, postures);
if ( if (
@@ -98,21 +91,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
const deviceModel = fingerprint?.deviceModel ?? null;
const computedName = getUserDeviceName(deviceModel, client.name);
if (computedName && computedName !== client.name) {
await db
.update(clients)
.set({ name: computedName })
.where(eq(clients.clientId, client.clientId));
}
if (computedName && computedName !== olm.name) {
await db
.update(olms)
.set({ name: computedName })
.where(eq(olms.olmId, olm.olmId));
}
const [org] = await db const [org] = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -165,7 +143,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
if (policyCheck.policies?.passwordAge?.compliant === false) { if (!policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}` `Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
); );
@@ -175,7 +153,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
); );
return; return;
} else if ( } else if (
policyCheck.policies?.maxSessionLength?.compliant === false !policyCheck.policies?.maxSessionLength?.compliant === false
) { ) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}` `Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
@@ -65,6 +65,7 @@ export async function recoverOlmWithFingerprint(
.where( .where(
and( and(
eq(olms.userId, userId), eq(olms.userId, userId),
eq(olms.archived, false),
eq( eq(
currentFingerprint.platformFingerprint, currentFingerprint.platformFingerprint,
platformFingerprint platformFingerprint
+2 -2
View File
@@ -13,7 +13,7 @@ import { build } from "@server/build";
import { getOrgTierData } from "#dynamic/lib/billing"; import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import { cache } from "@server/lib/cache"; import { cache } from "@server/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const updateOrgParamsSchema = z.strictObject({ const updateOrgParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -89,7 +89,7 @@ export async function updateOrg(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const isLicensed = await isLicensedOrSubscribed(orgId); const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) { if (build == "enterprise" && !isLicensed) {
parsedBody.data.requireTwoFactor = undefined; parsedBody.data.requireTwoFactor = undefined;
parsedBody.data.maxSessionLengthHours = undefined; parsedBody.data.maxSessionLengthHours = undefined;
parsedBody.data.passwordExpiryDays = undefined; parsedBody.data.passwordExpiryDays = undefined;
+6 -2
View File
@@ -23,7 +23,7 @@ import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const updateResourceParamsSchema = z.strictObject({ const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive()) resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -342,7 +342,11 @@ async function updateHttpResource(
} }
const isLicensed = await isLicensedOrSubscribed(resource.orgId); const isLicensed = await isLicensedOrSubscribed(resource.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
updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined; updateData.maintenanceModeType = undefined;
updateData.maintenanceTitle = undefined; updateData.maintenanceTitle = undefined;
+2 -2
View File
@@ -11,7 +11,7 @@ import { ActionsEnum } from "@server/auth/actions";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const createRoleParamsSchema = z.strictObject({ const createRoleParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -101,7 +101,7 @@ export async function createRole(
} }
const isLicensed = await isLicensedOrSubscribed(orgId); const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) { if (build === "oss" || !isLicensed) {
roleData.requireDeviceApproval = undefined; roleData.requireDeviceApproval = undefined;
} }
+3 -2
View File
@@ -8,7 +8,8 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; 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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const updateRoleParamsSchema = z.strictObject({ const updateRoleParamsSchema = z.strictObject({
@@ -111,7 +112,7 @@ export async function updateRole(
} }
const isLicensed = await isLicensedOrSubscribed(orgId); const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) { if (build === "oss" || !isLicensed) {
updateData.requireDeviceApproval = undefined; updateData.requireDeviceApproval = undefined;
} }
+5 -14
View File
@@ -17,6 +17,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators"; import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip"; import { isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
const createSiteParamsSchema = z.strictObject({ const createSiteParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -258,19 +259,7 @@ export async function createSite(
let newSite: Site; let newSite: Site;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (type == "newt") { if (type == "wireguard" || type == "newt") {
[newSite] = await trx
.insert(sites)
.values({
orgId,
name,
niceId,
address: updatedAddress || null,
type,
dockerSocketEnabled: true
})
.returning();
} else if (type == "wireguard") {
// we are creating a site with an exit node (tunneled) // we are creating a site with an exit node (tunneled)
if (!subnet) { if (!subnet) {
return next( return next(
@@ -322,9 +311,11 @@ export async function createSite(
exitNodeId, exitNodeId,
name, name,
niceId, niceId,
address: updatedAddress || null,
subnet, subnet,
type, type,
pubKey: pubKey || null dockerSocketEnabled: type == "newt",
...(pubKey && type == "wireguard" && { pubKey })
}) })
.returning(); .returning();
} else if (type == "local") { } else if (type == "local") {
@@ -97,7 +97,6 @@ export async function listAllSiteResourcesByOrg(
destination: siteResources.destination, destination: siteResources.destination,
enabled: siteResources.enabled, enabled: siteResources.enabled,
alias: siteResources.alias, alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress,
tcpPortRangeString: siteResources.tcpPortRangeString, tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString, udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp, disableIcmp: siteResources.disableIcmp,
+8 -12
View File
@@ -64,20 +64,16 @@ export async function ensureSetupToken() {
); );
} }
if (existingToken) { if (existingToken?.token !== envSetupToken) {
// Token exists in DB - update it if different console.warn(
if (existingToken.token !== envSetupToken) { "Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
console.warn( );
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
await db await db
.update(setupTokens) .update(setupTokens)
.set({ token: envSetupToken }) .set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId)); .where(eq(setupTokens.tokenId, existingToken.tokenId));
}
} else { } else {
// No existing token - insert new one
const tokenId = generateId(15); const tokenId = generateId(15);
await db.insert(setupTokens).values({ await db.insert(setupTokens).values({
+2 -2
View File
@@ -49,7 +49,7 @@ export default async function migration() {
"firewallEnabled" boolean DEFAULT false NOT NULL, "firewallEnabled" boolean DEFAULT false NOT NULL,
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL, "autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
"tpmAvailable" boolean DEFAULT false NOT NULL, "tpmAvailable" boolean DEFAULT false NOT NULL,
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL, "windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
"macosSipEnabled" boolean DEFAULT false NOT NULL, "macosSipEnabled" boolean DEFAULT false NOT NULL,
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL, "macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL, "macosFirewallStealthMode" boolean DEFAULT false NOT NULL,
@@ -75,7 +75,7 @@ export default async function migration() {
"firewallEnabled" boolean DEFAULT false NOT NULL, "firewallEnabled" boolean DEFAULT false NOT NULL,
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL, "autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
"tpmAvailable" boolean DEFAULT false NOT NULL, "tpmAvailable" boolean DEFAULT false NOT NULL,
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL, "windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
"macosSipEnabled" boolean DEFAULT false NOT NULL, "macosSipEnabled" boolean DEFAULT false NOT NULL,
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL, "macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL, "macosFirewallStealthMode" boolean DEFAULT false NOT NULL,
+2 -2
View File
@@ -53,7 +53,7 @@ CREATE TABLE 'currentFingerprint' (
'firewallEnabled' integer DEFAULT false NOT NULL, 'firewallEnabled' integer DEFAULT false NOT NULL,
'autoUpdatesEnabled' integer DEFAULT false NOT NULL, 'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
'tpmAvailable' integer DEFAULT false NOT NULL, 'tpmAvailable' integer DEFAULT false NOT NULL,
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL, 'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
'macosSipEnabled' integer DEFAULT false NOT NULL, 'macosSipEnabled' integer DEFAULT false NOT NULL,
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL, 'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
'macosFirewallStealthMode' integer DEFAULT false NOT NULL, 'macosFirewallStealthMode' integer DEFAULT false NOT NULL,
@@ -83,7 +83,7 @@ CREATE TABLE 'fingerprintSnapshots' (
'firewallEnabled' integer DEFAULT false NOT NULL, 'firewallEnabled' integer DEFAULT false NOT NULL,
'autoUpdatesEnabled' integer DEFAULT false NOT NULL, 'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
'tpmAvailable' integer DEFAULT false NOT NULL, 'tpmAvailable' integer DEFAULT false NOT NULL,
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL, 'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
'macosSipEnabled' integer DEFAULT false NOT NULL, 'macosSipEnabled' integer DEFAULT false NOT NULL,
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL, 'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
'macosFirewallStealthMode' integer DEFAULT false NOT NULL, 'macosFirewallStealthMode' integer DEFAULT false NOT NULL,
+1 -4
View File
@@ -18,7 +18,6 @@ import { build } from "@server/build";
import OrgPolicyResult from "@app/components/OrgPolicyResult"; import OrgPolicyResult from "@app/components/OrgPolicyResult";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
export default async function OrgLayout(props: { export default async function OrgLayout(props: {
children: React.ReactNode; children: React.ReactNode;
@@ -71,7 +70,6 @@ export default async function OrgLayout(props: {
} catch (e) {} } catch (e) {}
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<ApplyInternalRedirect orgId={orgId} />
<Layout orgId={orgId} navItems={[]} orgs={orgs}> <Layout orgId={orgId} navItems={[]} orgs={orgs}>
<OrgPolicyResult <OrgPolicyResult
orgId={orgId} orgId={orgId}
@@ -88,7 +86,7 @@ export default async function OrgLayout(props: {
try { try {
const getSubscription = cache(() => const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>( internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
`/org/${orgId}/billing/subscriptions`, `/org/${orgId}/billing/subscription`,
cookie cookie
) )
); );
@@ -106,7 +104,6 @@ export default async function OrgLayout(props: {
env={env.app.environment} env={env.app.environment}
sandbox_mode={env.app.sandbox_mode} sandbox_mode={env.app.sandbox_mode}
> >
<ApplyInternalRedirect orgId={orgId} />
{props.children} {props.children}
<SetLastOrgCookie orgId={orgId} /> <SetLastOrgCookie orgId={orgId} />
</SubscriptionStatusProvider> </SubscriptionStatusProvider>
@@ -19,6 +19,17 @@ export interface ApprovalFeedPageProps {
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) { export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
const params = await props.params; const params = await props.params;
let approvals: ApprovalItem[] = [];
const res = await internal
.get<
AxiosResponse<{ approvals: ApprovalItem[] }>
>(`/org/${params.orgId}/approvals`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
approvals = res.data.data.approvals;
}
let org: GetOrgResponse | null = null; let org: GetOrgResponse | null = null;
const orgRes = await getCachedOrg(params.orgId); const orgRes = await getCachedOrg(params.orgId);
@@ -43,18 +43,15 @@ import Link from "next/link";
export default function GeneralPage() { export default function GeneralPage() {
const { org } = useOrgContext(); const { org } = useOrgContext();
const envContext = useEnvContext(); const api = createApiClient(useEnvContext());
const api = createApiClient(envContext);
const t = useTranslations(); const t = useTranslations();
// Subscription state - now handling multiple subscriptions // Subscription state
const [allSubscriptions, setAllSubscriptions] = useState< const [subscription, setSubscription] =
GetOrgSubscriptionResponse["subscriptions"] useState<GetOrgSubscriptionResponse["subscription"]>(null);
const [subscriptionItems, setSubscriptionItems] = useState<
GetOrgSubscriptionResponse["items"]
>([]); >([]);
const [tierSubscription, setTierSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [licenseSubscription, setLicenseSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [subscriptionLoading, setSubscriptionLoading] = useState(true); const [subscriptionLoading, setSubscriptionLoading] = useState(true);
// Example usage data (replace with real usage data if available) // Example usage data (replace with real usage data if available)
@@ -71,41 +68,12 @@ export default function GeneralPage() {
try { try {
const res = await api.get< const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse> AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscriptions`); >(`/org/${org.org.orgId}/billing/subscription`);
const { subscriptions } = res.data.data; const { subscription, items } = res.data.data;
setAllSubscriptions(subscriptions); setSubscription(subscription);
setSubscriptionItems(items);
// Import tier and license price sets
const { getTierPriceSet } = await import("@server/lib/billing/tiers");
const { getLicensePriceSet } = await import("@server/lib/billing/licenses");
const tierPriceSet = getTierPriceSet(
envContext.env.app.environment,
envContext.env.app.sandbox_mode
);
const licensePriceSet = getLicensePriceSet(
envContext.env.app.environment,
envContext.env.app.sandbox_mode
);
// Find tier subscription (subscription with items matching tier prices)
const tierSub = subscriptions.find(({ items }) =>
items.some((item) =>
item.priceId && Object.values(tierPriceSet).includes(item.priceId)
)
);
setTierSubscription(tierSub || null);
// Find license subscription (subscription with items matching license prices)
const licenseSub = subscriptions.find(({ items }) =>
items.some((item) =>
item.priceId && Object.values(licensePriceSet).includes(item.priceId)
)
);
setLicenseSubscription(licenseSub || null);
setHasSubscription( setHasSubscription(
!!tierSub?.subscription && tierSub.subscription.status === "active" !!subscription && subscription.status === "active"
); );
} catch (error) { } catch (error) {
toast({ toast({
@@ -153,7 +121,7 @@ export default function GeneralPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await api.post<AxiosResponse<string>>( const response = await api.post<AxiosResponse<string>>(
`/org/${org.org.orgId}/billing/create-checkout-session-saas`, `/org/${org.org.orgId}/billing/create-checkout-session`,
{} {}
); );
console.log("Checkout session response:", response.data); console.log("Checkout session response:", response.data);
@@ -334,10 +302,6 @@ export default function GeneralPage() {
return { usage: usage ?? 0, item, limit }; return { usage: usage ?? 0, item, limit };
} }
// Get tier subscription items
const tierSubscriptionItems = tierSubscription?.items || [];
const tierSubscriptionData = tierSubscription?.subscription || null;
// Helper to check if usage exceeds limit // Helper to check if usage exceeds limit
function isOverLimit(usage: any, limit: any, usageType: any) { function isOverLimit(usage: any, limit: any, usageType: any) {
if (!limit || !usage) return false; if (!limit || !usage) return false;
@@ -424,15 +388,15 @@ export default function GeneralPage() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Badge <Badge
variant={ variant={
tierSubscriptionData?.status === "active" ? "green" : "outline" subscription?.status === "active" ? "green" : "outline"
} }
> >
{tierSubscriptionData?.status === "active" && ( {subscription?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" /> <CheckCircle className="h-3 w-3 mr-1" />
)} )}
{tierSubscriptionData {subscription
? tierSubscriptionData.status.charAt(0).toUpperCase() + ? subscription.status.charAt(0).toUpperCase() +
tierSubscriptionData.status.slice(1) subscription.status.slice(1)
: t("billingFreeTier")} : t("billingFreeTier")}
</Badge> </Badge>
<Link <Link
@@ -449,7 +413,7 @@ export default function GeneralPage() {
{usageTypes.some((type) => { {usageTypes.some((type) => {
const { usage, limit } = getUsageItemAndLimit( const { usage, limit } = getUsageItemAndLimit(
usageData, usageData,
tierSubscriptionItems, subscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -477,7 +441,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => { {usageTypes.map((type) => {
const { usage, limit } = getUsageItemAndLimit( const { usage, limit } = getUsageItemAndLimit(
usageData, usageData,
tierSubscriptionItems, subscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -566,7 +530,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => { {usageTypes.map((type) => {
const { item, limit } = getUsageItemAndLimit( const { item, limit } = getUsageItemAndLimit(
usageData, usageData,
tierSubscriptionItems, subscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -650,7 +614,7 @@ export default function GeneralPage() {
const { usage, item } = const { usage, item } =
getUsageItemAndLimit( getUsageItemAndLimit(
usageData, usageData,
tierSubscriptionItems, subscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -672,7 +636,7 @@ export default function GeneralPage() {
); );
})} })}
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */} {/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
{tierSubscriptionItems {subscriptionItems
.filter( .filter(
(item) => (item) =>
item.unitAmount && item.unitAmount &&
@@ -708,7 +672,7 @@ export default function GeneralPage() {
const { usage, item } = const { usage, item } =
getUsageItemAndLimit( getUsageItemAndLimit(
usageData, usageData,
tierSubscriptionItems, subscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -723,7 +687,7 @@ export default function GeneralPage() {
return sum + cost; return sum + cost;
}, 0) + }, 0) +
// Add recurring charges // Add recurring charges
tierSubscriptionItems subscriptionItems
.filter( .filter(
(item) => (item) =>
item.unitAmount && item.unitAmount &&
@@ -785,56 +749,6 @@ export default function GeneralPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
{/* License Keys Section */}
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingLicenseKeys") || "License Keys"}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("billingLicenseKeysDescription") || "Manage your license key subscriptions"}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
<span className="font-semibold">
{t("billingLicenseSubscription") || "License Subscription"}
</span>
</div>
<Badge
variant={
licenseSubscription.subscription?.status === "active"
? "green"
: "outline"
}
>
{licenseSubscription.subscription?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{licenseSubscription.subscription?.status
? licenseSubscription.subscription.status
.charAt(0)
.toUpperCase() +
licenseSubscription.subscription.status.slice(1)
: t("billingInactive") || "Inactive"}
</Badge>
</div>
<SettingsSectionFooter>
<Button
variant="secondary"
onClick={() => handleModifySubscription()}
disabled={isLoading}
>
{t("billingModifyLicenses") || "Modify License Subscription"}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer> </SettingsContainer>
); );
} }
@@ -32,7 +32,6 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { OlmInstallCommands } from "@app/components/olm-install-commands";
export default function CredentialsPage() { export default function CredentialsPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -205,12 +204,6 @@ export default function CredentialsPage() {
</SettingsSectionFooter> </SettingsSectionFooter>
)} )}
</SettingsSection> </SettingsSection>
<OlmInstallCommands
id={displayOlmId ?? "********"}
endpoint={env.app.dashboardUrl}
secret={displaySecret ?? "********"}
/>
</SettingsContainer> </SettingsContainer>
<ConfirmDeleteDialog <ConfirmDeleteDialog
@@ -1,22 +1,15 @@
"use client"; "use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect";
import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
@@ -26,24 +19,44 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { z } from "zod";
import { toast } from "@app/hooks/useToast"; import { createElement, useEffect, useState } from "react";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { ChevronDown, ChevronUp, InfoIcon, Terminal } from "lucide-react";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import {
FaApple,
FaCubes,
FaDocker,
FaFreebsd,
FaWindows
} from "react-icons/fa";
import { SiNixos, SiKubernetes } from "react-icons/si";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { import {
CreateClientBody, CreateClientBody,
CreateClientResponse, CreateClientResponse,
PickClientDefaultsResponse PickClientDefaultsResponse
} from "@server/routers/client"; } from "@server/routers/client";
import { ListSitesResponse } from "@server/routers/site";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { OlmInstallCommands } from "@app/components/olm-install-commands";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
type ClientType = "olm"; type ClientType = "olm";
@@ -55,6 +68,18 @@ interface TunnelTypeOption {
disabled?: boolean; disabled?: boolean;
} }
type CommandItem = string | { title: string; command: string };
type Commands = {
unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>;
docker: Record<string, CommandItem[]>;
};
const platforms = ["unix", "docker", "windows"] as const;
type Platform = (typeof platforms)[number];
export default function Page() { export default function Page() {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
@@ -88,9 +113,13 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true); const [loadingPage, setLoadingPage] = useState(true);
const [platform, setPlatform] = useState<Platform>("unix");
const [architecture, setArchitecture] = useState("All");
const [commands, setCommands] = useState<Commands | null>(null);
const [olmId, setOlmId] = useState(""); const [olmId, setOlmId] = useState("");
const [olmSecret, setOlmSecret] = useState(""); const [olmSecret, setOlmSecret] = useState("");
const [olmVersion, setOlmVersion] = useState("latest"); const [olmCommand, setOlmCommand] = useState("");
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
@@ -98,6 +127,136 @@ export default function Page() {
const [clientDefaults, setClientDefaults] = const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null); useState<PickClientDefaultsResponse | null>(null);
const hydrateCommands = (
id: string,
secret: string,
endpoint: string,
version: string
) => {
const commands = {
unix: {
All: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-olm.sh | bash`
},
{
title: t("run"),
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
windows: {
x64: [
{
title: t("install"),
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
docker: {
"Docker Compose": [
`services:
olm:
image: fosrl/olm
container_name: olm
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- OLM_ID=${id}
- OLM_SECRET=${secret}`
],
"Docker Run": [
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
}
};
setCommands(commands);
};
const getArchitectures = () => {
switch (platform) {
case "unix":
return ["All"];
case "windows":
return ["x64"];
case "docker":
return ["Docker Compose", "Docker Run"];
default:
return ["x64"];
}
};
const getPlatformName = (platformName: string) => {
switch (platformName) {
case "windows":
return "Windows";
case "unix":
return "Unix & macOS";
case "docker":
return "Docker";
default:
return "Unix & macOS";
}
};
const getCommand = (): CommandItem[] => {
const placeholder: CommandItem[] = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
let platformCommands = commands[platform as keyof Commands];
if (!platformCommands) {
// get first key
const firstPlatform = Object.keys(commands)[0] as Platform;
platformCommands = commands[firstPlatform as keyof Commands];
setPlatform(firstPlatform);
}
let architectureCommands = platformCommands[architecture];
if (!architectureCommands) {
// get first key
const firstArchitecture = Object.keys(platformCommands)[0];
architectureCommands = platformCommands[firstArchitecture];
setArchitecture(firstArchitecture);
}
return architectureCommands || placeholder;
};
const getPlatformIcon = (platformName: string) => {
switch (platformName) {
case "windows":
return <FaWindows className="h-4 w-4 mr-2" />;
case "unix":
return <Terminal className="h-4 w-4 mr-2" />;
case "docker":
return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
return <SiKubernetes className="h-4 w-4 mr-2" />;
case "podman":
return <FaCubes className="h-4 w-4 mr-2" />;
case "freebsd":
return <FaFreebsd className="h-4 w-4 mr-2" />;
case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
}
};
const form = useForm<CreateClientFormValues>({ const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema), resolver: zodResolver(createClientFormSchema),
defaultValues: { defaultValues: {
@@ -152,6 +311,23 @@ export default function Page() {
const load = async () => { const load = async () => {
setLoadingPage(true); setLoadingPage(true);
// Fetch available sites
// const res = await api.get<AxiosResponse<ListSitesResponse>>(
// `/org/${orgId}/sites/`
// );
// const sites = res.data.data.sites.filter(
// (s) => s.type === "newt" && s.subnet
// );
// setSites(
// sites.map((site) => ({
// id: site.siteId.toString(),
// text: site.name
// }))
// );
let olmVersion = "latest";
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); const timeoutId = setTimeout(() => controller.abort(), 3000);
@@ -172,7 +348,7 @@ export default function Page() {
} }
const data = await response.json(); const data = await response.json();
const latestVersion = data.tag_name; const latestVersion = data.tag_name;
setOlmVersion(latestVersion); olmVersion = latestVersion;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
console.error(t("olmErrorFetchTimeout")); console.error(t("olmErrorFetchTimeout"));
@@ -201,9 +377,18 @@ export default function Page() {
const olmId = data.olmId; const olmId = data.olmId;
const olmSecret = data.olmSecret; const olmSecret = data.olmSecret;
const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`;
setOlmId(olmId); setOlmId(olmId);
setOlmSecret(olmSecret); setOlmSecret(olmSecret);
setOlmCommand(olmCommand);
hydrateCommands(
olmId,
olmSecret,
env.app.dashboardUrl,
olmVersion
);
if (data.subnet) { if (data.subnet) {
form.setValue("subnet", data.subnet); form.setValue("subnet", data.subnet);
@@ -386,12 +571,118 @@ export default function Page() {
</InfoSections> </InfoSections>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
<OlmInstallCommands <SettingsSection>
id={olmId} <SettingsSectionHeader>
endpoint={env.app.dashboardUrl} <SettingsSectionTitle>
secret={olmSecret} {t("clientInstallOlm")}
version={olmVersion} </SettingsSectionTitle>
/> <SettingsSectionDescription>
{t("clientInstallOlmDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">
{t("operatingSystem")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{platforms.map((os) => (
<Button
key={os}
variant={
platform === os
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<div>
<p className="font-bold mb-3">
{["docker", "podman"].includes(
platform
)
? t("method")
: t("architecture")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures().map(
(arch) => (
<Button
key={arch}
variant={
architecture ===
arch
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() =>
setArchitecture(
arch
)
}
>
{arch}
</Button>
)
)}
</div>
<div className="pt-4">
<p className="font-bold mb-3">
{t("commands")}
</p>
<div className="mt-2 space-y-3">
{getCommand().map(
(item, index) => {
const commandText =
typeof item ===
"string"
? item
: item.command;
const title =
typeof item ===
"string"
? undefined
: item.title;
return (
<div
key={index}
>
{title && (
<p className="text-sm font-medium mb-1.5">
{
title
}
</p>
)}
<CopyTextBox
text={
commandText
}
outline={
true
}
/>
</div>
);
}
)}
</div>
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
</> </>
)} )}
</SettingsContainer> </SettingsContainer>
@@ -656,17 +656,17 @@ export default function GeneralPage() {
</InfoSection> </InfoSection>
)} )}
{client.posture.windowsAntivirusEnabled !== null && {client.posture.windowsDefenderEnabled !== null &&
client.posture.windowsAntivirusEnabled !== undefined && ( client.posture.windowsDefenderEnabled !== undefined && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("windowsAntivirusEnabled")} {t("windowsDefenderEnabled")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{isPaidUser {isPaidUser
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.windowsAntivirusEnabled .windowsDefenderEnabled
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -265,3 +265,4 @@ function GeneralSectionForm({ org }: SectionFormProps) {
</SettingsSection> </SettingsSection>
); );
} }
@@ -67,7 +67,6 @@ export default async function ClientResourcesPage(
destination: siteResource.destination, destination: siteResource.destination,
// destinationPort: siteResource.destinationPort, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId, siteNiceId: siteResource.siteNiceId,
niceId: siteResource.niceId, niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null, tcpPortRangeString: siteResource.tcpPortRangeString || null,

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