mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-08 15:14:52 +02:00
Compare commits
147 Commits
776f3ea59f
..
1.15.2
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c01349d1 | |||
| 165bbd3584 | |||
| ffb253e0e9 | |||
| e5e9fe456f | |||
| c63589b204 | |||
| 11408c2656 | |||
| 7d4aed8819 | |||
| 508369a59d | |||
| 26a91cd5e1 | |||
| 48dd4d5913 | |||
| 72d46b7352 | |||
| 4613aae47d | |||
| 1bc4480d84 | |||
| b5d76f73e8 | |||
| a5c7913e77 | |||
| 34b914f509 | |||
| 5a3d75ca12 | |||
| 158d7b23d8 | |||
| bf5dd3b0a1 | |||
| e4d4c62833 | |||
| 20ae903d7f | |||
| f5f757e4bd | |||
| 5ad564d21b | |||
| 8f8775cb93 | |||
| 37695827aa | |||
| 7a72d209ea | |||
| b0566d3c6f | |||
| 5dda8c384f | |||
| 873408270e | |||
| 8fec8f35bc | |||
| 141c846fe2 | |||
| cb569ff14d | |||
| 1497469016 | |||
| e356a6d33b | |||
| 12aea2901d | |||
| 5ff56467ea | |||
| 3a8718a4b0 | |||
| 37c4a7b690 | |||
| b735e7c34d | |||
| 5f85c3b3b8 | |||
| 5d9cb9fa21 | |||
| 643d56958d | |||
| f378d6f040 | |||
| bb57794388 | |||
| a9ca49b8a2 | |||
| c1b473294e | |||
| e3e4bdfe09 | |||
| bfbeace2e2 | |||
| efcf46ce8a | |||
| 2085715965 | |||
| d227db7b7b | |||
| 2af67ad355 | |||
| f100854423 | |||
| 92331d7a33 | |||
| 9a5bcb9099 | |||
| 8eb6bb2a95 | |||
| 2aa65ccab3 | |||
| be1577a3e7 | |||
| c8e1b3bf29 | |||
| e17b986628 | |||
| 5f19918ca0 | |||
| 2959ad0e70 | |||
| a76eec7bb7 | |||
| 068b2a0dcd | |||
| 316b7e5653 | |||
| 00fc1da33c | |||
| 9ef93df54f | |||
| fd9fdf6399 | |||
| 8fa1701e06 | |||
| 4abe83f8a9 | |||
| 0a7564acb6 | |||
| db0f7cfbae | |||
| 1724885371 | |||
| a97e9ea8b1 | |||
| 9d30e97526 | |||
| b91330a27a | |||
| 744bc9ebe9 | |||
| 89ed9e6d7f | |||
| b007e7f54a | |||
| 6651a6df42 | |||
| 3f29b165aa | |||
| b13b91face | |||
| 63c14fe2d5 | |||
| 14e74ed02d | |||
| 7e30750618 | |||
| 4d1dd16be5 | |||
| fa49cf5eba | |||
| 26b39fc1c6 | |||
| 0d36e368ea | |||
| 859f265c68 | |||
| 3219f520ba | |||
| 97e27b6caf | |||
| 09da83a72b | |||
| d13b210e2f | |||
| 09fb672718 | |||
| 9797ad0e17 | |||
| 8b3d61ac36 | |||
| 7161c9547a | |||
| 60d4362a87 | |||
| 1836e0c8fc | |||
| d3344aeb34 | |||
| cfeb093fa6 | |||
| a469b3ffcc | |||
| 14b3a3fdd8 | |||
| 94367ce387 | |||
| 5be518aa50 | |||
| d059a8da9e | |||
| 1dcacbef7a | |||
| a25edeccf7 | |||
| 315f73c77d | |||
| 666288fccc | |||
| 0ccf61c2a9 | |||
| c16b1b27a3 | |||
| ed9ba60be6 | |||
| 24d047e3d8 | |||
| 9671079ffb | |||
| 688892523c | |||
| b02c341f62 | |||
| 3e9bcada1e | |||
| 93d4bd6438 | |||
| 5146498b33 | |||
| 72da4f39a8 | |||
| a2b2fb804b | |||
| 3eac80e666 | |||
| 718d2122a4 | |||
| 310c6c90a3 | |||
| 9d80f62d58 | |||
| 77032fc989 | |||
| 64e6086f0c | |||
| fe729ec762 | |||
| d6a829abc2 | |||
| 75005ccf81 | |||
| fd6c600531 | |||
| 6996c2501e | |||
| efbd9bdb56 | |||
| 0d34213647 | |||
| 870b85d71b | |||
| 86ba6b6f86 | |||
| 02be3cd0c4 | |||
| 1b756ef9a0 | |||
| ceda06f9ae | |||
| 068eba015b | |||
| 90f66baf85 | |||
| 5743c0bb72 | |||
| ec10c37468 | |||
| 5ee3e140ed | |||
| 8e1fd4474f |
+2
-12
@@ -44,19 +44,9 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
+87
-24
@@ -29,7 +29,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: 1.24
|
||||
|
||||
@@ -339,37 +339,37 @@ jobs:
|
||||
TAG=${{ env.TAG }}
|
||||
MAJOR_TAG=$(echo $TAG | cut -d. -f1)
|
||||
MINOR_TAG=$(echo $TAG | cut -d. -f1,2)
|
||||
|
||||
|
||||
echo "Waiting for multi-arch manifests to be ready..."
|
||||
sleep 30
|
||||
|
||||
|
||||
# Determine if this is an RC release
|
||||
IS_RC="false"
|
||||
if echo "$TAG" | grep -qE "rc[0-9]+$"; then
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
IS_RC="true"
|
||||
fi
|
||||
|
||||
|
||||
if [ "$IS_RC" = "true" ]; then
|
||||
echo "RC release detected - copying version-specific tags only"
|
||||
|
||||
|
||||
# SQLite OSS
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:$TAG \
|
||||
docker://$GHCR_IMAGE:$TAG
|
||||
|
||||
|
||||
# PostgreSQL OSS
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:postgresql-$TAG \
|
||||
docker://$GHCR_IMAGE:postgresql-$TAG
|
||||
|
||||
|
||||
# SQLite Enterprise
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:ee-$TAG \
|
||||
docker://$GHCR_IMAGE:ee-$TAG
|
||||
|
||||
|
||||
# PostgreSQL Enterprise
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
@@ -377,7 +377,7 @@ jobs:
|
||||
docker://$GHCR_IMAGE:ee-postgresql-$TAG
|
||||
else
|
||||
echo "Regular release detected - copying all tags (latest, major, minor, full version)"
|
||||
|
||||
|
||||
# SQLite OSS - all tags
|
||||
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}"
|
||||
@@ -385,7 +385,7 @@ jobs:
|
||||
docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:$TAG_SUFFIX
|
||||
done
|
||||
|
||||
|
||||
# PostgreSQL OSS - all tags
|
||||
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}"
|
||||
@@ -393,7 +393,7 @@ jobs:
|
||||
docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX
|
||||
done
|
||||
|
||||
|
||||
# SQLite Enterprise - all tags
|
||||
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}"
|
||||
@@ -401,7 +401,7 @@ jobs:
|
||||
docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:ee-$TAG_SUFFIX
|
||||
done
|
||||
|
||||
|
||||
# PostgreSQL Enterprise - all tags
|
||||
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}"
|
||||
@@ -410,7 +410,7 @@ jobs:
|
||||
docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
echo "All images copied successfully to GHCR!"
|
||||
shell: bash
|
||||
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
|
||||
# Determine if this is an RC release
|
||||
IS_RC="false"
|
||||
if echo "$TAG" | grep -qE "rc[0-9]+$"; then
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
IS_RC="true"
|
||||
fi
|
||||
|
||||
@@ -482,19 +482,82 @@ jobs:
|
||||
echo "==> cosign sign (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}"
|
||||
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
||||
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
||||
VERIFIED_INDEX=true
|
||||
else
|
||||
VERIFIED_INDEX=false
|
||||
fi
|
||||
|
||||
echo "==> cosign verify (keyless policy) ${REF}"
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer "${issuer}" \
|
||||
--certificate-identity-regexp "${id_regex}" \
|
||||
"${REF}" -o text
|
||||
|
||||
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
||||
VERIFIED_INDEX_KEYLESS=true
|
||||
else
|
||||
VERIFIED_INDEX_KEYLESS=false
|
||||
fi
|
||||
|
||||
# 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}"
|
||||
done
|
||||
done
|
||||
|
||||
|
||||
echo "All images signed and verified successfully!"
|
||||
shell: bash
|
||||
|
||||
@@ -513,7 +576,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: npm run set:oss
|
||||
|
||||
- name: Generate database migrations
|
||||
run: npm run db:sqlite:generate
|
||||
run: npm run db:generate
|
||||
|
||||
- name: Apply database migrations
|
||||
run: npm run db:sqlite:push
|
||||
@@ -64,9 +64,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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
|
||||
run: make dev-build-sqlite
|
||||
|
||||
@@ -76,8 +73,5 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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
|
||||
run: make dev-build-pg
|
||||
|
||||
Vendored
+3
-3
@@ -4,13 +4,13 @@
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
@@ -19,4 +19,4 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
+18
-39
@@ -1,21 +1,11 @@
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
# OCI Image Labels - Build Args for dynamic values
|
||||
ARG VERSION="dev"
|
||||
ARG REVISION=""
|
||||
ARG CREATED=""
|
||||
ARG LICENSE="AGPL-3.0"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG BUILD=oss
|
||||
ARG DATABASE=sqlite
|
||||
|
||||
# Derive title and description based on BUILD type
|
||||
ARG IMAGE_TITLE="Pangolin"
|
||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
RUN apk add --no-cache curl tzdata python3 make g++
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package*.json ./
|
||||
@@ -23,41 +13,31 @@ 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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# OCI Image Labels - Build Args for dynamic values
|
||||
ARG VERSION="dev"
|
||||
ARG REVISION=""
|
||||
ARG CREATED=""
|
||||
ARG LICENSE="AGPL-3.0"
|
||||
|
||||
# Derive title and description based on BUILD type
|
||||
ARG IMAGE_TITLE="Pangolin"
|
||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only curl and tzdata needed at runtime - no build tools!
|
||||
@@ -66,11 +46,10 @@ RUN apk add --no-cache curl tzdata
|
||||
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||
# This includes the compiled native modules like better-sqlite3
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/init ./dist/init
|
||||
COPY --from=builder /app/server/migrations ./dist/init
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
|
||||
</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">
|
||||
<strong>
|
||||
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
@@ -74,6 +80,8 @@ Download the Pangolin client for your platform:
|
||||
- [Mac](https://pangolin.net/downloads/mac)
|
||||
- [Windows](https://pangolin.net/downloads/windows)
|
||||
- [Linux](https://pangolin.net/downloads/linux)
|
||||
- [iOS](https://pangolin.net/downloads/ios)
|
||||
- [Android](https://pangolin.net/downloads/android)
|
||||
|
||||
## Get Started
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
||||
import { clearExitNodes } from "./commands/clearExitNodes";
|
||||
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
||||
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||
import { deleteClient } from "./commands/deleteClient";
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("pangctl")
|
||||
@@ -15,5 +16,6 @@ yargs(hideBin(process.argv))
|
||||
.command(clearExitNodes)
|
||||
.command(rotateServerSecret)
|
||||
.command(clearLicenseKeys)
|
||||
.command(deleteClient)
|
||||
.demandCommand()
|
||||
.help().argv;
|
||||
|
||||
+24
-21
@@ -1,27 +1,30 @@
|
||||
# To see all available options, please visit the docs:
|
||||
# https://docs.pangolin.net/self-host/advanced/config-file
|
||||
|
||||
app:
|
||||
dashboard_url: http://localhost:3002
|
||||
log_level: debug
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: example.com
|
||||
|
||||
server:
|
||||
secret: my_secret_key
|
||||
# https://docs.pangolin.net/
|
||||
|
||||
gerbil:
|
||||
base_endpoint: example.com
|
||||
start_port: 51820
|
||||
base_endpoint: "{{.DashboardDomain}}"
|
||||
|
||||
orgs:
|
||||
block_size: 24
|
||||
subnet_group: 100.90.137.0/20
|
||||
app:
|
||||
dashboard_url: "https://{{.DashboardDomain}}"
|
||||
log_level: "info"
|
||||
telemetry:
|
||||
anonymous_usage: true
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "{{.BaseDomain}}"
|
||||
|
||||
server:
|
||||
secret: "{{.Secret}}"
|
||||
cors:
|
||||
origins: ["https://{{.DashboardDomain}}"]
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||
credentials: false
|
||||
|
||||
flags:
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: true
|
||||
allow_raw_resources: true
|
||||
enable_integration_api: true
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: false
|
||||
allow_raw_resources: true
|
||||
|
||||
@@ -21,9 +21,8 @@ http:
|
||||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||
service: next-service
|
||||
priority: 10
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
@@ -35,7 +34,6 @@ http:
|
||||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
priority: 100
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
@@ -53,3 +51,12 @@ http:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:3000" # API/WebSocket server
|
||||
|
||||
tcp:
|
||||
serversTransports:
|
||||
pp-transport-v1:
|
||||
proxyProtocol:
|
||||
version: 1
|
||||
pp-transport-v2:
|
||||
proxyProtocol:
|
||||
version: 2
|
||||
|
||||
@@ -3,32 +3,52 @@ api:
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
file:
|
||||
directory: "/var/dynamic"
|
||||
watch: true
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "v1.3.0"
|
||||
version: "{{.BadgerVersion}}"
|
||||
|
||||
log:
|
||||
level: "DEBUG"
|
||||
level: "INFO"
|
||||
format: "common"
|
||||
maxSize: 100
|
||||
maxBackups: 3
|
||||
maxAge: 3
|
||||
compress: true
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
email: "{{.LetsEncryptEmail}}"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":9443"
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
|
||||
ping:
|
||||
entryPoint: "web"
|
||||
|
||||
+7
-1
@@ -6,6 +6,12 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
// 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 = `
|
||||
// patch __dirname
|
||||
// import { fileURLToPath } from "url";
|
||||
@@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv))
|
||||
describe: "Build type (oss, saas, enterprise)",
|
||||
type: "string",
|
||||
choices: ["oss", "saas", "enterprise"],
|
||||
default: "oss"
|
||||
default: build
|
||||
})
|
||||
.help()
|
||||
.alias("help", "h").argv;
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ module installer
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/term v0.39.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.39.0 // indirect
|
||||
require golang.org/x/sys v0.40.0 // indirect
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+7
-11
@@ -6,7 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -592,17 +593,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
||||
}
|
||||
|
||||
func generateRandomSecretKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 32
|
||||
|
||||
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))]
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to generate random secret key: %v", err))
|
||||
}
|
||||
return string(b)
|
||||
return base64.StdEncoding.EncodeToString(secret)
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
|
||||
+14
-13
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.",
|
||||
"resourcesTableDestination": "Дестинация",
|
||||
"resourcesTableAlias": "Псевдоним",
|
||||
"resourcesTableAliasAddress": "Адрес на псевдоним.",
|
||||
"resourcesTableAliasAddressInfo": "Този адрес е част от подсистемата на организацията. Използва се за разрешаване на псевдонимни записи чрез вътрешно DNS разрешаване.",
|
||||
"resourcesTableClients": "Клиенти",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.",
|
||||
"resourcesTableNoTargets": "Без цели",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Вход",
|
||||
"deviceInformation": "Информация за устройството",
|
||||
"deviceInformationDescription": "Информация за устройството и агента",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Защита на устройството.",
|
||||
"deviceSecurityDescription": "Информация за състоянието на защитата на устройството.",
|
||||
"platform": "Платформа",
|
||||
"macosVersion": "Версия на macOS",
|
||||
"windowsVersion": "Версия на Windows",
|
||||
@@ -2503,17 +2505,16 @@
|
||||
"hostname": "Име на хост",
|
||||
"firstSeen": "Видян за първи път",
|
||||
"lastSeen": "Последно видян",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"biometricsEnabled": "Активирани биометрични данни.",
|
||||
"diskEncrypted": "Криптиран диск.",
|
||||
"firewallEnabled": "Активирана защитна стена.",
|
||||
"autoUpdatesEnabled": "Активирани автоматични актуализации.",
|
||||
"tpmAvailable": "TPM е на разположение.",
|
||||
"macosSipEnabled": "Protection на системната цялост (SIP).",
|
||||
"macosGatekeeperEnabled": "Gatekeeper.",
|
||||
"macosFirewallStealthMode": "Скрит режим на защитната стена.",
|
||||
"linuxAppArmorEnabled": "AppArmor.",
|
||||
"linuxSELinuxEnabled": "SELinux.",
|
||||
"deviceSettingsDescription": "Разгледайте информация и настройки на устройството",
|
||||
"devicePendingApprovalDescription": "Това устройство чака одобрение",
|
||||
"deviceBlockedDescription": "Това устройство е в момента блокирано. Няма да може да се свърже с никакви ресурси, освен ако не бъде деблокирано.",
|
||||
|
||||
+12
-11
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.",
|
||||
"resourcesTableDestination": "Místo určení",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.",
|
||||
"resourcesTableNoTargets": "Žádné cíle",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Přihlásit se",
|
||||
"deviceInformation": "Informace o zařízení",
|
||||
"deviceInformationDescription": "Informace o zařízení a agentovi",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Zabezpečení zařízení",
|
||||
"deviceSecurityDescription": "Informace o bezpečnostní pozici zařízení",
|
||||
"platform": "Platforma",
|
||||
"macosVersion": "macOS verze",
|
||||
"windowsVersion": "Verze Windows",
|
||||
@@ -2503,16 +2505,15 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "První vidění",
|
||||
"lastSeen": "Naposledy viděno",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometrie povolena",
|
||||
"diskEncrypted": "Šifrovaný disk",
|
||||
"firewallEnabled": "Firewall povolen",
|
||||
"autoUpdatesEnabled": "Automatické aktualizace povoleny",
|
||||
"tpmAvailable": "TPM k dispozici",
|
||||
"macosSipEnabled": "Ochrana systémové integrity (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Režim neviditelnosti firewallu",
|
||||
"linuxAppArmorEnabled": "Pancíř aplikace",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Zobrazit informace o zařízení a nastavení",
|
||||
"devicePendingApprovalDescription": "Toto zařízení čeká na schválení",
|
||||
|
||||
+15
-14
@@ -97,7 +97,7 @@
|
||||
"siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren",
|
||||
"siteSettingDescription": "Standorteinstellungen konfigurieren",
|
||||
"siteSetting": "{siteName} Einstellungen",
|
||||
"siteNewtTunnel": "Neuer Standort (empfohlen)",
|
||||
"siteNewtTunnel": "Newt Standort (empfohlen)",
|
||||
"siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.",
|
||||
"siteWg": "Einfacher WireGuard Tunnel",
|
||||
"siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.",
|
||||
@@ -107,7 +107,7 @@
|
||||
"siteSeeAll": "Alle Standorte anzeigen",
|
||||
"siteTunnelDescription": "Legen Sie fest, wie Sie sich mit dem Standort verbinden möchten",
|
||||
"siteNewtCredentials": "Zugangsdaten",
|
||||
"siteNewtCredentialsDescription": "So wird sich die Seite mit dem Server authentifizieren",
|
||||
"siteNewtCredentialsDescription": "So wird sich der Standort mit dem Server authentifizieren",
|
||||
"remoteNodeCredentialsDescription": "So wird sich der entfernte Node mit dem Server authentifizieren",
|
||||
"siteCredentialsSave": "Anmeldedaten speichern",
|
||||
"siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.",
|
||||
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.",
|
||||
"resourcesTableDestination": "Ziel",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.",
|
||||
"resourcesTableNoTargets": "Keine Ziele",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Anmelden",
|
||||
"deviceInformation": "Geräteinformationen",
|
||||
"deviceInformationDescription": "Informationen über das Gerät und den Agent",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Gerätesicherheit",
|
||||
"deviceSecurityDescription": "Informationen zur Gerätesicherheit",
|
||||
"platform": "Plattform",
|
||||
"macosVersion": "macOS-Version",
|
||||
"windowsVersion": "Windows-Version",
|
||||
@@ -2501,18 +2503,17 @@
|
||||
"deviceModel": "Gerätemodell",
|
||||
"serialNumber": "Seriennummer",
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Erster Blick",
|
||||
"firstSeen": "Zuerst gesehen",
|
||||
"lastSeen": "Zuletzt gesehen",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometrie aktiviert",
|
||||
"diskEncrypted": "Festplatte verschlüsselt",
|
||||
"firewallEnabled": "Firewall aktiviert",
|
||||
"autoUpdatesEnabled": "Automatische Updates aktiviert",
|
||||
"tpmAvailable": "TPM verfügbar",
|
||||
"macosSipEnabled": "Schutz der Systemintegrität (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Firewall Stealth-Modus",
|
||||
"linuxAppArmorEnabled": "AppRüstung",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Geräteinformationen und -einstellungen anzeigen",
|
||||
"devicePendingApprovalDescription": "Dieses Gerät wartet auf Freigabe",
|
||||
|
||||
+38
-1
@@ -1436,6 +1436,15 @@
|
||||
"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.",
|
||||
"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",
|
||||
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
|
||||
"failed": "Failed",
|
||||
@@ -1625,6 +1634,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "No internal resources found.",
|
||||
"resourcesTableDestination": "Destination",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.",
|
||||
"resourcesTableNoTargets": "No targets",
|
||||
@@ -2111,6 +2122,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
||||
"instanceName": "Instance Name",
|
||||
@@ -2508,7 +2545,7 @@
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"windowsAntivirusEnabled": "Antivirus Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
|
||||
+11
-10
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.",
|
||||
"resourcesTableDestination": "Destino",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.",
|
||||
"resourcesTableNoTargets": "Sin objetivos",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Iniciar sesión",
|
||||
"deviceInformation": "Información del dispositivo",
|
||||
"deviceInformationDescription": "Información sobre el dispositivo y el agente",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Seguridad del dispositivo",
|
||||
"deviceSecurityDescription": "Información de postura de seguridad del dispositivo",
|
||||
"platform": "Plataforma",
|
||||
"macosVersion": "versión macOS",
|
||||
"windowsVersion": "Versión de Windows",
|
||||
@@ -2503,15 +2505,14 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Primer detectado",
|
||||
"lastSeen": "Último Visto",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometría habilitada",
|
||||
"diskEncrypted": "Disco cifrado",
|
||||
"firewallEnabled": "Cortafuegos activado",
|
||||
"autoUpdatesEnabled": "Actualizaciones automáticas habilitadas",
|
||||
"tpmAvailable": "TPM disponible",
|
||||
"macosSipEnabled": "Protección de integridad del sistema (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"macosFirewallStealthMode": "Modo Sigilo Firewall",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Ver información y ajustes del dispositivo",
|
||||
|
||||
+12
-11
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.",
|
||||
"resourcesTableDestination": "Destination",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.",
|
||||
"resourcesTableNoTargets": "Aucune cible",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Se connecter",
|
||||
"deviceInformation": "Informations sur l'appareil",
|
||||
"deviceInformationDescription": "Informations sur l'appareil et l'agent",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Sécurité de l'appareil",
|
||||
"deviceSecurityDescription": "Informations sur la posture de sécurité de l'appareil",
|
||||
"platform": "Plateforme",
|
||||
"macosVersion": "Version macOS",
|
||||
"windowsVersion": "Version de Windows",
|
||||
@@ -2503,16 +2505,15 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Première vue",
|
||||
"lastSeen": "Dernière vue",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "biométrique activée",
|
||||
"diskEncrypted": "Disque chiffré",
|
||||
"firewallEnabled": "Pare-feu activé",
|
||||
"autoUpdatesEnabled": "Mises à jour automatiques activées",
|
||||
"tpmAvailable": "TPM disponible",
|
||||
"macosSipEnabled": "Protection contre l'intégrité du système (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Mode furtif du pare-feu",
|
||||
"linuxAppArmorEnabled": "Armure d'application",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Afficher les informations et les paramètres de l'appareil",
|
||||
"devicePendingApprovalDescription": "Cet appareil est en attente d'approbation",
|
||||
|
||||
+11
-10
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.",
|
||||
"resourcesTableDestination": "Destinazione",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.",
|
||||
"resourcesTableNoTargets": "Nessun obiettivo",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Log In",
|
||||
"deviceInformation": "Informazioni Sul Dispositivo",
|
||||
"deviceInformationDescription": "Informazioni sul dispositivo e sull'agente",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Sicurezza Del Dispositivo",
|
||||
"deviceSecurityDescription": "Informazioni postura sicurezza dispositivo",
|
||||
"platform": "Piattaforma",
|
||||
"macosVersion": "versione macOS",
|
||||
"windowsVersion": "Versione Windows",
|
||||
@@ -2503,15 +2505,14 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Prima Visto",
|
||||
"lastSeen": "Visto L'Ultima",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometria Abilitata",
|
||||
"diskEncrypted": "Cifratura Del Disco",
|
||||
"firewallEnabled": "Firewall Abilitato",
|
||||
"autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati",
|
||||
"tpmAvailable": "TPM Disponibile",
|
||||
"macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"macosFirewallStealthMode": "Modo Furtivo Del Firewall",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Visualizza informazioni e impostazioni del dispositivo",
|
||||
|
||||
+11
-10
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.",
|
||||
"resourcesTableDestination": "대상지",
|
||||
"resourcesTableAlias": "별칭",
|
||||
"resourcesTableAliasAddress": "별칭 주소",
|
||||
"resourcesTableAliasAddressInfo": "이 주소는 조직의 유틸리티 서브넷의 일부로, 내부 DNS 해석을 사용하여 별칭 레코드를 해석하는 데 사용됩니다.",
|
||||
"resourcesTableClients": "클라이언트",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.",
|
||||
"resourcesTableNoTargets": "대상 없음",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "로그인",
|
||||
"deviceInformation": "장치 정보",
|
||||
"deviceInformationDescription": "장치와 에이전트 정보",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "디바이스 보안",
|
||||
"deviceSecurityDescription": "디바이스 보안 상태 정보",
|
||||
"platform": "플랫폼",
|
||||
"macosVersion": "macOS 버전",
|
||||
"windowsVersion": "Windows 버전",
|
||||
@@ -2503,15 +2505,14 @@
|
||||
"hostname": "호스트 이름",
|
||||
"firstSeen": "처음 발견됨",
|
||||
"lastSeen": "마지막으로 발견됨",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "생체 인식 활성화",
|
||||
"diskEncrypted": "디스크 암호화됨",
|
||||
"firewallEnabled": "방화벽 활성화",
|
||||
"autoUpdatesEnabled": "자동 업데이트 활성화",
|
||||
"tpmAvailable": "TPM 사용 가능",
|
||||
"macosSipEnabled": "시스템 무결성 보호 (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"macosFirewallStealthMode": "방화벽 스텔스 모드",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "장치 정보 및 설정 보기",
|
||||
|
||||
+12
-11
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.",
|
||||
"resourcesTableDestination": "Destinasjon",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.",
|
||||
"resourcesTableNoTargets": "Ingen mål",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Logg inn",
|
||||
"deviceInformation": "Enhetens informasjon",
|
||||
"deviceInformationDescription": "Informasjon om enheten og agenten",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Enhetens sikkerhet",
|
||||
"deviceSecurityDescription": "Sikkerhetsstillings informasjon om utstyr",
|
||||
"platform": "Plattform",
|
||||
"macosVersion": "macOS versjon",
|
||||
"windowsVersion": "Windows versjon",
|
||||
@@ -2503,16 +2505,15 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Først sett",
|
||||
"lastSeen": "Sist sett",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometri aktivert",
|
||||
"diskEncrypted": "Disk kryptert",
|
||||
"firewallEnabled": "Brannmur aktivert",
|
||||
"autoUpdatesEnabled": "Automatiske oppdateringer aktivert",
|
||||
"tpmAvailable": "TPM tilgjengelig",
|
||||
"macosSipEnabled": "System Integritetsbeskyttelse (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Brannmur Usynlig Modus",
|
||||
"linuxAppArmorEnabled": "Rustning",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Vis enhetsinformasjon og innstillinger",
|
||||
"devicePendingApprovalDescription": "Denne enheten venter på godkjenning",
|
||||
|
||||
+12
-11
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.",
|
||||
"resourcesTableDestination": "Bestemming",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.",
|
||||
"resourcesTableNoTargets": "Geen doelen",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Log in",
|
||||
"deviceInformation": "Apparaat informatie",
|
||||
"deviceInformationDescription": "Informatie over het apparaat en de agent",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Apparaat beveiliging",
|
||||
"deviceSecurityDescription": "Apparaat beveiligingsinformatie",
|
||||
"platform": "Platform",
|
||||
"macosVersion": "macOS versie",
|
||||
"windowsVersion": "Windows versie",
|
||||
@@ -2503,16 +2505,15 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Eerst gezien",
|
||||
"lastSeen": "Laatst gezien op",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometrie ingeschakeld",
|
||||
"diskEncrypted": "Schijf versleuteld",
|
||||
"firewallEnabled": "Firewall ingeschakeld",
|
||||
"autoUpdatesEnabled": "Auto Updates Ingeschakeld",
|
||||
"tpmAvailable": "TPM beschikbaar",
|
||||
"macosSipEnabled": "Systeemintegriteitsbescherming (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Firewall Verberg Modus",
|
||||
"linuxAppArmorEnabled": "Appharnas",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Apparaatinformatie en -instellingen bekijken",
|
||||
"devicePendingApprovalDescription": "Dit apparaat wacht op goedkeuring",
|
||||
|
||||
+12
-11
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.",
|
||||
"resourcesTableDestination": "Miejsce docelowe",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.",
|
||||
"resourcesTableNoTargets": "Brak celów",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Zaloguj się",
|
||||
"deviceInformation": "Informacje o urządzeniu",
|
||||
"deviceInformationDescription": "Informacje o urządzeniu i agentach",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Bezpieczeństwo urządzenia",
|
||||
"deviceSecurityDescription": "Informacje o bezpieczeństwie urządzenia",
|
||||
"platform": "Platforma",
|
||||
"macosVersion": "Wersja macOS",
|
||||
"windowsVersion": "Wersja Windows",
|
||||
@@ -2503,16 +2505,15 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Widziany po raz pierwszy",
|
||||
"lastSeen": "Ostatnio widziane",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometria włączona",
|
||||
"diskEncrypted": "Dysk zaszyfrowany",
|
||||
"firewallEnabled": "Zapora włączona",
|
||||
"autoUpdatesEnabled": "Automatyczne aktualizacje włączone",
|
||||
"tpmAvailable": "TPM dostępne",
|
||||
"macosSipEnabled": "Ochrona integralności systemu (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Tryb Stealth zapory",
|
||||
"linuxAppArmorEnabled": "Zbroja aplikacji",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Wyświetl informacje o urządzeniu i ustawienia",
|
||||
"devicePendingApprovalDescription": "To urządzenie czeka na zatwierdzenie",
|
||||
|
||||
+11
-10
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.",
|
||||
"resourcesTableDestination": "Destino",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.",
|
||||
"resourcesTableNoTargets": "Nenhum alvo",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Iniciar sessão",
|
||||
"deviceInformation": "Informações do dispositivo",
|
||||
"deviceInformationDescription": "Informações sobre o dispositivo e o agente",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Segurança do dispositivo",
|
||||
"deviceSecurityDescription": "Informações sobre postagem de segurança",
|
||||
"platform": "Plataforma",
|
||||
"macosVersion": "Versão do macOS",
|
||||
"windowsVersion": "Versão do Windows",
|
||||
@@ -2503,15 +2505,14 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Visto primeiro",
|
||||
"lastSeen": "Visto por último",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biometria habilitada",
|
||||
"diskEncrypted": "Disco criptografado",
|
||||
"firewallEnabled": "Firewall habilitado",
|
||||
"autoUpdatesEnabled": "Atualizações Automáticas Habilitadas",
|
||||
"tpmAvailable": "TPM disponível",
|
||||
"macosSipEnabled": "Proteção da Integridade do Sistema (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"macosFirewallStealthMode": "Modo Furtivo do Firewall",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Ver informações e configurações do dispositivo",
|
||||
|
||||
+12
-11
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.",
|
||||
"resourcesTableDestination": "Пункт назначения",
|
||||
"resourcesTableAlias": "Alias",
|
||||
"resourcesTableAliasAddress": "Псевдоним адреса",
|
||||
"resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.",
|
||||
"resourcesTableClients": "Клиенты",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.",
|
||||
"resourcesTableNoTargets": "Нет ярлыков",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Войти",
|
||||
"deviceInformation": "Информация об устройстве",
|
||||
"deviceInformationDescription": "Информация о устройстве и агенте",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Безопасность устройства",
|
||||
"deviceSecurityDescription": "Информация о позе безопасности устройства",
|
||||
"platform": "Платформа",
|
||||
"macosVersion": "Версия macOS",
|
||||
"windowsVersion": "Версия Windows",
|
||||
@@ -2503,16 +2505,15 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Первый раз виден",
|
||||
"lastSeen": "Последнее посещение",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Включены биометрические данные",
|
||||
"diskEncrypted": "Диск зашифрован",
|
||||
"firewallEnabled": "Брандмауэр включен",
|
||||
"autoUpdatesEnabled": "Автоматические обновления включены",
|
||||
"tpmAvailable": "Доступно TPM",
|
||||
"macosSipEnabled": "Защита целостности системы (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"macosFirewallStealthMode": "Стилс-режим брандмауэра",
|
||||
"linuxAppArmorEnabled": "Броня",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Просмотр информации и настроек устройства",
|
||||
"devicePendingApprovalDescription": "Это устройство ожидает одобрения",
|
||||
|
||||
+11
-10
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.",
|
||||
"resourcesTableDestination": "Hedef",
|
||||
"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",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.",
|
||||
"resourcesTableNoTargets": "Hedef yok",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "Giriş Yap",
|
||||
"deviceInformation": "Cihaz Bilgisi",
|
||||
"deviceInformationDescription": "Cihaz ve temsilci hakkında bilgi",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "Cihaz Güvenliği",
|
||||
"deviceSecurityDescription": "Cihaz güvenliği durumu bilgisi",
|
||||
"platform": "Platform",
|
||||
"macosVersion": "macOS Sürümü",
|
||||
"windowsVersion": "Windows Sürümü",
|
||||
@@ -2503,15 +2505,14 @@
|
||||
"hostname": "Ana Makine Adı",
|
||||
"firstSeen": "İlk Görüldü",
|
||||
"lastSeen": "Son Görüldü",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "Biyometri Etkin",
|
||||
"diskEncrypted": "Disk Şifrelenmiş",
|
||||
"firewallEnabled": "Güvenlik Duvarı Etkin",
|
||||
"autoUpdatesEnabled": "Otomatik Güncellemeler Etkin",
|
||||
"tpmAvailable": "TPM Mevcut",
|
||||
"macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Cihaz bilgilerini ve ayarlarını görüntüleyin",
|
||||
|
||||
+11
-10
@@ -1625,6 +1625,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "未找到内部资源。",
|
||||
"resourcesTableDestination": "目标",
|
||||
"resourcesTableAlias": "Alias",
|
||||
"resourcesTableAliasAddress": "别名地址",
|
||||
"resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。",
|
||||
"resourcesTableClients": "客户端",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。",
|
||||
"resourcesTableNoTargets": "没有目标",
|
||||
@@ -2489,8 +2491,8 @@
|
||||
"logIn": "登录",
|
||||
"deviceInformation": "设备信息",
|
||||
"deviceInformationDescription": "关于设备和代理的信息",
|
||||
"deviceSecurity": "Device Security",
|
||||
"deviceSecurityDescription": "Device security posture information",
|
||||
"deviceSecurity": "设备安全",
|
||||
"deviceSecurityDescription": "设备安全态势信息",
|
||||
"platform": "平台",
|
||||
"macosVersion": "macOS 版本",
|
||||
"windowsVersion": "Windows 版本",
|
||||
@@ -2503,15 +2505,14 @@
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "第一次查看",
|
||||
"lastSeen": "上次查看时间",
|
||||
"biometricsEnabled": "Biometrics Enabled",
|
||||
"diskEncrypted": "Disk Encrypted",
|
||||
"firewallEnabled": "Firewall Enabled",
|
||||
"autoUpdatesEnabled": "Auto Updates Enabled",
|
||||
"tpmAvailable": "TPM Available",
|
||||
"windowsDefenderEnabled": "Windows Defender Enabled",
|
||||
"macosSipEnabled": "System Integrity Protection (SIP)",
|
||||
"biometricsEnabled": "生物计已启用",
|
||||
"diskEncrypted": "磁盘加密",
|
||||
"firewallEnabled": "防火墙已启用",
|
||||
"autoUpdatesEnabled": "启用自动更新",
|
||||
"tpmAvailable": "TPM 可用",
|
||||
"macosSipEnabled": "系统完整性保护 (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Firewall Stealth Mode",
|
||||
"macosFirewallStealthMode": "防火墙隐形模式",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "查看设备信息和设置",
|
||||
|
||||
+2392
-2095
File diff suppressed because it is too large
Load Diff
Generated
+850
-3048
File diff suppressed because it is too large
Load Diff
+24
-31
@@ -12,30 +12,30 @@
|
||||
"license": "SEE LICENSE IN LICENSE AND README.md",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
||||
"dev:check": "npx tsc --noEmit && npm run format:check",
|
||||
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
|
||||
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
|
||||
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
|
||||
"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: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: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",
|
||||
"next:build": "next build",
|
||||
"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",
|
||||
"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: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",
|
||||
"build:next": "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",
|
||||
"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",
|
||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.2.0",
|
||||
"@aws-sdk/client-s3": "3.955.0",
|
||||
"@faker-js/faker": "10.1.0",
|
||||
"@asteasolutions/zod-to-openapi": "8.4.0",
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@faker-js/faker": "10.2.0",
|
||||
"@headlessui/react": "2.2.9",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -75,9 +75,7 @@
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.1.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cookies": "0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3": "7.9.0",
|
||||
@@ -90,9 +88,8 @@
|
||||
"glob": "13.0.0",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"i": "0.3.7",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.8.2",
|
||||
"ioredis": "5.9.2",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
@@ -100,30 +97,26 @@
|
||||
"maxmind": "5.0.1",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.5.9",
|
||||
"next-intl": "4.6.1",
|
||||
"next-intl": "4.7.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "7.0.11",
|
||||
"npm": "11.7.0",
|
||||
"nprogress": "0.2.0",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.16.3",
|
||||
"posthog-node": "5.17.4",
|
||||
"pg": "8.17.1",
|
||||
"posthog-node": "5.23.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.68.0",
|
||||
"react-hook-form": "7.71.1",
|
||||
"react-icons": "5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.6.0",
|
||||
"resend": "6.8.0",
|
||||
"semver": "7.7.3",
|
||||
"stripe": "20.1.0",
|
||||
"stripe": "20.2.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"topojson-client": "3.1.0",
|
||||
@@ -133,10 +126,10 @@
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.18.3",
|
||||
"ws": "8.19.0",
|
||||
"yaml": "2.8.2",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.2.1",
|
||||
"zod": "4.3.5",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -170,12 +163,12 @@
|
||||
"esbuild": "0.27.2",
|
||||
"esbuild-node-externals": "1.20.1",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.7.4",
|
||||
"react-email": "5.0.7",
|
||||
"prettier": "3.8.0",
|
||||
"react-email": "5.2.5",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.49.0"
|
||||
"typescript-eslint": "8.53.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ export const currentFingerprint = pgTable("currentFingerprint", {
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsDefenderEnabled: boolean("windowsDefenderEnabled")
|
||||
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
@@ -830,7 +830,7 @@ export const fingerprintSnapshots = pgTable("fingerprintSnapshots", {
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsDefenderEnabled: boolean("windowsDefenderEnabled")
|
||||
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
|
||||
@@ -475,7 +475,7 @@ export const currentFingerprint = sqliteTable("currentFingerprint", {
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsDefenderEnabled: integer("windowsDefenderEnabled", {
|
||||
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
@@ -549,7 +549,7 @@ export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", {
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsDefenderEnabled: integer("windowsDefenderEnabled", {
|
||||
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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,6 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
export default function CopyCodeBox({ text }: { text: string }) {
|
||||
const DEFAULT_HINT = "Copy and paste this code when prompted";
|
||||
|
||||
export default function CopyCodeBox({
|
||||
text,
|
||||
hint
|
||||
}: {
|
||||
text: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-block">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
|
||||
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) {
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Copy and paste this code when prompted
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{hint ?? DEFAULT_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const subscribedLimitSet: LimitSet = {
|
||||
description: "Contact us to increase soft limit."
|
||||
}, // 12000 GB
|
||||
[FeatureId.DOMAINS]: {
|
||||
value: 25,
|
||||
value: 250,
|
||||
description: "Contact us to increase soft limit."
|
||||
},
|
||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import { pickPort } from "@server/routers/target/helpers";
|
||||
import { resourcePassword } from "@server/db";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isLicensedOrSubscribed } from "../isLicencedOrSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type ProxyResourcesResults = {
|
||||
@@ -213,11 +213,7 @@ export async function updateProxyResources(
|
||||
// Update existing resource
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build == "enterprise" && !isLicensed) {
|
||||
logger.warn(
|
||||
"Server is not licensed! Clearing set maintenance screen values"
|
||||
);
|
||||
// null the maintenance mode fields if not licensed
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
}
|
||||
|
||||
@@ -594,7 +590,7 @@ export async function updateProxyResources(
|
||||
existingRule.action !== getRuleAction(rule.action) ||
|
||||
existingRule.match !== rule.match.toUpperCase() ||
|
||||
existingRule.value !==
|
||||
getRuleValue(rule.match.toUpperCase(), rule.value) ||
|
||||
getRuleValue(rule.match.toUpperCase(), rule.value) ||
|
||||
existingRule.priority !== intendedPriority
|
||||
) {
|
||||
validateRule(rule);
|
||||
@@ -653,11 +649,7 @@ export async function updateProxyResources(
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build == "enterprise" && !isLicensed) {
|
||||
logger.warn(
|
||||
"Server is not licensed! Clearing set maintenance screen values"
|
||||
);
|
||||
// null the maintenance mode fields if not licensed
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@server/db";
|
||||
import { getUniqueClientName } from "@server/db/names";
|
||||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import logger from "@server/logger";
|
||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.14.0";
|
||||
export const APP_VERSION = "1.15.0";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
};
|
||||
@@ -1,17 +1,3 @@
|
||||
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> {
|
||||
if (build === "enterprise") {
|
||||
return await license.isUnlocked();
|
||||
}
|
||||
|
||||
if (build === "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
return tier === TierId.STANDARD;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -3,13 +3,10 @@ import yaml from "js-yaml";
|
||||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
import { getEnvOrYaml } from "./getEnvOrYaml";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
};
|
||||
|
||||
export const configSchema = z
|
||||
.object({
|
||||
app: z
|
||||
@@ -311,7 +308,10 @@ export const configSchema = z
|
||||
.object({
|
||||
smtp_host: z.string().optional(),
|
||||
smtp_port: portSchema.optional(),
|
||||
smtp_user: z.string().optional(),
|
||||
smtp_user: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("EMAIL_SMTP_USER")),
|
||||
smtp_pass: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -12,6 +12,10 @@ export type LicenseStatus = {
|
||||
isLicenseValid: boolean; // Is the license key valid?
|
||||
hostId: string; // Host ID
|
||||
tier?: LicenseKeyTier;
|
||||
maxSites?: number;
|
||||
usedSites?: number;
|
||||
maxUsers?: number;
|
||||
usedUsers?: number;
|
||||
};
|
||||
|
||||
export type LicenseKeyCache = {
|
||||
@@ -22,12 +26,14 @@ export type LicenseKeyCache = {
|
||||
type?: LicenseKeyType;
|
||||
tier?: LicenseKeyTier;
|
||||
terminateAt?: Date;
|
||||
quantity?: number;
|
||||
quantity_2?: number;
|
||||
};
|
||||
|
||||
export class License {
|
||||
private serverSecret!: string;
|
||||
|
||||
constructor(private hostMeta: HostMeta) {}
|
||||
constructor(private hostMeta: HostMeta) { }
|
||||
|
||||
public async check(): Promise<LicenseStatus> {
|
||||
return {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { getTierPriceSet } from "@server/lib/billing/tiers";
|
||||
import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription";
|
||||
import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export async function getOrgTierData(
|
||||
@@ -25,22 +25,32 @@ export async function getOrgTierData(
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
const { subscription, items } = await getOrgSubscriptionData(orgId);
|
||||
// TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM
|
||||
|
||||
if (items && items.length > 0) {
|
||||
const tierPriceSet = getTierPriceSet();
|
||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
||||
// Check if any subscription item matches this tier's price ID
|
||||
const matchingItem = items.find((item) => item.priceId === priceId);
|
||||
if (matchingItem) {
|
||||
tier = tierId;
|
||||
break;
|
||||
const subscriptionsWithItems = await getOrgSubscriptionsData(orgId);
|
||||
|
||||
for (const { subscription, items } of subscriptionsWithItems) {
|
||||
if (items && items.length > 0) {
|
||||
const tierPriceSet = getTierPriceSet();
|
||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
||||
// Check if any subscription item matches this tier's price ID
|
||||
const matchingItem = items.find((item) => item.priceId === priceId);
|
||||
if (matchingItem) {
|
||||
tier = tierId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subscription && subscription.status === "active") {
|
||||
active = true;
|
||||
|
||||
if (subscription && subscription.status === "active") {
|
||||
active = true;
|
||||
}
|
||||
|
||||
// If we found a tier and active subscription, we can stop
|
||||
if (tier && active) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import * as fs from "fs";
|
||||
import logger from "@server/logger";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
let encryptionKeyPath = "";
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
@@ -27,15 +26,7 @@ function loadEncryptData() {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
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();
|
||||
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { privateConfigFilePath1 } from "@server/lib/consts";
|
||||
import { z } from "zod";
|
||||
import { colorsSchema } from "@server/lib/colorsSchema";
|
||||
import { build } from "@server/build";
|
||||
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
@@ -32,19 +33,29 @@ export const privateConfigSchema = z.object({
|
||||
}),
|
||||
server: z
|
||||
.object({
|
||||
encryption_key_path: z
|
||||
encryption_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("./config/encryption.pem")
|
||||
.pipe(z.string().min(8)),
|
||||
resend_api_key: z.string().optional(),
|
||||
reo_client_id: z.string().optional(),
|
||||
fossorial_api_key: z.string().optional()
|
||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||
resend_api_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
||||
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()
|
||||
.default({
|
||||
encryption_key_path: "./config/encryption.pem"
|
||||
}),
|
||||
.prefault({}),
|
||||
redis: z
|
||||
.object({
|
||||
host: z.string(),
|
||||
@@ -157,8 +168,14 @@ export const privateConfigSchema = z.object({
|
||||
.optional(),
|
||||
stripe: z
|
||||
.object({
|
||||
secret_key: z.string(),
|
||||
webhook_secret: z.string(),
|
||||
secret_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
|
||||
webhook_secret: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
|
||||
s3Bucket: z.string(),
|
||||
s3Region: z.string().default("us-east-1"),
|
||||
localFilePath: z.string()
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, HostMeta } from "@server/db";
|
||||
import { db, HostMeta, sites, users } from "@server/db";
|
||||
import { hostMeta, licenseKey } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import NodeCache from "node-cache";
|
||||
import { validateJWT } from "./licenseJwt";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ type TokenPayload = {
|
||||
type: LicenseKeyType;
|
||||
tier: LicenseKeyTier;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
terminateAt: string; // ISO
|
||||
iat: number; // Issued at
|
||||
};
|
||||
@@ -140,10 +141,20 @@ 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 = {
|
||||
hostId: this.hostMeta.hostMetaId,
|
||||
isHostLicensed: true,
|
||||
isLicenseValid: false
|
||||
isLicenseValid: false,
|
||||
usedSites: siteCountRes?.value ?? 0,
|
||||
usedUsers: userCountRes?.value ?? 0
|
||||
};
|
||||
|
||||
this.checkInProgress = true;
|
||||
@@ -151,6 +162,8 @@ LQIDAQAB
|
||||
try {
|
||||
if (!this.doRecheck && this.statusCache.has(this.statusKey)) {
|
||||
const res = this.statusCache.get("status") as LicenseStatus;
|
||||
res.usedSites = status.usedSites;
|
||||
res.usedUsers = status.usedUsers;
|
||||
return res;
|
||||
}
|
||||
logger.debug("Checking license status...");
|
||||
@@ -193,7 +206,9 @@ LQIDAQAB
|
||||
type: payload.type,
|
||||
tier: payload.tier,
|
||||
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") {
|
||||
@@ -292,6 +307,8 @@ LQIDAQAB
|
||||
cached.tier = payload.tier;
|
||||
cached.iat = new Date(payload.iat * 1000);
|
||||
cached.terminateAt = new Date(payload.terminateAt);
|
||||
cached.quantity = payload.quantity;
|
||||
cached.quantity_2 = payload.quantity_2;
|
||||
|
||||
// Encrypt the updated token before storing
|
||||
const encryptedKey = encrypt(
|
||||
@@ -317,7 +334,7 @@ LQIDAQAB
|
||||
}
|
||||
}
|
||||
|
||||
// Compute host status
|
||||
// Compute host status: quantity = users, quantity_2 = sites
|
||||
for (const key of keys) {
|
||||
const cached = newCache.get(key.licenseKey)!;
|
||||
|
||||
@@ -329,6 +346,28 @@ LQIDAQAB
|
||||
if (!cached.valid) {
|
||||
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
|
||||
@@ -502,7 +541,7 @@ LQIDAQAB
|
||||
// Calculate exponential backoff delay
|
||||
const retryDelay = Math.floor(
|
||||
initialRetryDelay *
|
||||
Math.pow(exponentialFactor, attempt - 1)
|
||||
Math.pow(exponentialFactor, attempt - 1)
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -1,51 +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 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 { build } from "@server/build";
|
||||
import { getOrgTierData } from "@server/lib/billing";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import {
|
||||
approvals,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
||||
|
||||
import { build } from "@server/build";
|
||||
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
||||
import { getOrgTierData } from "@server/lib/billing";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import response from "@server/lib/response";
|
||||
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ const createCheckoutSessionSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export async function createCheckoutSession(
|
||||
export async function createCheckoutSessionSAAS(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@@ -87,7 +87,7 @@ export async function createCheckoutSession(
|
||||
data: session.url,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization created successfully",
|
||||
message: "Checkout session created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
+21
-31
@@ -37,18 +37,7 @@ const getOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/billing/subscription",
|
||||
description: "Get an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: getOrgSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getOrgSubscription(
|
||||
export async function getOrgSubscriptions(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
@@ -66,12 +55,9 @@ export async function getOrgSubscription(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
let subscriptionData = null;
|
||||
let itemsData: SubscriptionItem[] = [];
|
||||
let subscriptions = null;
|
||||
try {
|
||||
const { subscription, items } = await getOrgSubscriptionData(orgId);
|
||||
subscriptionData = subscription;
|
||||
itemsData = items;
|
||||
subscriptions = await getOrgSubscriptionsData(orgId);
|
||||
} catch (err) {
|
||||
if ((err as Error).message === "Not found") {
|
||||
return next(
|
||||
@@ -86,8 +72,7 @@ export async function getOrgSubscription(
|
||||
|
||||
return response<GetOrgSubscriptionResponse>(res, {
|
||||
data: {
|
||||
subscription: subscriptionData,
|
||||
items: itemsData
|
||||
subscriptions
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
@@ -102,9 +87,9 @@ export async function getOrgSubscription(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgSubscriptionData(
|
||||
export async function getOrgSubscriptionsData(
|
||||
orgId: string
|
||||
): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
|
||||
): Promise<Array<{ subscription: Subscription; items: SubscriptionItem[] }>> {
|
||||
const org = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
@@ -122,21 +107,21 @@ export async function getOrgSubscriptionData(
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
let subscription = null;
|
||||
let items: SubscriptionItem[] = [];
|
||||
const subscriptionsWithItems: Array<{
|
||||
subscription: Subscription;
|
||||
items: SubscriptionItem[];
|
||||
}> = [];
|
||||
|
||||
if (customer.length > 0) {
|
||||
// Get subscription for customer
|
||||
// Get all subscriptions for customer
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.customerId, customer[0].customerId))
|
||||
.limit(1);
|
||||
.where(eq(subscriptions.customerId, customer[0].customerId));
|
||||
|
||||
if (subs.length > 0) {
|
||||
subscription = subs[0];
|
||||
// Get subscription items
|
||||
items = await db
|
||||
for (const subscription of subs) {
|
||||
// Get subscription items for each subscription
|
||||
const items = await db
|
||||
.select()
|
||||
.from(subscriptionItems)
|
||||
.where(
|
||||
@@ -145,8 +130,13 @@ export async function getOrgSubscriptionData(
|
||||
subscription.subscriptionId
|
||||
)
|
||||
);
|
||||
|
||||
subscriptionsWithItems.push({
|
||||
subscription,
|
||||
items
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { subscription, items };
|
||||
return subscriptionsWithItems;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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,6 +25,12 @@ import logger from "@server/logger";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
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(
|
||||
subscription: Stripe.Subscription
|
||||
@@ -123,24 +129,142 @@ export async function handleSubscriptionCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
|
||||
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
|
||||
);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, customer.orgId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, customer.orgId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
||||
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
||||
|
||||
if (orgUserRes) {
|
||||
const email = orgUserRes.user.email;
|
||||
if (orgUserRes) {
|
||||
const email = orgUserRes.user.email;
|
||||
|
||||
if (email) {
|
||||
moveEmailToAudience(email, AudienceIds.Subscribed);
|
||||
if (email) {
|
||||
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) {
|
||||
|
||||
@@ -24,11 +24,22 @@ import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
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(
|
||||
subscription: Stripe.Subscription
|
||||
): Promise<void> {
|
||||
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
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
@@ -64,24 +75,62 @@ export async function handleSubscriptionDeleted(
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
|
||||
const type = getSubType(fullSubscription);
|
||||
if (type === "saas") {
|
||||
logger.debug(
|
||||
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, customer.orgId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.orgId,
|
||||
subscription.status
|
||||
);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, customer.orgId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
||||
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
||||
|
||||
if (orgUserRes) {
|
||||
const email = orgUserRes.user.email;
|
||||
if (orgUserRes) {
|
||||
const email = orgUserRes.user.email;
|
||||
|
||||
if (email) {
|
||||
moveEmailToAudience(email, AudienceIds.Churned);
|
||||
if (email) {
|
||||
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) {
|
||||
|
||||
@@ -26,6 +26,8 @@ import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId } from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType } from "./getSubType";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
export async function handleSubscriptionUpdated(
|
||||
subscription: Stripe.Subscription,
|
||||
@@ -56,7 +58,7 @@ export async function handleSubscriptionUpdated(
|
||||
}
|
||||
|
||||
// get the customer
|
||||
const [existingCustomer] = await db
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.customerId, subscription.customer as string))
|
||||
@@ -74,11 +76,6 @@ export async function handleSubscriptionUpdated(
|
||||
})
|
||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||
|
||||
await handleSubscriptionLifesycle(
|
||||
existingCustomer.orgId,
|
||||
subscription.status
|
||||
);
|
||||
|
||||
// Upsert subscription items
|
||||
if (Array.isArray(fullSubscription.items?.data)) {
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
|
||||
@@ -141,20 +138,20 @@ export async function handleSubscriptionUpdated(
|
||||
// This item has cycled
|
||||
const meterId = item.plan.meter;
|
||||
if (!meterId) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const featureId = getFeatureIdByMetricId(meterId);
|
||||
if (!featureId) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`No featureId found for meterId ${meterId}. Skipping usage reset.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orgId = existingCustomer.orgId;
|
||||
const orgId = customer.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
logger.warn(
|
||||
@@ -236,6 +233,45 @@ export async function handleSubscriptionUpdated(
|
||||
}
|
||||
}
|
||||
// --- 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) {
|
||||
logger.error(
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createCheckoutSession";
|
||||
export * from "./createCheckoutSessionSAAS";
|
||||
export * from "./createPortalSession";
|
||||
export * from "./getOrgSubscription";
|
||||
export * from "./getOrgSubscriptions";
|
||||
export * from "./getOrgUsage";
|
||||
export * from "./internalGetOrgTier";
|
||||
|
||||
@@ -159,11 +159,11 @@ if (build === "saas") {
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/billing/create-checkout-session",
|
||||
"/org/:orgId/billing/create-checkout-session-saas",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
billing.createCheckoutSession
|
||||
billing.createCheckoutSessionSAAS
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -175,10 +175,10 @@ if (build === "saas") {
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/billing/subscription",
|
||||
"/org/:orgId/billing/subscriptions",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
billing.getOrgSubscription
|
||||
billing.getOrgSubscriptions
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -200,6 +200,14 @@ if (build === "saas") {
|
||||
generateLicense.generateNewLicense
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/license/enterprise",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
generateLicense.generateNewEnterpriseLicense
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/send-support-request",
|
||||
rateLimit({
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||
|
||||
async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
|
||||
export interface CreateNewLicenseResponse {
|
||||
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 {
|
||||
const response = await fetch(
|
||||
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const data: CreateNewLicenseResponse = await response.json();
|
||||
|
||||
logger.debug("Fossorial API response:", { data });
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error creating new license:", error);
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
|
||||
export * from "./listGeneratedLicenses";
|
||||
export * from "./generateNewLicense";
|
||||
export * from "./generateNewEnterpriseLicense";
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
async function fetchLicenseKeys(orgId: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
||||
@@ -186,7 +186,7 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | null;
|
||||
headerAuth: ResourceHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
org: Org
|
||||
org: Org;
|
||||
};
|
||||
|
||||
export type UserSessionWithUser = {
|
||||
@@ -270,7 +270,6 @@ hybridRouter.get(
|
||||
}
|
||||
);
|
||||
|
||||
let encryptionKeyPath = "";
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
@@ -278,16 +277,8 @@ function loadEncryptData() {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyPath =
|
||||
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();
|
||||
encryptionKeyHex =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
|
||||
@@ -37,27 +37,55 @@ const paramsSchema = z.strictObject({
|
||||
const bodySchema = z.strictObject({
|
||||
logoUrl: z
|
||||
.union([
|
||||
z.string().length(0),
|
||||
z.url().refine(
|
||||
async (url) => {
|
||||
z.literal(""),
|
||||
z
|
||||
.url("Must be a valid URL")
|
||||
.superRefine(async (url, ctx) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return (
|
||||
response.status === 200 &&
|
||||
(
|
||||
response.headers.get("content-type") ?? ""
|
||||
).startsWith("image/")
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
});
|
||||
|
||||
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) {
|
||||
return false;
|
||||
let errorMessage =
|
||||
"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"
|
||||
}
|
||||
)
|
||||
})
|
||||
])
|
||||
.optional(),
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.nullish(),
|
||||
logoWidth: z.coerce.number<number>().min(1),
|
||||
logoHeight: z.coerce.number<number>().min(1),
|
||||
resourceTitle: z.string(),
|
||||
@@ -78,7 +106,7 @@ export async function upsertLoginPageBranding(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
const parsedBody = await bodySchema.safeParseAsync(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -117,9 +145,8 @@ export async function upsertLoginPageBranding(
|
||||
typeof loginPageBranding
|
||||
>;
|
||||
|
||||
if ((updateData.logoUrl ?? "").trim().length === 0) {
|
||||
updateData.logoUrl = undefined;
|
||||
}
|
||||
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
|
||||
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
|
||||
|
||||
if (
|
||||
build !== "saas" &&
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
|
||||
|
||||
export type GetOrgSubscriptionResponse = {
|
||||
subscription: Subscription | null;
|
||||
items: SubscriptionItem[];
|
||||
subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>;
|
||||
};
|
||||
|
||||
export type GetOrgUsageResponse = {
|
||||
|
||||
@@ -26,7 +26,8 @@ const applyBlueprintSchema = z
|
||||
message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
});
|
||||
}
|
||||
})
|
||||
}),
|
||||
source: z.enum(["API", "UI", "CLI"]).optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -84,7 +85,7 @@ export async function applyYAMLBlueprint(
|
||||
);
|
||||
}
|
||||
|
||||
const { blueprint: contents, name } = parsedBody.data;
|
||||
const { blueprint: contents, name, source = "UI" } = parsedBody.data;
|
||||
|
||||
logger.debug(`Received blueprint:`, contents);
|
||||
|
||||
@@ -107,7 +108,7 @@ export async function applyYAMLBlueprint(
|
||||
blueprint = await applyBlueprint({
|
||||
orgId,
|
||||
name,
|
||||
source: "UI",
|
||||
source,
|
||||
configData: parsedConfig
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Blueprint } from "@server/db";
|
||||
|
||||
export type BlueprintSource = "API" | "UI" | "NEWT";
|
||||
export type BlueprintSource = "API" | "UI" | "NEWT" | "CLI";
|
||||
|
||||
export type BlueprintData = Omit<Blueprint, "source"> & {
|
||||
source: BlueprintSource;
|
||||
|
||||
@@ -9,9 +9,6 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
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({
|
||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -77,9 +74,6 @@ export async function archiveClient(
|
||||
.update(clients)
|
||||
.set({ archived: true })
|
||||
.where(eq(clients.clientId, clientId));
|
||||
|
||||
// Rebuild associations to clean up related data
|
||||
await rebuildClientAssociationsFromClient(client, trx);
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, olms } from "@server/db";
|
||||
import { db, olms, users } from "@server/db";
|
||||
import { clients, currentFingerprint } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
|
||||
const getClientSchema = z.strictObject({
|
||||
clientId: z
|
||||
@@ -35,6 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
||||
currentFingerprint,
|
||||
eq(olms.olmId, currentFingerprint.olmId)
|
||||
)
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.limit(1);
|
||||
return res;
|
||||
} else if (niceId && orgId) {
|
||||
@@ -47,6 +49,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
||||
currentFingerprint,
|
||||
eq(olms.olmId, currentFingerprint.olmId)
|
||||
)
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
@@ -58,7 +61,7 @@ type PostureData = {
|
||||
firewallEnabled?: boolean | null;
|
||||
autoUpdatesEnabled?: boolean | null;
|
||||
tpmAvailable?: boolean | null;
|
||||
windowsDefenderEnabled?: boolean | null;
|
||||
windowsAntivirusEnabled?: boolean | null;
|
||||
macosSipEnabled?: boolean | null;
|
||||
macosGatekeeperEnabled?: boolean | null;
|
||||
macosFirewallStealthMode?: boolean | null;
|
||||
@@ -75,75 +78,123 @@ function getPlatformPostureData(
|
||||
const normalizedPlatform = platform?.toLowerCase() || "unknown";
|
||||
const posture: PostureData = {};
|
||||
|
||||
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Defender
|
||||
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Antivirus status
|
||||
if (normalizedPlatform === "windows") {
|
||||
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
|
||||
if (
|
||||
fingerprint.diskEncrypted !== null &&
|
||||
fingerprint.diskEncrypted !== undefined
|
||||
) {
|
||||
posture.diskEncrypted = fingerprint.diskEncrypted;
|
||||
}
|
||||
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.firewallEnabled !== null &&
|
||||
fingerprint.firewallEnabled !== undefined
|
||||
) {
|
||||
posture.firewallEnabled = fingerprint.firewallEnabled;
|
||||
}
|
||||
if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
|
||||
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
|
||||
}
|
||||
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
|
||||
if (
|
||||
fingerprint.tpmAvailable !== null &&
|
||||
fingerprint.tpmAvailable !== undefined
|
||||
) {
|
||||
posture.tpmAvailable = fingerprint.tpmAvailable;
|
||||
}
|
||||
if (fingerprint.windowsDefenderEnabled !== null && fingerprint.windowsDefenderEnabled !== undefined) {
|
||||
posture.windowsDefenderEnabled = fingerprint.windowsDefenderEnabled;
|
||||
if (
|
||||
fingerprint.windowsAntivirusEnabled !== null &&
|
||||
fingerprint.windowsAntivirusEnabled !== undefined
|
||||
) {
|
||||
posture.windowsAntivirusEnabled =
|
||||
fingerprint.windowsAntivirusEnabled;
|
||||
}
|
||||
}
|
||||
// macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode
|
||||
else if (normalizedPlatform === "macos") {
|
||||
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
|
||||
if (
|
||||
fingerprint.diskEncrypted !== null &&
|
||||
fingerprint.diskEncrypted !== undefined
|
||||
) {
|
||||
posture.diskEncrypted = fingerprint.diskEncrypted;
|
||||
}
|
||||
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.biometricsEnabled !== null &&
|
||||
fingerprint.biometricsEnabled !== undefined
|
||||
) {
|
||||
posture.biometricsEnabled = fingerprint.biometricsEnabled;
|
||||
}
|
||||
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.firewallEnabled !== null &&
|
||||
fingerprint.firewallEnabled !== undefined
|
||||
) {
|
||||
posture.firewallEnabled = fingerprint.firewallEnabled;
|
||||
}
|
||||
if (fingerprint.macosSipEnabled !== null && fingerprint.macosSipEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.macosSipEnabled !== null &&
|
||||
fingerprint.macosSipEnabled !== undefined
|
||||
) {
|
||||
posture.macosSipEnabled = fingerprint.macosSipEnabled;
|
||||
}
|
||||
if (fingerprint.macosGatekeeperEnabled !== null && fingerprint.macosGatekeeperEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.macosGatekeeperEnabled !== null &&
|
||||
fingerprint.macosGatekeeperEnabled !== undefined
|
||||
) {
|
||||
posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled;
|
||||
}
|
||||
if (fingerprint.macosFirewallStealthMode !== null && fingerprint.macosFirewallStealthMode !== undefined) {
|
||||
posture.macosFirewallStealthMode = fingerprint.macosFirewallStealthMode;
|
||||
if (
|
||||
fingerprint.macosFirewallStealthMode !== null &&
|
||||
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
|
||||
else if (normalizedPlatform === "linux") {
|
||||
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
|
||||
if (
|
||||
fingerprint.diskEncrypted !== null &&
|
||||
fingerprint.diskEncrypted !== undefined
|
||||
) {
|
||||
posture.diskEncrypted = fingerprint.diskEncrypted;
|
||||
}
|
||||
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.firewallEnabled !== null &&
|
||||
fingerprint.firewallEnabled !== undefined
|
||||
) {
|
||||
posture.firewallEnabled = fingerprint.firewallEnabled;
|
||||
}
|
||||
if (fingerprint.linuxAppArmorEnabled !== null && fingerprint.linuxAppArmorEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.linuxAppArmorEnabled !== null &&
|
||||
fingerprint.linuxAppArmorEnabled !== undefined
|
||||
) {
|
||||
posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled;
|
||||
}
|
||||
if (fingerprint.linuxSELinuxEnabled !== null && fingerprint.linuxSELinuxEnabled !== undefined) {
|
||||
if (
|
||||
fingerprint.linuxSELinuxEnabled !== null &&
|
||||
fingerprint.linuxSELinuxEnabled !== undefined
|
||||
) {
|
||||
posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled;
|
||||
}
|
||||
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
|
||||
if (
|
||||
fingerprint.tpmAvailable !== null &&
|
||||
fingerprint.tpmAvailable !== undefined
|
||||
) {
|
||||
posture.tpmAvailable = fingerprint.tpmAvailable;
|
||||
}
|
||||
}
|
||||
// iOS: Biometric configuration
|
||||
else if (normalizedPlatform === "ios") {
|
||||
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
|
||||
posture.biometricsEnabled = fingerprint.biometricsEnabled;
|
||||
}
|
||||
// none supported yet
|
||||
}
|
||||
// Android: Screen lock, Biometric configuration, Hard drive encryption
|
||||
else if (normalizedPlatform === "android") {
|
||||
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
|
||||
posture.biometricsEnabled = fingerprint.biometricsEnabled;
|
||||
}
|
||||
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
|
||||
if (
|
||||
fingerprint.diskEncrypted !== null &&
|
||||
fingerprint.diskEncrypted !== undefined
|
||||
) {
|
||||
posture.diskEncrypted = fingerprint.diskEncrypted;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +209,9 @@ export type GetClientResponse = NonNullable<
|
||||
olmId: string | null;
|
||||
agent: string | null;
|
||||
olmVersion: string | null;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
fingerprint: {
|
||||
username: string | null;
|
||||
hostname: string | null;
|
||||
@@ -240,27 +294,31 @@ export async function getClient(
|
||||
// Build fingerprint data if available
|
||||
const fingerprintData = client.currentFingerprint
|
||||
? {
|
||||
username: client.currentFingerprint.username || null,
|
||||
hostname: client.currentFingerprint.hostname || null,
|
||||
platform: client.currentFingerprint.platform || null,
|
||||
osVersion: client.currentFingerprint.osVersion || null,
|
||||
kernelVersion:
|
||||
client.currentFingerprint.kernelVersion || null,
|
||||
arch: client.currentFingerprint.arch || null,
|
||||
deviceModel: client.currentFingerprint.deviceModel || null,
|
||||
serialNumber: client.currentFingerprint.serialNumber || null,
|
||||
firstSeen: client.currentFingerprint.firstSeen || null,
|
||||
lastSeen: client.currentFingerprint.lastSeen || null
|
||||
}
|
||||
username: client.currentFingerprint.username || null,
|
||||
hostname: client.currentFingerprint.hostname || null,
|
||||
platform: client.currentFingerprint.platform || null,
|
||||
osVersion: client.currentFingerprint.osVersion || null,
|
||||
kernelVersion:
|
||||
client.currentFingerprint.kernelVersion || null,
|
||||
arch: client.currentFingerprint.arch || null,
|
||||
deviceModel: client.currentFingerprint.deviceModel || null,
|
||||
serialNumber: client.currentFingerprint.serialNumber || null,
|
||||
firstSeen: client.currentFingerprint.firstSeen || null,
|
||||
lastSeen: client.currentFingerprint.lastSeen || null
|
||||
}
|
||||
: null;
|
||||
|
||||
// Build posture data if available (platform-specific)
|
||||
// Only return posture data if org is licensed/subscribed
|
||||
let postureData: PostureData | null = null;
|
||||
if (build !== "oss") {
|
||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||
client.clients.orgId
|
||||
);
|
||||
if (isOrgLicensed) {
|
||||
postureData = getPlatformPostureData(
|
||||
client.currentFingerprint?.platform || null,
|
||||
client.currentFingerprint
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
const data: GetClientResponse = {
|
||||
@@ -269,6 +327,9 @@ export async function getClient(
|
||||
olmId: client.olms ? client.olms.olmId : null,
|
||||
agent: client.olms?.agent || null,
|
||||
olmVersion: client.olms?.version || null,
|
||||
userEmail: client.user?.email ?? null,
|
||||
userName: client.user?.name ?? null,
|
||||
userUsername: client.user?.username ?? null,
|
||||
fingerprint: fingerprintData,
|
||||
posture: postureData
|
||||
};
|
||||
|
||||
@@ -175,10 +175,7 @@ async function getSiteAssociations(clientIds: number[]) {
|
||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||
}
|
||||
|
||||
type ClientWithSites = Omit<
|
||||
Awaited<ReturnType<typeof queryClients>>[0],
|
||||
"deviceModel"
|
||||
> & {
|
||||
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
||||
sites: Array<{
|
||||
siteId: number;
|
||||
siteName: string | null;
|
||||
@@ -324,9 +321,8 @@ export async function listClients(
|
||||
const clientsWithSites = clientsList.map((client) => {
|
||||
const model = client.deviceModel || null;
|
||||
const newName = getUserDeviceName(model, client.name);
|
||||
const { deviceModel, ...clientWithoutDeviceModel } = client;
|
||||
return {
|
||||
...clientWithoutDeviceModel,
|
||||
...client,
|
||||
name: newName,
|
||||
sites: sitesByClient[client.clientId] || []
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ export type GeneratedLicenseKey = {
|
||||
createdAt: string;
|
||||
tier: string;
|
||||
type: string;
|
||||
users: number;
|
||||
sites: number;
|
||||
};
|
||||
|
||||
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
|
||||
@@ -19,6 +21,7 @@ export type NewLicenseKey = {
|
||||
tier: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
isValid: boolean;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { olms, clients } from "@server/db";
|
||||
import { olms } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -8,9 +8,6 @@ import response from "@server/lib/response";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
import { sendTerminateClient } from "../client/terminate";
|
||||
import { OlmErrorCodes } from "./error";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -37,26 +34,7 @@ export async function archiveUserOlm(
|
||||
|
||||
const { olmId } = parsedParams.data;
|
||||
|
||||
// Archive the OLM and disconnect associated clients in a transaction
|
||||
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
|
||||
.update(olms)
|
||||
.set({ archived: true })
|
||||
|
||||
@@ -22,7 +22,7 @@ function fingerprintSnapshotHash(fingerprint: any, postures: any): string {
|
||||
autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false,
|
||||
tpmAvailable: postures.tpmAvailable ?? false,
|
||||
|
||||
windowsDefenderEnabled: postures.windowsDefenderEnabled ?? false,
|
||||
windowsAntivirusEnabled: postures.windowsAntivirusEnabled ?? false,
|
||||
|
||||
macosSipEnabled: postures.macosSipEnabled ?? false,
|
||||
macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false,
|
||||
@@ -87,7 +87,7 @@ export async function handleFingerprintInsertion(
|
||||
autoUpdatesEnabled: postures.autoUpdatesEnabled,
|
||||
tpmAvailable: postures.tpmAvailable,
|
||||
|
||||
windowsDefenderEnabled: postures.windowsDefenderEnabled,
|
||||
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
|
||||
|
||||
macosSipEnabled: postures.macosSipEnabled,
|
||||
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
|
||||
@@ -117,7 +117,7 @@ export async function handleFingerprintInsertion(
|
||||
autoUpdatesEnabled: postures.autoUpdatesEnabled,
|
||||
tpmAvailable: postures.tpmAvailable,
|
||||
|
||||
windowsDefenderEnabled: postures.windowsDefenderEnabled,
|
||||
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
|
||||
|
||||
macosSipEnabled: postures.macosSipEnabled,
|
||||
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
|
||||
@@ -162,7 +162,7 @@ export async function handleFingerprintInsertion(
|
||||
autoUpdatesEnabled: postures.autoUpdatesEnabled,
|
||||
tpmAvailable: postures.tpmAvailable,
|
||||
|
||||
windowsDefenderEnabled: postures.windowsDefenderEnabled,
|
||||
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
|
||||
|
||||
macosSipEnabled: postures.macosSipEnabled,
|
||||
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
|
||||
@@ -197,7 +197,7 @@ export async function handleFingerprintInsertion(
|
||||
autoUpdatesEnabled: postures.autoUpdatesEnabled,
|
||||
tpmAvailable: postures.tpmAvailable,
|
||||
|
||||
windowsDefenderEnabled: postures.windowsDefenderEnabled,
|
||||
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
|
||||
|
||||
macosSipEnabled: postures.macosSipEnabled,
|
||||
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
@@ -46,6 +47,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Handling fingerprint insertion for olm register...", {
|
||||
olmId: olm.olmId,
|
||||
fingerprint,
|
||||
postures
|
||||
});
|
||||
|
||||
await handleFingerprintInsertion(olm, fingerprint, postures);
|
||||
|
||||
if (
|
||||
@@ -91,6 +98,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
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
|
||||
.select()
|
||||
.from(orgs)
|
||||
@@ -143,7 +165,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!policyCheck.policies?.passwordAge?.compliant === false) {
|
||||
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
||||
logger.warn(
|
||||
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
|
||||
);
|
||||
@@ -153,7 +175,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
);
|
||||
return;
|
||||
} else if (
|
||||
!policyCheck.policies?.maxSessionLength?.compliant === false
|
||||
policyCheck.policies?.maxSessionLength?.compliant === false
|
||||
) {
|
||||
logger.warn(
|
||||
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
|
||||
|
||||
@@ -65,7 +65,6 @@ export async function recoverOlmWithFingerprint(
|
||||
.where(
|
||||
and(
|
||||
eq(olms.userId, userId),
|
||||
eq(olms.archived, false),
|
||||
eq(
|
||||
currentFingerprint.platformFingerprint,
|
||||
platformFingerprint
|
||||
|
||||
@@ -13,7 +13,7 @@ import { build } from "@server/build";
|
||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { cache } from "@server/lib/cache";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
|
||||
const updateOrgParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -89,7 +89,7 @@ export async function updateOrg(
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build == "enterprise" && !isLicensed) {
|
||||
if (!isLicensed) {
|
||||
parsedBody.data.requireTwoFactor = undefined;
|
||||
parsedBody.data.maxSessionLengthHours = undefined;
|
||||
parsedBody.data.passwordExpiryDays = undefined;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { OpenAPITags } from "@server/openApi";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
|
||||
const updateResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -342,11 +342,7 @@ async function updateHttpResource(
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(resource.orgId);
|
||||
if (build == "enterprise" && !isLicensed) {
|
||||
logger.warn(
|
||||
"Server is not licensed! Clearing set maintenance screen values"
|
||||
);
|
||||
// null the maintenance mode fields if not licensed
|
||||
if (!isLicensed) {
|
||||
updateData.maintenanceModeEnabled = undefined;
|
||||
updateData.maintenanceModeType = undefined;
|
||||
updateData.maintenanceTitle = undefined;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ActionsEnum } from "@server/auth/actions";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
|
||||
const createRoleParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -101,7 +101,7 @@ export async function createRole(
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build === "oss" || !isLicensed) {
|
||||
if (!isLicensed) {
|
||||
roleData.requireDeviceApproval = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const updateRoleParamsSchema = z.strictObject({
|
||||
@@ -112,7 +111,7 @@ export async function updateRole(
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build === "oss" || !isLicensed) {
|
||||
if (!isLicensed) {
|
||||
updateData.requireDeviceApproval = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { hashPassword } from "@server/auth/password";
|
||||
import { isValidIP } from "@server/lib/validators";
|
||||
import { isIpInCidr } from "@server/lib/ip";
|
||||
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const createSiteParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -259,7 +258,19 @@ export async function createSite(
|
||||
let newSite: Site;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (type == "wireguard" || type == "newt") {
|
||||
if (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)
|
||||
if (!subnet) {
|
||||
return next(
|
||||
@@ -311,11 +322,9 @@ export async function createSite(
|
||||
exitNodeId,
|
||||
name,
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
subnet,
|
||||
type,
|
||||
dockerSocketEnabled: type == "newt",
|
||||
...(pubKey && type == "wireguard" && { pubKey })
|
||||
pubKey: pubKey || null
|
||||
})
|
||||
.returning();
|
||||
} else if (type == "local") {
|
||||
|
||||
@@ -97,6 +97,7 @@ export async function listAllSiteResourcesByOrg(
|
||||
destination: siteResources.destination,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress,
|
||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||
udpPortRangeString: siteResources.udpPortRangeString,
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
|
||||
@@ -64,16 +64,20 @@ export async function ensureSetupToken() {
|
||||
);
|
||||
}
|
||||
|
||||
if (existingToken?.token !== envSetupToken) {
|
||||
console.warn(
|
||||
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
|
||||
);
|
||||
if (existingToken) {
|
||||
// Token exists in DB - update it if different
|
||||
if (existingToken.token !== envSetupToken) {
|
||||
console.warn(
|
||||
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
|
||||
);
|
||||
|
||||
await db
|
||||
.update(setupTokens)
|
||||
.set({ token: envSetupToken })
|
||||
.where(eq(setupTokens.tokenId, existingToken.tokenId));
|
||||
await db
|
||||
.update(setupTokens)
|
||||
.set({ token: envSetupToken })
|
||||
.where(eq(setupTokens.tokenId, existingToken.tokenId));
|
||||
}
|
||||
} else {
|
||||
// No existing token - insert new one
|
||||
const tokenId = generateId(15);
|
||||
|
||||
await db.insert(setupTokens).values({
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function migration() {
|
||||
"firewallEnabled" boolean DEFAULT false NOT NULL,
|
||||
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
|
||||
"tpmAvailable" boolean DEFAULT false NOT NULL,
|
||||
"windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
|
||||
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL,
|
||||
"macosSipEnabled" boolean DEFAULT false NOT NULL,
|
||||
"macosGatekeeperEnabled" 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,
|
||||
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
|
||||
"tpmAvailable" boolean DEFAULT false NOT NULL,
|
||||
"windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
|
||||
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL,
|
||||
"macosSipEnabled" boolean DEFAULT false NOT NULL,
|
||||
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
|
||||
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL,
|
||||
|
||||
@@ -53,7 +53,7 @@ CREATE TABLE 'currentFingerprint' (
|
||||
'firewallEnabled' integer DEFAULT false NOT NULL,
|
||||
'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
|
||||
'tpmAvailable' integer DEFAULT false NOT NULL,
|
||||
'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
|
||||
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL,
|
||||
'macosSipEnabled' integer DEFAULT false NOT NULL,
|
||||
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
|
||||
'macosFirewallStealthMode' integer DEFAULT false NOT NULL,
|
||||
@@ -83,7 +83,7 @@ CREATE TABLE 'fingerprintSnapshots' (
|
||||
'firewallEnabled' integer DEFAULT false NOT NULL,
|
||||
'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
|
||||
'tpmAvailable' integer DEFAULT false NOT NULL,
|
||||
'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
|
||||
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL,
|
||||
'macosSipEnabled' integer DEFAULT false NOT NULL,
|
||||
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
|
||||
'macosFirewallStealthMode' integer DEFAULT false NOT NULL,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { build } from "@server/build";
|
||||
import OrgPolicyResult from "@app/components/OrgPolicyResult";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
|
||||
|
||||
export default async function OrgLayout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -70,6 +71,7 @@ export default async function OrgLayout(props: {
|
||||
} catch (e) {}
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
<OrgPolicyResult
|
||||
orgId={orgId}
|
||||
@@ -86,7 +88,7 @@ export default async function OrgLayout(props: {
|
||||
try {
|
||||
const getSubscription = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
|
||||
`/org/${orgId}/billing/subscription`,
|
||||
`/org/${orgId}/billing/subscriptions`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
@@ -104,6 +106,7 @@ export default async function OrgLayout(props: {
|
||||
env={env.app.environment}
|
||||
sandbox_mode={env.app.sandbox_mode}
|
||||
>
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
{props.children}
|
||||
<SetLastOrgCookie orgId={orgId} />
|
||||
</SubscriptionStatusProvider>
|
||||
|
||||
@@ -19,17 +19,6 @@ export interface ApprovalFeedPageProps {
|
||||
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
|
||||
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;
|
||||
const orgRes = await getCachedOrg(params.orgId);
|
||||
|
||||
|
||||
@@ -43,15 +43,18 @@ import Link from "next/link";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const envContext = useEnvContext();
|
||||
const api = createApiClient(envContext);
|
||||
const t = useTranslations();
|
||||
|
||||
// Subscription state
|
||||
const [subscription, setSubscription] =
|
||||
useState<GetOrgSubscriptionResponse["subscription"]>(null);
|
||||
const [subscriptionItems, setSubscriptionItems] = useState<
|
||||
GetOrgSubscriptionResponse["items"]
|
||||
// Subscription state - now handling multiple subscriptions
|
||||
const [allSubscriptions, setAllSubscriptions] = useState<
|
||||
GetOrgSubscriptionResponse["subscriptions"]
|
||||
>([]);
|
||||
const [tierSubscription, setTierSubscription] =
|
||||
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
|
||||
const [licenseSubscription, setLicenseSubscription] =
|
||||
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
||||
|
||||
// Example usage data (replace with real usage data if available)
|
||||
@@ -68,12 +71,41 @@ export default function GeneralPage() {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<GetOrgSubscriptionResponse>
|
||||
>(`/org/${org.org.orgId}/billing/subscription`);
|
||||
const { subscription, items } = res.data.data;
|
||||
setSubscription(subscription);
|
||||
setSubscriptionItems(items);
|
||||
>(`/org/${org.org.orgId}/billing/subscriptions`);
|
||||
const { subscriptions } = res.data.data;
|
||||
setAllSubscriptions(subscriptions);
|
||||
|
||||
// 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(
|
||||
!!subscription && subscription.status === "active"
|
||||
!!tierSub?.subscription && tierSub.subscription.status === "active"
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -121,7 +153,7 @@ export default function GeneralPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.post<AxiosResponse<string>>(
|
||||
`/org/${org.org.orgId}/billing/create-checkout-session`,
|
||||
`/org/${org.org.orgId}/billing/create-checkout-session-saas`,
|
||||
{}
|
||||
);
|
||||
console.log("Checkout session response:", response.data);
|
||||
@@ -302,6 +334,10 @@ export default function GeneralPage() {
|
||||
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
|
||||
function isOverLimit(usage: any, limit: any, usageType: any) {
|
||||
if (!limit || !usage) return false;
|
||||
@@ -388,15 +424,15 @@ export default function GeneralPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Badge
|
||||
variant={
|
||||
subscription?.status === "active" ? "green" : "outline"
|
||||
tierSubscriptionData?.status === "active" ? "green" : "outline"
|
||||
}
|
||||
>
|
||||
{subscription?.status === "active" && (
|
||||
{tierSubscriptionData?.status === "active" && (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{subscription
|
||||
? subscription.status.charAt(0).toUpperCase() +
|
||||
subscription.status.slice(1)
|
||||
{tierSubscriptionData
|
||||
? tierSubscriptionData.status.charAt(0).toUpperCase() +
|
||||
tierSubscriptionData.status.slice(1)
|
||||
: t("billingFreeTier")}
|
||||
</Badge>
|
||||
<Link
|
||||
@@ -413,7 +449,7 @@ export default function GeneralPage() {
|
||||
{usageTypes.some((type) => {
|
||||
const { usage, limit } = getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -441,7 +477,7 @@ export default function GeneralPage() {
|
||||
{usageTypes.map((type) => {
|
||||
const { usage, limit } = getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -530,7 +566,7 @@ export default function GeneralPage() {
|
||||
{usageTypes.map((type) => {
|
||||
const { item, limit } = getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -614,7 +650,7 @@ export default function GeneralPage() {
|
||||
const { usage, item } =
|
||||
getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -636,7 +672,7 @@ export default function GeneralPage() {
|
||||
);
|
||||
})}
|
||||
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
|
||||
{subscriptionItems
|
||||
{tierSubscriptionItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.unitAmount &&
|
||||
@@ -672,7 +708,7 @@ export default function GeneralPage() {
|
||||
const { usage, item } =
|
||||
getUsageItemAndLimit(
|
||||
usageData,
|
||||
subscriptionItems,
|
||||
tierSubscriptionItems,
|
||||
limitsData,
|
||||
type.id
|
||||
);
|
||||
@@ -687,7 +723,7 @@ export default function GeneralPage() {
|
||||
return sum + cost;
|
||||
}, 0) +
|
||||
// Add recurring charges
|
||||
subscriptionItems
|
||||
tierSubscriptionItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.unitAmount &&
|
||||
@@ -749,6 +785,56 @@ export default function GeneralPage() {
|
||||
</SettingsSectionBody>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { OlmInstallCommands } from "@app/components/olm-install-commands";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -204,6 +205,12 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionFooter>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<OlmInstallCommands
|
||||
id={displayOlmId ?? "********"}
|
||||
endpoint={env.app.dashboardUrl}
|
||||
secret={displaySecret ?? "********"}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,44 +26,24 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
CreateClientBody,
|
||||
CreateClientResponse,
|
||||
PickClientDefaultsResponse
|
||||
} from "@server/routers/client";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OlmInstallCommands } from "@app/components/olm-install-commands";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientType = "olm";
|
||||
@@ -68,18 +55,6 @@ interface TunnelTypeOption {
|
||||
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() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -113,13 +88,9 @@ export default function Page() {
|
||||
|
||||
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 [olmSecret, setOlmSecret] = useState("");
|
||||
const [olmCommand, setOlmCommand] = useState("");
|
||||
const [olmVersion, setOlmVersion] = useState("latest");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
@@ -127,136 +98,6 @@ export default function Page() {
|
||||
const [clientDefaults, setClientDefaults] =
|
||||
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>({
|
||||
resolver: zodResolver(createClientFormSchema),
|
||||
defaultValues: {
|
||||
@@ -311,23 +152,6 @@ export default function Page() {
|
||||
const load = async () => {
|
||||
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 {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
@@ -348,7 +172,7 @@ export default function Page() {
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
olmVersion = latestVersion;
|
||||
setOlmVersion(latestVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.error(t("olmErrorFetchTimeout"));
|
||||
@@ -377,18 +201,9 @@ export default function Page() {
|
||||
|
||||
const olmId = data.olmId;
|
||||
const olmSecret = data.olmSecret;
|
||||
const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||
|
||||
setOlmId(olmId);
|
||||
setOlmSecret(olmSecret);
|
||||
setOlmCommand(olmCommand);
|
||||
|
||||
hydrateCommands(
|
||||
olmId,
|
||||
olmSecret,
|
||||
env.app.dashboardUrl,
|
||||
olmVersion
|
||||
);
|
||||
|
||||
if (data.subnet) {
|
||||
form.setValue("subnet", data.subnet);
|
||||
@@ -571,118 +386,12 @@ export default function Page() {
|
||||
</InfoSections>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("clientInstallOlm")}
|
||||
</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>
|
||||
<OlmInstallCommands
|
||||
id={olmId}
|
||||
endpoint={env.app.dashboardUrl}
|
||||
secret={olmSecret}
|
||||
version={olmVersion}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
@@ -656,17 +656,17 @@ export default function GeneralPage() {
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.windowsDefenderEnabled !== null &&
|
||||
client.posture.windowsDefenderEnabled !== undefined && (
|
||||
{client.posture.windowsAntivirusEnabled !== null &&
|
||||
client.posture.windowsAntivirusEnabled !== undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("windowsDefenderEnabled")}
|
||||
{t("windowsAntivirusEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.windowsDefenderEnabled
|
||||
.windowsAntivirusEnabled
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
|
||||
@@ -265,4 +265,3 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export default async function ClientResourcesPage(
|
||||
destination: siteResource.destination,
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
alias: siteResource.alias || null,
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceId: siteResource.siteNiceId,
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user