mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-19 11:26:37 +00:00
Compare commits
248 Commits
1.15.3-s.1
...
cloud-mult
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0d6de986 | ||
|
|
057f82a561 | ||
|
|
719d2a5ffe | ||
|
|
d4bff9d5cb | ||
|
|
19fcc1f93b | ||
|
|
d45ea127c2 | ||
|
|
f591cf8601 | ||
|
|
6661a76aa8 | ||
|
|
a2ed22bfcc | ||
|
|
e370f8891a | ||
|
|
8a83e32c42 | ||
|
|
831eb6325c | ||
|
|
4d6240c987 | ||
|
|
79cf7c84dc | ||
|
|
b71f582329 | ||
|
|
b8c3cc751a | ||
|
|
d00262dc31 | ||
|
|
3debc6c8d3 | ||
|
|
5092eb58fb | ||
|
|
f0b9240575 | ||
|
|
9cf59c409e | ||
|
|
bfd5aa30a7 | ||
|
|
9737170665 | ||
|
|
922a040466 | ||
|
|
33f0782f3a | ||
|
|
e6a5cef945 | ||
|
|
4c8edb80b3 | ||
|
|
d4668fae99 | ||
|
|
ddfe55e3ae | ||
|
|
761a5f1d4c | ||
|
|
1fbcad8787 | ||
|
|
aba586e605 | ||
|
|
27b21b5ad4 | ||
|
|
b6e54dab17 | ||
|
|
1f8e89772d | ||
|
|
be89e5ca55 | ||
|
|
5f3657fd56 | ||
|
|
494162400e | ||
|
|
ab65bb6a8a | ||
|
|
333625f199 | ||
|
|
dbfd715381 | ||
|
|
f1d989964e | ||
|
|
b701629498 | ||
|
|
8250946325 | ||
|
|
71f63d8e6f | ||
|
|
dd5e834db0 | ||
|
|
970ecb52f0 | ||
|
|
62ea1b40e1 | ||
|
|
3b0fd5c592 | ||
|
|
b7616026dd | ||
|
|
16ad60b89a | ||
|
|
db7971d2f7 | ||
|
|
f3f8bd3125 | ||
|
|
516fd0ee8f | ||
|
|
8d6700d493 | ||
|
|
9d4ace9b3e | ||
|
|
2800655e33 | ||
|
|
91eecee11d | ||
|
|
899e5aa395 | ||
|
|
d5820c4902 | ||
|
|
a91c002274 | ||
|
|
4d142b93dd | ||
|
|
04dcf57ff3 | ||
|
|
975550c755 | ||
|
|
a964a80d85 | ||
|
|
22c3b8f116 | ||
|
|
c4b1831cfe | ||
|
|
cdb6813384 | ||
|
|
b14b68d83c | ||
|
|
3c2f930e6b | ||
|
|
ca9c7ce555 | ||
|
|
c2e95a0607 | ||
|
|
2767ee9e80 | ||
|
|
d998a8087f | ||
|
|
fdce016921 | ||
|
|
c73d70933b | ||
|
|
e9d0ad6e37 | ||
|
|
a35586f762 | ||
|
|
f527c30923 | ||
|
|
94e70219cf | ||
|
|
6496763aae | ||
|
|
a409ec269b | ||
|
|
bc7bc8da66 | ||
|
|
52484c774e | ||
|
|
4e1e0cade1 | ||
|
|
fda5904dac | ||
|
|
69ecc22318 | ||
|
|
bff9d33ee6 | ||
|
|
edf506953b | ||
|
|
5e11746549 | ||
|
|
1ae315e303 | ||
|
|
758b03ab25 | ||
|
|
e756fad573 | ||
|
|
3547450b03 | ||
|
|
733f6692c6 | ||
|
|
2d83160b16 | ||
|
|
256fa880dd | ||
|
|
b08c5f5c67 | ||
|
|
d0862a2d26 | ||
|
|
e97340ed52 | ||
|
|
e27c81eea6 | ||
|
|
7f7f3d43b2 | ||
|
|
4b1b772098 | ||
|
|
f66b88490f | ||
|
|
18f9157169 | ||
|
|
6eb82a807b | ||
|
|
bf57a97833 | ||
|
|
e9e2093220 | ||
|
|
c3540da2e3 | ||
|
|
d228cf56dd | ||
|
|
8f4cecd963 | ||
|
|
66adff44bb | ||
|
|
be41c094dc | ||
|
|
273848ca18 | ||
|
|
1e9dbead3b | ||
|
|
aeaa8ba133 | ||
|
|
24654af635 | ||
|
|
e88a21d6db | ||
|
|
bcd01badaf | ||
|
|
8e063506e0 | ||
|
|
84f5d6137a | ||
|
|
0a8565f5e8 | ||
|
|
bd8da25a46 | ||
|
|
a841f588dd | ||
|
|
75a4362ce3 | ||
|
|
e763e001e5 | ||
|
|
69475a0ae7 | ||
|
|
53e14c2ad7 | ||
|
|
1edc33148a | ||
|
|
a4cbfc74e4 | ||
|
|
c0d25aeb02 | ||
|
|
40f49bf6da | ||
|
|
0bfce87dc6 | ||
|
|
2a0655e9de | ||
|
|
a86cfa5934 | ||
|
|
54b77523c5 | ||
|
|
ba06c8928d | ||
|
|
c8a4ac1ed4 | ||
|
|
143acbae48 | ||
|
|
937f6fdae8 | ||
|
|
ba7239ac08 | ||
|
|
2e748274c0 | ||
|
|
eab2750953 | ||
|
|
17b6cb0c73 | ||
|
|
ce74489df5 | ||
|
|
342b188fae | ||
|
|
fa6fee7b55 | ||
|
|
c53d5a4d7d | ||
|
|
521e905724 | ||
|
|
4623090050 | ||
|
|
dd9e5cc541 | ||
|
|
626be6a347 | ||
|
|
56327ed503 | ||
|
|
6d1665004b | ||
|
|
59b8119fbd | ||
|
|
9ff863db5e | ||
|
|
e2ac6e6d4d | ||
|
|
df4101875a | ||
|
|
3f5c788d48 | ||
|
|
45cd4df6e5 | ||
|
|
94ac3ec76e | ||
|
|
af7263a0b1 | ||
|
|
035396f95c | ||
|
|
f318f6304b | ||
|
|
9d0ff472e5 | ||
|
|
d27482e812 | ||
|
|
d5b6de70da | ||
|
|
69c2212ea0 | ||
|
|
10be9bcd56 | ||
|
|
f531def0d2 | ||
|
|
ed40eae655 | ||
|
|
ba5ae6ed04 | ||
|
|
d6ade102dc | ||
|
|
0a6301697e | ||
|
|
13b4fc6725 | ||
|
|
c94d246c24 | ||
|
|
5b779ba9fe | ||
|
|
3ba2cb19a9 | ||
|
|
a095dddd01 | ||
|
|
1b5cfaa49b | ||
|
|
66f3fabbae | ||
|
|
0be8fb7931 | ||
|
|
431e6ffaae | ||
|
|
7d8185e0ee | ||
|
|
dff45748bd | ||
|
|
da514ef314 | ||
|
|
7f73cde794 | ||
|
|
b0af0d9cd5 | ||
|
|
e6464929ff | ||
|
|
122053939d | ||
|
|
8429197b07 | ||
|
|
44f2081882 | ||
|
|
300b4a3706 | ||
|
|
81ef2db7f8 | ||
|
|
c41e8be3e8 | ||
|
|
41bab0ce0b | ||
|
|
5f26b9eeea | ||
|
|
1cca69ad23 | ||
|
|
410ed3949b | ||
|
|
efc6ef3075 | ||
|
|
63f7dd1d20 | ||
|
|
57b8c69983 | ||
|
|
aad060810a | ||
|
|
9222b00a6f | ||
|
|
ff61b22e7e | ||
|
|
577cb91343 | ||
|
|
1889386f64 | ||
|
|
5d7f082ebf | ||
|
|
db6327c4ff | ||
|
|
fd7f6b2b99 | ||
|
|
49435398a8 | ||
|
|
e101ac341b | ||
|
|
6cfc7b7c69 | ||
|
|
313acabc86 | ||
|
|
34cced872f | ||
|
|
ac09e3aaf9 | ||
|
|
9f2fd34e99 | ||
|
|
67b63d3084 | ||
|
|
4a31a7b84b | ||
|
|
538b601b1e | ||
|
|
588f064c25 | ||
|
|
d521e79662 | ||
|
|
ccddb9244d | ||
|
|
0547396213 | ||
|
|
6c85171091 | ||
|
|
a8f6b6c1da | ||
|
|
f899326189 | ||
|
|
0f4d1d2a74 | ||
|
|
941d5c08e3 | ||
|
|
db9f74158b | ||
|
|
609ffccd67 | ||
|
|
748af1d8cb | ||
|
|
d309ec249e | ||
|
|
67949b4968 | ||
|
|
1fc40b3017 | ||
|
|
bb1a375484 | ||
|
|
13c011895d | ||
|
|
bd8d0e3392 | ||
|
|
cda6b67bef | ||
|
|
066305b095 | ||
|
|
f2ba4b270f | ||
|
|
89695df012 | ||
|
|
b04385a340 | ||
|
|
d374ea6ea6 | ||
|
|
01a2820390 | ||
|
|
c89c1a03da | ||
|
|
38ac4c5980 | ||
|
|
ed3ee64e4b |
@@ -31,4 +31,6 @@ dist
|
|||||||
migrations/
|
migrations/
|
||||||
config/
|
config/
|
||||||
build.ts
|
build.ts
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
|
Dockerfile*
|
||||||
|
migrations/
|
||||||
|
|||||||
189
.github/workflows/cicd.yml
vendored
189
.github/workflows/cicd.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: CI/CD Pipeline
|
name: Public CICD Pipeline
|
||||||
|
|
||||||
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||||
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||||
@@ -440,6 +440,10 @@ jobs:
|
|||||||
issuer="https://token.actions.githubusercontent.com"
|
issuer="https://token.actions.githubusercontent.com"
|
||||||
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
||||||
|
|
||||||
|
# Track failures
|
||||||
|
FAILED_TAGS=()
|
||||||
|
SUCCESSFUL_TAGS=()
|
||||||
|
|
||||||
# Determine if this is an RC release
|
# Determine if this is an RC release
|
||||||
IS_RC="false"
|
IS_RC="false"
|
||||||
if [[ "$TAG" == *"-rc."* ]]; then
|
if [[ "$TAG" == *"-rc."* ]]; then
|
||||||
@@ -471,94 +475,123 @@ jobs:
|
|||||||
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
||||||
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
||||||
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
TAG_FAILED=false
|
||||||
|
|
||||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
# Wrap the entire tag processing in error handling
|
||||||
REF="${BASE_IMAGE}@${DIGEST}"
|
(
|
||||||
echo "Resolved digest: ${REF}"
|
set -e
|
||||||
|
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
||||||
|
REF="${BASE_IMAGE}@${DIGEST}"
|
||||||
|
echo "Resolved digest: ${REF}"
|
||||||
|
|
||||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||||
cosign sign --recursive "${REF}"
|
cosign sign --recursive "${REF}"
|
||||||
|
|
||||||
echo "==> cosign sign (key) --recursive ${REF}"
|
echo "==> cosign sign (key) --recursive ${REF}"
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||||
|
|
||||||
# Retry wrapper for verification to handle registry propagation delays
|
# Retry wrapper for verification to handle registry propagation delays
|
||||||
retry_verify() {
|
retry_verify() {
|
||||||
local cmd="$1"
|
local cmd="$1"
|
||||||
local attempts=6
|
local attempts=6
|
||||||
local delay=5
|
local delay=5
|
||||||
local i=1
|
local i=1
|
||||||
until eval "$cmd"; do
|
until eval "$cmd"; do
|
||||||
if [ $i -ge $attempts ]; then
|
if [ $i -ge $attempts ]; then
|
||||||
echo "Verification failed after $attempts attempts"
|
echo "Verification failed after $attempts attempts"
|
||||||
return 1
|
return 1
|
||||||
fi
|
|
||||||
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
|
||||||
sleep $delay
|
|
||||||
i=$((i+1))
|
|
||||||
delay=$((delay*2))
|
|
||||||
# Cap the delay to avoid very long waits
|
|
||||||
if [ $delay -gt 60 ]; then delay=60; fi
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "==> cosign verify (public key) ${REF}"
|
|
||||||
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
|
||||||
VERIFIED_INDEX=true
|
|
||||||
else
|
|
||||||
VERIFIED_INDEX=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> cosign verify (keyless policy) ${REF}"
|
|
||||||
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
|
||||||
VERIFIED_INDEX_KEYLESS=true
|
|
||||||
else
|
|
||||||
VERIFIED_INDEX_KEYLESS=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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
|
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 (keyless policy) child ${CHILD_REF}"
|
echo "==> cosign verify (public key) ${REF}"
|
||||||
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
|
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
||||||
CHILD_VERIFIED=true
|
VERIFIED_INDEX=true
|
||||||
echo "Keyless verification succeeded for child ${CHILD_REF}"
|
else
|
||||||
else
|
VERIFIED_INDEX=false
|
||||||
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
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "==> cosign verify (keyless policy) ${REF}"
|
||||||
|
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
||||||
|
VERIFIED_INDEX_KEYLESS=true
|
||||||
|
else
|
||||||
|
VERIFIED_INDEX_KEYLESS=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
) || TAG_FAILED=true
|
||||||
|
|
||||||
|
if [ "$TAG_FAILED" = "true" ]; then
|
||||||
|
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||||
|
else
|
||||||
|
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "All images signed and verified successfully!"
|
# Report summary
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Sign and Verify Summary"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
||||||
|
echo "Failed: ${#FAILED_TAGS[@]}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
||||||
|
echo "Failed tags:"
|
||||||
|
for tag in "${FAILED_TAGS[@]}"; do
|
||||||
|
echo " - $tag"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
|
||||||
|
else
|
||||||
|
echo "✓ All images signed and verified successfully!"
|
||||||
|
fi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
post-run:
|
post-run:
|
||||||
|
|||||||
426
.github/workflows/cicd.yml.backup
vendored
426
.github/workflows/cicd.yml.backup
vendored
@@ -1,426 +0,0 @@
|
|||||||
name: CI/CD Pipeline
|
|
||||||
|
|
||||||
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
|
||||||
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write # for GHCR push
|
|
||||||
id-token: write # for Cosign Keyless (OIDC) Signing
|
|
||||||
|
|
||||||
# Required secrets:
|
|
||||||
# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub
|
|
||||||
# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing
|
|
||||||
# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pre-run:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions: write-all
|
|
||||||
steps:
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v2
|
|
||||||
with:
|
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
|
||||||
role-duration-seconds: 3600
|
|
||||||
aws-region: ${{ secrets.AWS_REGION }}
|
|
||||||
|
|
||||||
- name: Verify AWS identity
|
|
||||||
run: aws sts get-caller-identity
|
|
||||||
|
|
||||||
- name: Start EC2 instances
|
|
||||||
run: |
|
|
||||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
|
||||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
|
||||||
echo "EC2 instances started"
|
|
||||||
|
|
||||||
|
|
||||||
release-arm:
|
|
||||||
name: Build and Release (ARM64)
|
|
||||||
runs-on: [self-hosted, linux, arm64, us-east-1]
|
|
||||||
needs: [pre-run]
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
needs.pre-run.result == 'success'
|
|
||||||
}}
|
|
||||||
# Job-level timeout to avoid runaway or stuck runs
|
|
||||||
timeout-minutes: 120
|
|
||||||
env:
|
|
||||||
# Target images
|
|
||||||
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
|
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
|
||||||
|
|
||||||
- name: Monitor storage space
|
|
||||||
run: |
|
|
||||||
THRESHOLD=75
|
|
||||||
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
|
|
||||||
echo "Used space: $USED_SPACE%"
|
|
||||||
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
|
|
||||||
echo "Used space is below the threshold of 75% free. Running Docker system prune."
|
|
||||||
echo y | docker system prune -a
|
|
||||||
else
|
|
||||||
echo "Storage space is above the threshold. No action needed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
|
||||||
with:
|
|
||||||
registry: docker.io
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract tag name
|
|
||||||
id: get-tag
|
|
||||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Update version in package.json
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
|
||||||
cat server/lib/consts.ts
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Check if release candidate
|
|
||||||
id: check-rc
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
if [[ "$TAG" == *"-rc."* ]]; then
|
|
||||||
echo "IS_RC=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "IS_RC=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Build and push Docker images (Docker Hub - ARM64)
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
if [ "$IS_RC" = "true" ]; then
|
|
||||||
make build-rc-arm tag=$TAG
|
|
||||||
else
|
|
||||||
make build-release-arm tag=$TAG
|
|
||||||
fi
|
|
||||||
echo "Built & pushed ARM64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
release-amd:
|
|
||||||
name: Build and Release (AMD64)
|
|
||||||
runs-on: [self-hosted, linux, x64, us-east-1]
|
|
||||||
needs: [pre-run]
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
needs.pre-run.result == 'success'
|
|
||||||
}}
|
|
||||||
# Job-level timeout to avoid runaway or stuck runs
|
|
||||||
timeout-minutes: 120
|
|
||||||
env:
|
|
||||||
# Target images
|
|
||||||
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
|
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
|
||||||
|
|
||||||
- name: Monitor storage space
|
|
||||||
run: |
|
|
||||||
THRESHOLD=75
|
|
||||||
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
|
|
||||||
echo "Used space: $USED_SPACE%"
|
|
||||||
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
|
|
||||||
echo "Used space is below the threshold of 75% free. Running Docker system prune."
|
|
||||||
echo y | docker system prune -a
|
|
||||||
else
|
|
||||||
echo "Storage space is above the threshold. No action needed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
|
||||||
with:
|
|
||||||
registry: docker.io
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract tag name
|
|
||||||
id: get-tag
|
|
||||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Update version in package.json
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
|
||||||
cat server/lib/consts.ts
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Check if release candidate
|
|
||||||
id: check-rc
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
if [[ "$TAG" == *"-rc."* ]]; then
|
|
||||||
echo "IS_RC=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "IS_RC=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Build and push Docker images (Docker Hub - AMD64)
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
if [ "$IS_RC" = "true" ]; then
|
|
||||||
make build-rc-amd tag=$TAG
|
|
||||||
else
|
|
||||||
make build-release-amd tag=$TAG
|
|
||||||
fi
|
|
||||||
echo "Built & pushed AMD64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
create-manifest:
|
|
||||||
name: Create Multi-Arch Manifests
|
|
||||||
runs-on: [self-hosted, linux, x64, us-east-1]
|
|
||||||
needs: [release-arm, release-amd]
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
needs.release-arm.result == 'success' &&
|
|
||||||
needs.release-amd.result == 'success'
|
|
||||||
}}
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
|
||||||
with:
|
|
||||||
registry: docker.io
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract tag name
|
|
||||||
id: get-tag
|
|
||||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Check if release candidate
|
|
||||||
id: check-rc
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
if [[ "$TAG" == *"-rc."* ]]; then
|
|
||||||
echo "IS_RC=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "IS_RC=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Create multi-arch manifests
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
if [ "$IS_RC" = "true" ]; then
|
|
||||||
make create-manifests-rc tag=$TAG
|
|
||||||
else
|
|
||||||
make create-manifests tag=$TAG
|
|
||||||
fi
|
|
||||||
echo "Created multi-arch manifests for tag: ${TAG}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
sign-and-package:
|
|
||||||
name: Sign and Package
|
|
||||||
runs-on: [self-hosted, linux, x64, us-east-1]
|
|
||||||
needs: [release-arm, release-amd, create-manifest]
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
needs.release-arm.result == 'success' &&
|
|
||||||
needs.release-amd.result == 'success' &&
|
|
||||||
needs.create-manifest.result == 'success'
|
|
||||||
}}
|
|
||||||
# Job-level timeout to avoid runaway or stuck runs
|
|
||||||
timeout-minutes: 120
|
|
||||||
env:
|
|
||||||
# Target images
|
|
||||||
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
|
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
|
||||||
|
|
||||||
- name: Extract tag name
|
|
||||||
id: get-tag
|
|
||||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
|
||||||
with:
|
|
||||||
go-version: 1.24
|
|
||||||
|
|
||||||
- name: Update version in package.json
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
|
||||||
cat server/lib/consts.ts
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Pull latest Gerbil version
|
|
||||||
id: get-gerbil-tag
|
|
||||||
run: |
|
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
|
|
||||||
echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Pull latest Badger version
|
|
||||||
id: get-badger-tag
|
|
||||||
run: |
|
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
|
|
||||||
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Update install/main.go
|
|
||||||
run: |
|
|
||||||
PANGOLIN_VERSION=${{ env.TAG }}
|
|
||||||
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
|
|
||||||
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
|
||||||
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
|
|
||||||
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
|
|
||||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
|
|
||||||
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
|
|
||||||
cat install/main.go
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Build installer
|
|
||||||
working-directory: install
|
|
||||||
run: |
|
|
||||||
make go-build-release
|
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
||||||
with:
|
|
||||||
name: install-bin
|
|
||||||
path: install/bin/
|
|
||||||
|
|
||||||
- name: Install skopeo + jq
|
|
||||||
# skopeo: copy/inspect images between registries
|
|
||||||
# jq: JSON parsing tool used to extract digest values
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y skopeo jq
|
|
||||||
skopeo --version
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
env:
|
|
||||||
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json
|
|
||||||
run: |
|
|
||||||
mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")"
|
|
||||||
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Copy tag from Docker Hub to GHCR
|
|
||||||
# Mirror the already-built image (all architectures) to GHCR so we can sign it
|
|
||||||
# Wait a bit for both architectures to be available in Docker Hub manifest
|
|
||||||
env:
|
|
||||||
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
echo "Waiting for multi-arch manifest to be ready..."
|
|
||||||
sleep 30
|
|
||||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
|
||||||
skopeo copy --all --retry-times 3 \
|
|
||||||
docker://$DOCKERHUB_IMAGE:$TAG \
|
|
||||||
docker://$GHCR_IMAGE:$TAG
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install cosign
|
|
||||||
# cosign is used to sign and verify container images (key and keyless)
|
|
||||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
|
||||||
|
|
||||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
|
||||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
|
||||||
# then verify both the public key signature and the keyless OIDC signature.
|
|
||||||
env:
|
|
||||||
TAG: ${{ env.TAG }}
|
|
||||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
|
||||||
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
|
||||||
COSIGN_YES: "true"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
issuer="https://token.actions.githubusercontent.com"
|
|
||||||
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
|
||||||
|
|
||||||
for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
|
||||||
echo "Processing ${IMAGE}:${TAG}"
|
|
||||||
|
|
||||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')"
|
|
||||||
REF="${IMAGE}@${DIGEST}"
|
|
||||||
echo "Resolved digest: ${REF}"
|
|
||||||
|
|
||||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
|
||||||
cosign sign --recursive "${REF}"
|
|
||||||
|
|
||||||
echo "==> cosign sign (key) --recursive ${REF}"
|
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
|
||||||
|
|
||||||
echo "==> cosign verify (public key) ${REF}"
|
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
|
||||||
|
|
||||||
echo "==> cosign verify (keyless policy) ${REF}"
|
|
||||||
cosign verify \
|
|
||||||
--certificate-oidc-issuer "${issuer}" \
|
|
||||||
--certificate-identity-regexp "${id_regex}" \
|
|
||||||
"${REF}" -o text
|
|
||||||
done
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
post-run:
|
|
||||||
needs: [pre-run, release-arm, release-amd, create-manifest, sign-and-package]
|
|
||||||
if: >-
|
|
||||||
${{
|
|
||||||
always() &&
|
|
||||||
needs.pre-run.result == 'success' &&
|
|
||||||
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') &&
|
|
||||||
(needs.release-amd.result == 'success' || needs.release-amd.result == 'skipped' || needs.release-amd.result == 'failure') &&
|
|
||||||
(needs.create-manifest.result == 'success' || needs.create-manifest.result == 'skipped' || needs.create-manifest.result == 'failure') &&
|
|
||||||
(needs.sign-and-package.result == 'success' || needs.sign-and-package.result == 'skipped' || needs.sign-and-package.result == 'failure')
|
|
||||||
}}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions: write-all
|
|
||||||
steps:
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v2
|
|
||||||
with:
|
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
|
||||||
role-duration-seconds: 3600
|
|
||||||
aws-region: ${{ secrets.AWS_REGION }}
|
|
||||||
|
|
||||||
- name: Verify AWS identity
|
|
||||||
run: aws sts get-caller-identity
|
|
||||||
|
|
||||||
- name: Stop EC2 instances
|
|
||||||
run: |
|
|
||||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
|
||||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
|
||||||
echo "EC2 instances stopped"
|
|
||||||
2
.github/workflows/saas.yml
vendored
2
.github/workflows/saas.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: CI/CD Pipeline
|
name: SAAS Pipeline
|
||||||
|
|
||||||
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||||
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
run: npm run db:generate
|
run: npm run db:generate
|
||||||
|
|
||||||
- name: Apply database migrations
|
- name: Apply database migrations
|
||||||
run: npm run db:sqlite:push
|
run: npm run db:push
|
||||||
|
|
||||||
- name: Test with tsc
|
- name: Test with tsc
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -51,4 +51,6 @@ dynamic/
|
|||||||
scratch/
|
scratch/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
hydrateSaas.ts
|
hydrateSaas.ts
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
drizzle.config.ts
|
||||||
|
server/setup/migrations.ts
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -10,7 +10,7 @@
|
|||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
|||||||
65
Dockerfile
65
Dockerfile
@@ -1,33 +1,54 @@
|
|||||||
FROM node:24-alpine AS builder
|
FROM node:24-alpine AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG BUILD=oss
|
|
||||||
ARG DATABASE=sqlite
|
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
FROM base AS builder-dev
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG BUILD=oss
|
||||||
|
ARG DATABASE=sqlite
|
||||||
|
|
||||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||||
npm run set:$DATABASE && \
|
npm run set:$DATABASE && \
|
||||||
npm run set:$BUILD && \
|
npm run set:$BUILD && \
|
||||||
npm run db:$DATABASE:generate && \
|
npm run db:generate && \
|
||||||
npm run build && \
|
npm run build && \
|
||||||
npm run build:cli
|
npm run build:cli && \
|
||||||
|
test -f dist/server.mjs
|
||||||
|
|
||||||
# test to make sure the build output is there and error if not
|
FROM base AS builder
|
||||||
RUN test -f dist/server.mjs
|
|
||||||
|
|
||||||
# Prune dev dependencies and clean up to prepare for copy to runner
|
RUN npm ci --omit=dev
|
||||||
RUN npm prune --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl tzdata
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
COPY --from=builder-dev /app/.next/standalone ./
|
||||||
|
COPY --from=builder-dev /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder-dev /app/dist ./dist
|
||||||
|
COPY --from=builder-dev /app/server/migrations ./dist/init
|
||||||
|
|
||||||
|
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||||
|
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
||||||
|
|
||||||
|
COPY server/db/names.json ./dist/names.json
|
||||||
|
COPY server/db/ios_models.json ./dist/ios_models.json
|
||||||
|
COPY server/db/mac_models.json ./dist/mac_models.json
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
# OCI Image Labels - Build Args for dynamic values
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
ARG VERSION="dev"
|
ARG VERSION="dev"
|
||||||
ARG REVISION=""
|
ARG REVISION=""
|
||||||
@@ -38,28 +59,6 @@ ARG LICENSE="AGPL-3.0"
|
|||||||
ARG IMAGE_TITLE="Pangolin"
|
ARG IMAGE_TITLE="Pangolin"
|
||||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Only curl and tzdata needed at runtime - no build tools!
|
|
||||||
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/server/migrations ./dist/init
|
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
|
|
||||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
|
||||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
|
||||||
|
|
||||||
COPY server/db/names.json ./dist/names.json
|
|
||||||
COPY server/db/ios_models.json ./dist/ios_models.json
|
|
||||||
COPY server/db/mac_models.json ./dist/mac_models.json
|
|
||||||
COPY public ./public
|
|
||||||
|
|
||||||
# OCI Image Labels
|
# OCI Image Labels
|
||||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||||
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
|
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
FROM node:22-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineConfig } from "drizzle-kit";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const schema = [path.join("server", "db", "pg", "schema")];
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
dialect: "postgresql",
|
|
||||||
schema: schema,
|
|
||||||
out: path.join("server", "migrations"),
|
|
||||||
verbose: true,
|
|
||||||
dbCredentials: {
|
|
||||||
url: process.env.DATABASE_URL as string
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -281,7 +281,7 @@ esbuild
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
sourcemap: "inline",
|
sourcemap: "inline",
|
||||||
target: "node22"
|
target: "node24"
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
// Check if there were any errors in the build result
|
// Check if there were any errors in the build result
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Увеличаване на броя на сайтовете",
|
"sitestCountIncrease": "Увеличаване на броя на сайтовете",
|
||||||
"idpManage": "Управление на доставчици на идентичност",
|
"idpManage": "Управление на доставчици на идентичност",
|
||||||
"idpManageDescription": "Прегледайте и управлявайте доставчици на идентичност в системата",
|
"idpManageDescription": "Прегледайте и управлявайте доставчици на идентичност в системата",
|
||||||
|
"idpGlobalModeBanner": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в <adminPanelLink>администраторския панел</adminPanelLink>. За да активирате IdPs за всяка организация, редактирайте конфигурацията на сървъра и задайте режима на IdP към org. <configDocsLink>Вижте документацията</configDocsLink>. Ако желаете да продължите да използвате глобалните IdPs и да премахнете това от настройките на организацията, изрично задайте режима на global в конфигурацията.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в <adminPanelLink>администраторския панел</adminPanelLink>. За да използвате доставчици на идентичност за всяка организация, трябва да надстроите до изданието Enterprise.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в <adminPanelLink>администраторския панел</adminPanelLink>. За да използвате доставчици на идентичност за всяка организация, е необходим лиценз за изданието Enterprise.",
|
||||||
"idpDeletedDescription": "Доставчик на идентичност успешно изтрит",
|
"idpDeletedDescription": "Доставчик на идентичност успешно изтрит",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Сигурни ли сте, че искате да изтриете доставчика за идентичност?",
|
"idpQuestionRemove": "Сигурни ли сте, че искате да изтриете доставчика за идентичност?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Край на следващата година",
|
"logRetentionEndOfFollowingYear": "Край на следващата година",
|
||||||
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
|
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
|
||||||
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
|
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
|
||||||
"licenseRequiredToUse": "Необходим е лиценз Enterprise, за да се използва тази функция.",
|
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> се изисква за използване на тази функция.",
|
"ossEnterpriseEditionRequired": "Необходимо е <enterpriseEditionLink>изданието Enterprise</enterpriseEditionLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Решавач на сертификати",
|
"certResolver": "Решавач на сертификати",
|
||||||
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
|
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
|
||||||
"selectCertResolver": "Изберете решавач на сертификати",
|
"selectCertResolver": "Изберете решавач на сертификати",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Zvýšit počet stránek",
|
"sitestCountIncrease": "Zvýšit počet stránek",
|
||||||
"idpManage": "Spravovat poskytovatele identity",
|
"idpManage": "Spravovat poskytovatele identity",
|
||||||
"idpManageDescription": "Zobrazit a spravovat poskytovatele identity v systému",
|
"idpManageDescription": "Zobrazit a spravovat poskytovatele identity v systému",
|
||||||
|
"idpGlobalModeBanner": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v <adminPanelLink>admin panelu</adminPanelLink>. Chcete-li povolit IdP pro každou organizaci, upravte konfiguraci serveru a nastavte IdP režim na org. <configDocsLink>Viz dokumentace</configDocsLink>. Pokud chcete pokračovat v používání globálních IdP a zmizet z nastavení organizace, explicitně nastavte režim na globální v konfiguraci.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Spravujte globální IdP v <adminPanelLink>admin panelu</adminPanelLink>. Chcete-li použít poskytovatele identity pro každou organizaci, musíte přejít na Enterprise vydání.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v <adminPanelLink>admin panelu</adminPanelLink>. Chcete-li použít poskytovatele identity pro každou organizaci, je vyžadována Enterprise licence.",
|
||||||
"idpDeletedDescription": "Poskytovatel identity byl úspěšně odstraněn",
|
"idpDeletedDescription": "Poskytovatel identity byl úspěšně odstraněn",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Jste si jisti, že chcete trvale odstranit poskytovatele identity?",
|
"idpQuestionRemove": "Jste si jisti, že chcete trvale odstranit poskytovatele identity?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
|
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
|
||||||
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
|
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
|
||||||
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
|
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
|
||||||
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence pro podnikání.",
|
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Tato funkce je také dostupná v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Oddělovač certifikátů",
|
"certResolver": "Oddělovač certifikátů",
|
||||||
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
|
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
|
||||||
"selectCertResolver": "Vyberte řešič certifikátů",
|
"selectCertResolver": "Vyberte řešič certifikátů",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Anzahl der Standorte erhöhen",
|
"sitestCountIncrease": "Anzahl der Standorte erhöhen",
|
||||||
"idpManage": "Identitätsanbieter verwalten",
|
"idpManage": "Identitätsanbieter verwalten",
|
||||||
"idpManageDescription": "Identitätsanbieter im System anzeigen und verwalten",
|
"idpManageDescription": "Identitätsanbieter im System anzeigen und verwalten",
|
||||||
|
"idpGlobalModeBanner": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt über alle Organisationen). Verwalten Sie globale IdPs im <adminPanelLink>Admin-Panel</adminPanelLink>. Um IdPs pro Organisation zu aktivieren, bearbeiten Sie die Server-Konfiguration und setzen Sie den IdP-Modus auf org. <configDocsLink>Siehe Dokumentation</configDocsLink>. Wenn Sie weiterhin globale IdPs verwenden und diese in den Organisationseinstellungen verschwinden lassen wollen, setzen Sie den Modus explizit auf global in der Konfiguration.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im <adminPanelLink>Admin-Panel</adminPanelLink>verwalten. Um Identitätsanbieter pro Organisation nutzen zu können, müssen Sie zur Enterprise Edition upgraden.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im <adminPanelLink>Admin-Panel</adminPanelLink>verwalten. Um Identitätsanbieter pro Organisation zu verwenden, ist eine Enterprise-Lizenz erforderlich.",
|
||||||
"idpDeletedDescription": "Identitätsanbieter erfolgreich gelöscht",
|
"idpDeletedDescription": "Identitätsanbieter erfolgreich gelöscht",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Sind Sie sicher, dass Sie den Identitätsanbieter dauerhaft löschen möchten?",
|
"idpQuestionRemove": "Sind Sie sicher, dass Sie den Identitätsanbieter dauerhaft löschen möchten?",
|
||||||
@@ -1151,7 +1154,7 @@
|
|||||||
"actionDeleteClient": "Client löschen",
|
"actionDeleteClient": "Client löschen",
|
||||||
"actionArchiveClient": "Client archivieren",
|
"actionArchiveClient": "Client archivieren",
|
||||||
"actionUnarchiveClient": "Client dearchivieren",
|
"actionUnarchiveClient": "Client dearchivieren",
|
||||||
"actionBlockClient": "Klient sperren",
|
"actionBlockClient": "Client sperren",
|
||||||
"actionUnblockClient": "Client entsperren",
|
"actionUnblockClient": "Client entsperren",
|
||||||
"actionUpdateClient": "Client aktualisieren",
|
"actionUpdateClient": "Client aktualisieren",
|
||||||
"actionListClients": "Clients auflisten",
|
"actionListClients": "Clients auflisten",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
|
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
|
||||||
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
|
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
|
||||||
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
|
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
|
||||||
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine Enterprise-Lizenz erforderlich.",
|
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
|
||||||
"ossEnterpriseEditionRequired": "Die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> wird benötigt, um diese Funktion nutzen zu können.",
|
"ossEnterpriseEditionRequired": "Um diese Funktion nutzen zu können, ist die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
|
||||||
"certResolver": "Zertifikatsauflöser",
|
"certResolver": "Zertifikatsauflöser",
|
||||||
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
|
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
|
||||||
"selectCertResolver": "Zertifikatsauflöser auswählen",
|
"selectCertResolver": "Zertifikatsauflöser auswählen",
|
||||||
@@ -2529,10 +2532,10 @@
|
|||||||
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
|
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
|
||||||
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
|
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
|
||||||
"archiveClientConfirm": "Client archivieren",
|
"archiveClientConfirm": "Client archivieren",
|
||||||
"blockClient": "Klient sperren",
|
"blockClient": "Client sperren",
|
||||||
"blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?",
|
"blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?",
|
||||||
"blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.",
|
"blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.",
|
||||||
"blockClientConfirm": "Klient sperren",
|
"blockClientConfirm": "Client sperren",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
"usernameOrEmail": "Benutzername oder E-Mail",
|
"usernameOrEmail": "Benutzername oder E-Mail",
|
||||||
"selectYourOrganization": "Wählen Sie Ihre Organisation",
|
"selectYourOrganization": "Wählen Sie Ihre Organisation",
|
||||||
|
|||||||
@@ -201,6 +201,7 @@
|
|||||||
"protocolSelect": "Select a protocol",
|
"protocolSelect": "Select a protocol",
|
||||||
"resourcePortNumber": "Port Number",
|
"resourcePortNumber": "Port Number",
|
||||||
"resourcePortNumberDescription": "The external port number to proxy requests.",
|
"resourcePortNumberDescription": "The external port number to proxy requests.",
|
||||||
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"resourceConfig": "Configuration Snippets",
|
"resourceConfig": "Configuration Snippets",
|
||||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
||||||
@@ -246,6 +247,17 @@
|
|||||||
"orgErrorDeleteMessage": "An error occurred while deleting the organization.",
|
"orgErrorDeleteMessage": "An error occurred while deleting the organization.",
|
||||||
"orgDeleted": "Organization deleted",
|
"orgDeleted": "Organization deleted",
|
||||||
"orgDeletedMessage": "The organization and its data has been deleted.",
|
"orgDeletedMessage": "The organization and its data has been deleted.",
|
||||||
|
"deleteAccount": "Delete Account",
|
||||||
|
"deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.",
|
||||||
|
"deleteAccountButton": "Delete Account",
|
||||||
|
"deleteAccountConfirmTitle": "Delete Account",
|
||||||
|
"deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.",
|
||||||
|
"deleteAccountConfirmString": "delete account",
|
||||||
|
"deleteAccountSuccess": "Account Deleted",
|
||||||
|
"deleteAccountSuccessMessage": "Your account has been deleted.",
|
||||||
|
"deleteAccountError": "Failed to delete account",
|
||||||
|
"deleteAccountPreviewAccount": "Your Account",
|
||||||
|
"deleteAccountPreviewOrgs": "Organizations you own (and all their data)",
|
||||||
"orgMissing": "Organization ID Missing",
|
"orgMissing": "Organization ID Missing",
|
||||||
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
|
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
|
||||||
"accessUsersManage": "Manage Users",
|
"accessUsersManage": "Manage Users",
|
||||||
@@ -461,6 +473,8 @@
|
|||||||
"filterByApprovalState": "Filter By Approval State",
|
"filterByApprovalState": "Filter By Approval State",
|
||||||
"approvalListEmpty": "No approvals",
|
"approvalListEmpty": "No approvals",
|
||||||
"approvalState": "Approval State",
|
"approvalState": "Approval State",
|
||||||
|
"approvalLoadMore": "Load more",
|
||||||
|
"loadingApprovals": "Loading Approvals",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
"denied": "Denied",
|
"denied": "Denied",
|
||||||
@@ -791,6 +805,9 @@
|
|||||||
"sitestCountIncrease": "Increase site count",
|
"sitestCountIncrease": "Increase site count",
|
||||||
"idpManage": "Manage Identity Providers",
|
"idpManage": "Manage Identity Providers",
|
||||||
"idpManageDescription": "View and manage identity providers in the system",
|
"idpManageDescription": "View and manage identity providers in the system",
|
||||||
|
"idpGlobalModeBanner": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the <adminPanelLink>admin panel</adminPanelLink>. To enable IdPs per organization, edit the server config and set IdP mode to org. <configDocsLink>See the docs</configDocsLink>. If you want to continue using global IdPs and make this disappear from the organization settings, explicitly set the mode to global in the config.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the <adminPanelLink>admin panel</adminPanelLink>. To use identity providers per organization, you must upgrade to the Enterprise edition.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the <adminPanelLink>admin panel</adminPanelLink>. To use identity providers per organization, an Enterprise license is required.",
|
||||||
"idpDeletedDescription": "Identity provider deleted successfully",
|
"idpDeletedDescription": "Identity provider deleted successfully",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Are you sure you want to permanently delete the identity provider?",
|
"idpQuestionRemove": "Are you sure you want to permanently delete the identity provider?",
|
||||||
@@ -1014,6 +1031,7 @@
|
|||||||
"pangolinSetup": "Setup - Pangolin",
|
"pangolinSetup": "Setup - Pangolin",
|
||||||
"orgNameRequired": "Organization name is required",
|
"orgNameRequired": "Organization name is required",
|
||||||
"orgIdRequired": "Organization ID is required",
|
"orgIdRequired": "Organization ID is required",
|
||||||
|
"orgIdMaxLength": "Organization ID must be at most 32 characters",
|
||||||
"orgErrorCreate": "An error occurred while creating org",
|
"orgErrorCreate": "An error occurred while creating org",
|
||||||
"pageNotFound": "Page Not Found",
|
"pageNotFound": "Page Not Found",
|
||||||
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
|
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
|
||||||
@@ -1166,7 +1184,8 @@
|
|||||||
"actionViewLogs": "View Logs",
|
"actionViewLogs": "View Logs",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"searchProgress": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
|
"emptySearchOptions": "No options found",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"orgs": "Organizations",
|
"orgs": "Organizations",
|
||||||
"loginError": "An unexpected error occurred. Please try again.",
|
"loginError": "An unexpected error occurred. Please try again.",
|
||||||
@@ -1248,6 +1267,7 @@
|
|||||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||||
"sidebarBluePrints": "Blueprints",
|
"sidebarBluePrints": "Blueprints",
|
||||||
"sidebarOrganization": "Organization",
|
"sidebarOrganization": "Organization",
|
||||||
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||||
@@ -1409,6 +1429,7 @@
|
|||||||
"billingSites": "Sites",
|
"billingSites": "Sites",
|
||||||
"billingUsers": "Users",
|
"billingUsers": "Users",
|
||||||
"billingDomains": "Domains",
|
"billingDomains": "Domains",
|
||||||
|
"billingOrganizations": "Orgs",
|
||||||
"billingRemoteExitNodes": "Remote Nodes",
|
"billingRemoteExitNodes": "Remote Nodes",
|
||||||
"billingNoLimitConfigured": "No limit configured",
|
"billingNoLimitConfigured": "No limit configured",
|
||||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||||
@@ -1451,6 +1472,7 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"createNewOrgDescription": "Create a new organization",
|
"createNewOrgDescription": "Create a new organization",
|
||||||
"organization": "Organization",
|
"organization": "Organization",
|
||||||
|
"primary": "Primary",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"securityKeyManage": "Manage Security Keys",
|
"securityKeyManage": "Manage Security Keys",
|
||||||
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
|
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
|
||||||
@@ -1913,6 +1935,9 @@
|
|||||||
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
||||||
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
||||||
"brandingLogoURL": "Logo URL",
|
"brandingLogoURL": "Logo URL",
|
||||||
|
"brandingLogoURLOrPath": "Logo URL or Path",
|
||||||
|
"brandingLogoPathDescription": "Enter a URL or a local path.",
|
||||||
|
"brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.",
|
||||||
"brandingPrimaryColor": "Primary Color",
|
"brandingPrimaryColor": "Primary Color",
|
||||||
"brandingLogoWidth": "Width (px)",
|
"brandingLogoWidth": "Width (px)",
|
||||||
"brandingLogoHeight": "Height (px)",
|
"brandingLogoHeight": "Height (px)",
|
||||||
@@ -2057,7 +2082,7 @@
|
|||||||
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
|
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
|
||||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
"machineClientsBannerOlmCLI": "Olm CLI",
|
"machineClientsBannerOlmCLI": "Olm CLI",
|
||||||
"machineClientsBannerOlmContainer": "Olm Container",
|
"machineClientsBannerOlmContainer": "Container",
|
||||||
"clientsTableUserClients": "User",
|
"clientsTableUserClients": "User",
|
||||||
"clientsTableMachineClients": "Machine",
|
"clientsTableMachineClients": "Machine",
|
||||||
"licenseTableValidUntil": "Valid Until",
|
"licenseTableValidUntil": "Valid Until",
|
||||||
@@ -2280,8 +2305,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "End of following year",
|
"logRetentionEndOfFollowingYear": "End of following year",
|
||||||
"actionLogsDescription": "View a history of actions performed in this organization",
|
"actionLogsDescription": "View a history of actions performed in this organization",
|
||||||
"accessLogsDescription": "View access auth requests for resources in this organization",
|
"accessLogsDescription": "View access auth requests for resources in this organization",
|
||||||
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Aumentar el número de sitios",
|
"sitestCountIncrease": "Aumentar el número de sitios",
|
||||||
"idpManage": "Administrar proveedores de identidad",
|
"idpManage": "Administrar proveedores de identidad",
|
||||||
"idpManageDescription": "Ver y administrar proveedores de identidad en el sistema",
|
"idpManageDescription": "Ver y administrar proveedores de identidad en el sistema",
|
||||||
|
"idpGlobalModeBanner": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el <adminPanelLink>panel de administración</adminPanelLink>. Para habilitar los IdPs por organización, edita la configuración del servidor y establece el modo de IdP en org. <configDocsLink>Consulta la documentación</configDocsLink>. Si deseas seguir utilizando IdPs globales y hacer que esto desaparezca de las configuraciones de la organización, establece explícitamente el modo en global en la configuración.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el <adminPanelLink>panel de administración</adminPanelLink>. Para usar proveedores de identidad por organización, debes actualizar a la edición Empresarial.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando identificadores globales (compartidos en todas las organizaciones). Gestionar identificaciones globales en el panel <adminPanelLink>de administración</adminPanelLink>. Para utilizar proveedores de identidad por organización, se requiere una licencia de empresa.",
|
||||||
"idpDeletedDescription": "Proveedor de identidad eliminado correctamente",
|
"idpDeletedDescription": "Proveedor de identidad eliminado correctamente",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "¿Está seguro que desea eliminar permanentemente el proveedor de identidad?",
|
"idpQuestionRemove": "¿Está seguro que desea eliminar permanentemente el proveedor de identidad?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
||||||
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
||||||
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
||||||
"licenseRequiredToUse": "Se requiere una licencia Enterprise para utilizar esta función.",
|
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> para utilizar esta función. Esta característica también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> es necesaria para utilizar esta función.",
|
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>versión Enterprise</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Resolver certificado",
|
"certResolver": "Resolver certificado",
|
||||||
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
||||||
"selectCertResolver": "Seleccionar Resolver Certificado",
|
"selectCertResolver": "Seleccionar Resolver Certificado",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Augmenter le nombre de sites",
|
"sitestCountIncrease": "Augmenter le nombre de sites",
|
||||||
"idpManage": "Gérer les fournisseurs d'identité",
|
"idpManage": "Gérer les fournisseurs d'identité",
|
||||||
"idpManageDescription": "Voir et gérer les fournisseurs d'identité dans le système",
|
"idpManageDescription": "Voir et gérer les fournisseurs d'identité dans le système",
|
||||||
|
"idpGlobalModeBanner": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérez les IdPs globaux dans le panneau d'administration <adminPanelLink></adminPanelLink>. Pour activer les IdPs par organisation, éditez la configuration du serveur et réglez le mode IdP sur org. <configDocsLink>Voir la documentation</configDocsLink>. Si vous voulez continuer à utiliser les IdPs globaux et faire disparaître cela des paramètres de l'organisation, définissez explicitement le mode à global dans la configuration.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration <adminPanelLink></adminPanelLink>. Pour utiliser les fournisseurs d'identité par organisation, vous devez passer à l'édition Entreprise.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration <adminPanelLink></adminPanelLink>. Pour utiliser les fournisseurs d'identité par organisation, une licence d'entreprise est requise.",
|
||||||
"idpDeletedDescription": "Fournisseur d'identité supprimé avec succès",
|
"idpDeletedDescription": "Fournisseur d'identité supprimé avec succès",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Êtes-vous sûr de vouloir supprimer définitivement le fournisseur d'identité?",
|
"idpQuestionRemove": "Êtes-vous sûr de vouloir supprimer définitivement le fournisseur d'identité?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
|
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
|
||||||
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
|
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
|
||||||
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
|
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
|
||||||
"licenseRequiredToUse": "Une licence Entreprise est nécessaire pour utiliser cette fonctionnalité.",
|
"licenseRequiredToUse": "Une licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> est nécessaire pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité.",
|
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Résolveur de certificat",
|
"certResolver": "Résolveur de certificat",
|
||||||
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
|
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
|
||||||
"selectCertResolver": "Sélectionnez le résolveur de certificat",
|
"selectCertResolver": "Sélectionnez le résolveur de certificat",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Aumenta conteggio siti",
|
"sitestCountIncrease": "Aumenta conteggio siti",
|
||||||
"idpManage": "Gestisci Provider di Identità",
|
"idpManage": "Gestisci Provider di Identità",
|
||||||
"idpManageDescription": "Visualizza e gestisci i provider di identità nel sistema",
|
"idpManageDescription": "Visualizza e gestisci i provider di identità nel sistema",
|
||||||
|
"idpGlobalModeBanner": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Sta utilizzando IdP globali (condivisi in tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione <adminPanelLink></adminPanelLink>. Per abilitare IdP per organizzazione, modificare la configurazione del server e impostare la modalità IdP su org. <configDocsLink>Vedere i documenti</configDocsLink>. Se si desidera continuare a utilizzare IdP globali e far sparire questo dalle impostazioni dell'organizzazione, impostare esplicitamente la modalità globale nella configurazione.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci gli IdP globali nel pannello di amministrazione <adminPanelLink></adminPanelLink>. Per utilizzare i provider di identità per organizzazione, è necessario aggiornare all'edizione Enterprise.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione <adminPanelLink></adminPanelLink>. Per utilizzare provider di identità per organizzazione, è richiesta una licenza Enterprise.",
|
||||||
"idpDeletedDescription": "Provider di identità eliminato con successo",
|
"idpDeletedDescription": "Provider di identità eliminato con successo",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Sei sicuro di voler eliminare definitivamente il provider di identità?",
|
"idpQuestionRemove": "Sei sicuro di voler eliminare definitivamente il provider di identità?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
|
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
|
||||||
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
|
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
|
||||||
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
|
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
|
||||||
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise.",
|
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione.",
|
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Risolutore Di Certificato",
|
"certResolver": "Risolutore Di Certificato",
|
||||||
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
|
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
|
||||||
"selectCertResolver": "Seleziona Risolutore Di Certificato",
|
"selectCertResolver": "Seleziona Risolutore Di Certificato",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "사이트 수 증가",
|
"sitestCountIncrease": "사이트 수 증가",
|
||||||
"idpManage": "아이덴티티 공급자 관리",
|
"idpManage": "아이덴티티 공급자 관리",
|
||||||
"idpManageDescription": "시스템에서 ID 제공자를 보고 관리합니다",
|
"idpManageDescription": "시스템에서 ID 제공자를 보고 관리합니다",
|
||||||
|
"idpGlobalModeBanner": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. <adminPanelLink>관리자 패널</adminPanelLink>에서 글로벌 IdP를 관리하십시오. 조직별 IdP를 활성화하려면 서버 설정을 편집하고 IdP 모드를 조직으로 설정하십시오. <configDocsLink>문서 보기</configDocsLink>. 글로벌 IdP 사용을 계속하고 조직 설정에서 이 항목을 제거하려면 설정에서 모드를 글로벌로 명시적으로 설정하십시오.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. <adminPanelLink>관리자 패널</adminPanelLink>에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 Enterprise 에디션으로 업그레이드해야 합니다.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. <adminPanelLink>관리자 패널</adminPanelLink>에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 엔터프라이즈 라이선스가 필요합니다.",
|
||||||
"idpDeletedDescription": "신원 공급자가 성공적으로 삭제되었습니다",
|
"idpDeletedDescription": "신원 공급자가 성공적으로 삭제되었습니다",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "아이덴티티 공급자를 영구적으로 삭제하시겠습니까?",
|
"idpQuestionRemove": "아이덴티티 공급자를 영구적으로 삭제하시겠습니까?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "다음 연도 말",
|
"logRetentionEndOfFollowingYear": "다음 연도 말",
|
||||||
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
|
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
|
||||||
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
|
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
|
||||||
"licenseRequiredToUse": "이 기능을 사용하려면 Enterprise 라이선스가 필요합니다.",
|
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
|
||||||
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>이 필요합니다.",
|
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
|
||||||
"certResolver": "인증서 해결사",
|
"certResolver": "인증서 해결사",
|
||||||
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
|
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
|
||||||
"selectCertResolver": "인증서 해결사 선택",
|
"selectCertResolver": "인증서 해결사 선택",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Øk antall områder",
|
"sitestCountIncrease": "Øk antall områder",
|
||||||
"idpManage": "Administrer Identitetsleverandører",
|
"idpManage": "Administrer Identitetsleverandører",
|
||||||
"idpManageDescription": "Vis og administrer identitetsleverandører i systemet",
|
"idpManageDescription": "Vis og administrer identitetsleverandører i systemet",
|
||||||
|
"idpGlobalModeBanner": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdP (delt over alle organisasjoner). Administrer globale IdP'er i <adminPanelLink>admin-panelet</adminPanelLink>. For å aktivere IdP per organisasjon, rediger serverkonfigurasjonen og sett IdP-modus til org. <configDocsLink>Se dokumentasjonen</configDocsLink>. Hvis du vil fortsette å bruke globale IdPs og få denne til å forsvinne fra organisasjonens innstillinger, satt eksplisitt modusen til global i konfigurasjonen.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i <adminPanelLink>administrasjons-panelet</adminPanelLink>. For å bruke identitetsleverandører per organisasjon, må du oppgradere til Enterprise-utgaven.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i <adminPanelLink>administrasjons-panelet</adminPanelLink>. For å bruke identitetsleverandører per organisasjon, kreves en Enterprise-lisens.",
|
||||||
"idpDeletedDescription": "Identitetsleverandør slettet vellykket",
|
"idpDeletedDescription": "Identitetsleverandør slettet vellykket",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Er du sikker på at du vil slette identitetsleverandøren permanent?",
|
"idpQuestionRemove": "Er du sikker på at du vil slette identitetsleverandøren permanent?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Slutt på neste år",
|
"logRetentionEndOfFollowingYear": "Slutt på neste år",
|
||||||
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
|
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
|
||||||
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
|
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
|
||||||
"licenseRequiredToUse": "En Enterprise lisens er påkrevd for å bruke denne funksjonen.",
|
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens er påkrevd for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Sertifikat løser",
|
"certResolver": "Sertifikat løser",
|
||||||
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
|
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
|
||||||
"selectCertResolver": "Velg sertifikatløser",
|
"selectCertResolver": "Velg sertifikatløser",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Toename van site vergroten",
|
"sitestCountIncrease": "Toename van site vergroten",
|
||||||
"idpManage": "Identiteitsaanbieders beheren",
|
"idpManage": "Identiteitsaanbieders beheren",
|
||||||
"idpManageDescription": "Identiteitsaanbieders in het systeem bekijken en beheren",
|
"idpManageDescription": "Identiteitsaanbieders in het systeem bekijken en beheren",
|
||||||
|
"idpGlobalModeBanner": "Identiteitsaanbieders (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld tussen alle organisaties). Beheer globale IdPs in het <adminPanelLink>beheerderspaneel</adminPanelLink>. Om IdPs per organisatie in te schakelen, bewerk de server configuratie en zet IdP modus op org. <configDocsLink>Zie de documenten</configDocsLink>. Als je globale IdPs wilt blijven gebruiken en dit uit de organisatie-instellingen wilt laten verdwijnen, zet dan expliciet de modus naar globaal in de config.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het <adminPanelLink>beheerderspaneel</adminPanelLink>. Om identiteitsproviders per organisatie te gebruiken, moet u upgraden naar de Enterprise editie.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het <adminPanelLink>beheerderspaneel</adminPanelLink>. Om identiteitsaanbieders per organisatie te gebruiken, is een Enterprise-licentie vereist.",
|
||||||
"idpDeletedDescription": "Identity provider succesvol verwijderd",
|
"idpDeletedDescription": "Identity provider succesvol verwijderd",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Weet u zeker dat u de identiteitsprovider permanent wilt verwijderen?",
|
"idpQuestionRemove": "Weet u zeker dat u de identiteitsprovider permanent wilt verwijderen?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
|
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
|
||||||
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
|
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
|
||||||
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
|
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
|
||||||
"licenseRequiredToUse": "Een Enterprise-licentie is vereist om deze functie te gebruiken.",
|
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken.",
|
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Certificaat Resolver",
|
"certResolver": "Certificaat Resolver",
|
||||||
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
|
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
|
||||||
"selectCertResolver": "Certificaat Resolver selecteren",
|
"selectCertResolver": "Certificaat Resolver selecteren",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Zwiększ liczbę witryn",
|
"sitestCountIncrease": "Zwiększ liczbę witryn",
|
||||||
"idpManage": "Zarządzaj dostawcami tożsamości",
|
"idpManage": "Zarządzaj dostawcami tożsamości",
|
||||||
"idpManageDescription": "Wyświetl i zarządzaj dostawcami tożsamości w systemie",
|
"idpManageDescription": "Wyświetl i zarządzaj dostawcami tożsamości w systemie",
|
||||||
|
"idpGlobalModeBanner": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używa globalnych IdP (współdzielonych ze wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym <adminPanelLink></adminPanelLink>. Aby włączyć IdP na organizację, edytuj konfigurację serwera i ustaw tryb IdP na org. <configDocsLink>Zobacz dokumentację</configDocsLink>. Jeśli chcesz nadal używać globalnych IdP i sprawić, że zniknie to z ustawień organizacji, wyraźnie ustaw tryb globalny w konfiguracji.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym <adminPanelLink></adminPanelLink>. Aby korzystać z dostawców tożsamości na organizację, musisz zaktualizować do edycji Enterprise.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym <adminPanelLink></adminPanelLink>. Aby korzystać z dostawców tożsamości na organizację, wymagana jest licencja Enterprise.",
|
||||||
"idpDeletedDescription": "Dostawca tożsamości został pomyślnie usunięty",
|
"idpDeletedDescription": "Dostawca tożsamości został pomyślnie usunięty",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Czy na pewno chcesz trwale usunąć dostawcę tożsamości?",
|
"idpQuestionRemove": "Czy na pewno chcesz trwale usunąć dostawcę tożsamości?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
|
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
|
||||||
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
|
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
|
||||||
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
|
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
|
||||||
"licenseRequiredToUse": "Licencja Enterprise jest wymagana do korzystania z tej funkcji.",
|
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji.",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Rozwiązywanie certyfikatów",
|
"certResolver": "Rozwiązywanie certyfikatów",
|
||||||
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
|
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
|
||||||
"selectCertResolver": "Wybierz Resolver certyfikatów",
|
"selectCertResolver": "Wybierz Resolver certyfikatów",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Aumentar contagem de sites",
|
"sitestCountIncrease": "Aumentar contagem de sites",
|
||||||
"idpManage": "Gerir Provedores de Identidade",
|
"idpManage": "Gerir Provedores de Identidade",
|
||||||
"idpManageDescription": "Visualizar e gerir provedores de identidade no sistema",
|
"idpManageDescription": "Visualizar e gerir provedores de identidade no sistema",
|
||||||
|
"idpGlobalModeBanner": "Provedores de identidade (Pds) por organização estão desabilitados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie IdPs no painel <adminPanelLink>admin</adminPanelLink>. Para habilitar IdPs por organização, edite a configuração do servidor e defina o modo IdP como org. <configDocsLink>Veja a documentação</configDocsLink>. Se quiser continuar usando IdPs globais e fazer isso desaparecer das configurações da organização, defina explicitamente o modo como global na configuração.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no <adminPanelLink>painel administrativo</adminPanelLink>. Para usar provedores de identidade por organização, você deve atualizar para a edição Enterprise.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no <adminPanelLink>painel administrativo</adminPanelLink>. Para usar provedores de identidade por organização, é necessário uma licença Enterprise.",
|
||||||
"idpDeletedDescription": "Provedor de identidade eliminado com sucesso",
|
"idpDeletedDescription": "Provedor de identidade eliminado com sucesso",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Tem certeza que deseja eliminar permanentemente o provedor de identidade?",
|
"idpQuestionRemove": "Tem certeza que deseja eliminar permanentemente o provedor de identidade?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
|
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
|
||||||
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
|
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
|
||||||
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
|
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
|
||||||
"licenseRequiredToUse": "É necessária uma licença empresarial para usar esse recurso.",
|
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> é necessária para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso.",
|
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Resolvedor de Certificado",
|
"certResolver": "Resolvedor de Certificado",
|
||||||
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
|
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
|
||||||
"selectCertResolver": "Selecionar solucionador de certificado",
|
"selectCertResolver": "Selecionar solucionador de certificado",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Увеличить количество сайтов",
|
"sitestCountIncrease": "Увеличить количество сайтов",
|
||||||
"idpManage": "Управление поставщиками удостоверений",
|
"idpManage": "Управление поставщиками удостоверений",
|
||||||
"idpManageDescription": "Просмотр и управление поставщиками удостоверений в системе",
|
"idpManageDescription": "Просмотр и управление поставщиками удостоверений в системе",
|
||||||
|
"idpGlobalModeBanner": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в <adminPanelLink>админ-панели</adminPanelLink>. Чтобы включить IdP для каждой организации, отредактируйте конфигурацию сервера и установите режим IdP в org. <configDocsLink>См. документацию</configDocsLink>. Если вы хотите продолжать использовать глобальные IdP и скрыть это из настроек организации, явно установите режим в глобальном конфиге.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в <adminPanelLink>админ-панели</adminPanelLink>. Чтобы использовать поставщиков удостоверений для каждой организации, необходимо обновить систему до версии Enterprise.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в <adminPanelLink>админ-панели</adminPanelLink>. Для использования поставщиков удостоверений на организацию требуется лицензия Enterprise.",
|
||||||
"idpDeletedDescription": "Поставщик удостоверений успешно удалён",
|
"idpDeletedDescription": "Поставщик удостоверений успешно удалён",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Вы уверены, что хотите навсегда удалить поставщика удостоверений?",
|
"idpQuestionRemove": "Вы уверены, что хотите навсегда удалить поставщика удостоверений?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Конец следующего года",
|
"logRetentionEndOfFollowingYear": "Конец следующего года",
|
||||||
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
|
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
|
||||||
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
|
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
|
||||||
"licenseRequiredToUse": "Для использования этой функции требуется лицензия предприятия.",
|
"licenseRequiredToUse": "Лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Для использования этой функции требуется корпоративная версия <enterpriseEditionLink></enterpriseEditionLink>.",
|
"ossEnterpriseEditionRequired": "Для использования этой функции требуется <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||||
"certResolver": "Резольвер сертификата",
|
"certResolver": "Резольвер сертификата",
|
||||||
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
|
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
|
||||||
"selectCertResolver": "Выберите резолвер сертификата",
|
"selectCertResolver": "Выберите резолвер сертификата",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "Site sayısını artır",
|
"sitestCountIncrease": "Site sayısını artır",
|
||||||
"idpManage": "Kimlik Sağlayıcılarını Yönet",
|
"idpManage": "Kimlik Sağlayıcılarını Yönet",
|
||||||
"idpManageDescription": "Sistem içindeki kimlik sağlayıcıları görün ve yönetin",
|
"idpManageDescription": "Sistem içindeki kimlik sağlayıcıları görün ve yönetin",
|
||||||
|
"idpGlobalModeBanner": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri <adminPanelLink> yönetici panelinde </adminPanelLink>yönetin. Örgüt başına IdP'leri etkinleştirmek için, sunucu yapılandırmasını düzenleyin ve IdP modunu 'org' olarak ayarlayın. <configDocsLink> Belgeleri inceleyin </configDocsLink>. Küresel IdP'leri kullanmaya devam etmek istiyorsanız ve bunun örgüt ayarlarından kaybolmasını istiyorsanız, yapılandırmada modu otomatik olarak 'global' olarak ayarlayın.",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri <adminPanelLink> yönetici panelinde </adminPanelLink>yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için, Enterprise sürümüne yükseltmeniz gerekmektedir.",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri <adminPanelLink> yönetici panelinde </adminPanelLink>yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için Enterprise lisansı gereklidir.",
|
||||||
"idpDeletedDescription": "Kimlik sağlayıcı başarıyla silindi",
|
"idpDeletedDescription": "Kimlik sağlayıcı başarıyla silindi",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "Kimlik sağlayıcısını kalıcı olarak silmek istediğinizden emin misiniz?",
|
"idpQuestionRemove": "Kimlik sağlayıcısını kalıcı olarak silmek istediğinizden emin misiniz?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
|
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
|
||||||
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
|
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
|
||||||
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
|
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
|
||||||
"licenseRequiredToUse": "Bu özelliği kullanmak için bir kurumsal lisans gereklidir.",
|
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
|
||||||
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Kurumsal Sürüm</enterpriseEditionLink> gereklidir.",
|
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
|
||||||
"certResolver": "Sertifika Çözücü",
|
"certResolver": "Sertifika Çözücü",
|
||||||
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
|
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
|
||||||
"selectCertResolver": "Sertifika Çözücü Seçin",
|
"selectCertResolver": "Sertifika Çözücü Seçin",
|
||||||
|
|||||||
@@ -791,6 +791,9 @@
|
|||||||
"sitestCountIncrease": "增加站点数量",
|
"sitestCountIncrease": "增加站点数量",
|
||||||
"idpManage": "管理身份提供商",
|
"idpManage": "管理身份提供商",
|
||||||
"idpManageDescription": "查看和管理系统中的身份提供商",
|
"idpManageDescription": "查看和管理系统中的身份提供商",
|
||||||
|
"idpGlobalModeBanner": "此服务器上禁用了每个组织的身份提供商(Idps)。 它正在使用全局IdP(所有组织共享)。在 <adminPanelLink>管理面板</adminPanelLink>中管理全局IdP。 要启用每个组织的 IdP,请编辑服务器配置并将 IdP 模式设置为 org。 <configDocsLink>请参阅文档</configDocsLink>。 如果您想要继续使用全局IdP并使其从组织设置中消失,请在配置中将模式设置为全局模式。",
|
||||||
|
"idpGlobalModeBannerUpgradeRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 <adminPanelLink>管理面板</adminPanelLink>管理全局身份。要使用每个组织的身份提供者,您必须升级到企业版本。",
|
||||||
|
"idpGlobalModeBannerLicenseRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 <adminPanelLink>管理面板</adminPanelLink>管理全局身份。要使用每个组织的身份提供者,需要企业许可证。",
|
||||||
"idpDeletedDescription": "身份提供商删除成功",
|
"idpDeletedDescription": "身份提供商删除成功",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "您确定要永久删除身份提供者吗?",
|
"idpQuestionRemove": "您确定要永久删除身份提供者吗?",
|
||||||
@@ -2280,8 +2283,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "下一年结束",
|
"logRetentionEndOfFollowingYear": "下一年结束",
|
||||||
"actionLogsDescription": "查看此机构执行的操作历史",
|
"actionLogsDescription": "查看此机构执行的操作历史",
|
||||||
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
||||||
"licenseRequiredToUse": "需要企业许可证才能使用此功能。",
|
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
||||||
"ossEnterpriseEditionRequired": "需要 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 才能使用此功能。",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
||||||
"certResolver": "证书解决器",
|
"certResolver": "证书解决器",
|
||||||
"certResolverDescription": "选择用于此资源的证书解析器。",
|
"certResolverDescription": "选择用于此资源的证书解析器。",
|
||||||
"selectCertResolver": "选择证书解析",
|
"selectCertResolver": "选择证书解析",
|
||||||
|
|||||||
4127
package-lock.json
generated
4127
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
90
package.json
90
package.json
@@ -13,13 +13,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||||
"dev:check": "npx tsc --noEmit && npm run format:check",
|
"dev: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",
|
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
|
||||||
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
|
||||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
"db:push": "npx tsx server/db/migrate.ts",
|
||||||
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
|
||||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
|
||||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
|
||||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
|
||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"db:clear-migrations": "rm -rf server/migrations",
|
||||||
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||||
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||||
@@ -36,8 +33,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.0",
|
"@asteasolutions/zod-to-openapi": "8.4.0",
|
||||||
"@aws-sdk/client-s3": "3.971.0",
|
"@aws-sdk/client-s3": "3.989.0",
|
||||||
"@faker-js/faker": "10.2.0",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
@@ -62,67 +59,66 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.2",
|
"@react-email/components": "1.0.7",
|
||||||
"@react-email/render": "2.0.0",
|
"@react-email/render": "2.0.4",
|
||||||
"@react-email/tailwind": "2.0.2",
|
"@react-email/tailwind": "2.0.4",
|
||||||
"@simplewebauthn/browser": "13.2.2",
|
"@simplewebauthn/browser": "13.2.2",
|
||||||
"@simplewebauthn/server": "13.2.2",
|
"@simplewebauthn/server": "13.2.2",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.12",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.2",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.6",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"date-fns": "4.1.0",
|
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"eslint": "9.39.2",
|
|
||||||
"eslint-config-next": "16.1.0",
|
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.2.1",
|
"express-rate-limit": "8.2.1",
|
||||||
"glob": "13.0.0",
|
"glob": "13.0.3",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.9.2",
|
"ioredis": "5.9.3",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.562.0",
|
"lucide-react": "0.563.0",
|
||||||
"maxmind": "5.0.1",
|
"maxmind": "5.0.5",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.9",
|
"next": "15.5.12",
|
||||||
"next-intl": "4.7.0",
|
"next-intl": "4.8.2",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "7.0.11",
|
"nodemailer": "8.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.17.1",
|
"pg": "8.18.0",
|
||||||
"posthog-node": "5.23.0",
|
"posthog-node": "5.24.15",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "9.13.0",
|
"react-day-picker": "9.13.2",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.1",
|
"react-hook-form": "7.71.1",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.0.0",
|
||||||
"resend": "6.8.0",
|
"resend": "6.9.2",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.4",
|
||||||
"stripe": "20.2.0",
|
"sshpk": "^1.18.0",
|
||||||
|
"stripe": "20.3.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
|
"use-debounce": "^10.1.0",
|
||||||
"uuid": "13.0.0",
|
"uuid": "13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
@@ -131,14 +127,15 @@
|
|||||||
"ws": "8.19.0",
|
"ws": "8.19.0",
|
||||||
"yaml": "2.8.2",
|
"yaml": "2.8.2",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.5",
|
"zod": "4.3.6",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.51.2",
|
"@dotenvx/dotenvx": "1.52.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
|
"@react-email/preview-server": "5.2.8",
|
||||||
"@tailwindcss/postcss": "4.1.18",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@tanstack/react-query-devtools": "5.91.1",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
@@ -147,30 +144,33 @@
|
|||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.18.2",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "24.10.2",
|
"@types/node": "25.2.3",
|
||||||
"@types/nodemailer": "7.0.4",
|
"@types/nodemailer": "7.0.9",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.16.0",
|
"@types/pg": "8.16.0",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
|
"@types/sshpk": "^1.17.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/topojson-client": "3.1.5",
|
"@types/topojson-client": "3.1.5",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"@types/js-yaml": "4.0.9",
|
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.8",
|
"drizzle-kit": "0.31.9",
|
||||||
"esbuild": "0.27.2",
|
"esbuild": "0.27.3",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.20.1",
|
||||||
|
"eslint": "9.39.2",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.0",
|
"prettier": "3.8.1",
|
||||||
"react-email": "5.2.5",
|
"react-email": "5.2.8",
|
||||||
"tailwindcss": "4.1.18",
|
"tailwindcss": "4.1.18",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.53.1"
|
"typescript-eslint": "8.55.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ export enum ActionsEnum {
|
|||||||
viewLogs = "viewLogs",
|
viewLogs = "viewLogs",
|
||||||
exportLogs = "exportLogs",
|
exportLogs = "exportLogs",
|
||||||
listApprovals = "listApprovals",
|
listApprovals = "listApprovals",
|
||||||
updateApprovals = "updateApprovals"
|
updateApprovals = "updateApprovals",
|
||||||
|
signSshKey = "signSshKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
45
server/auth/canUserAccessSiteResource.ts
Normal file
45
server/auth/canUserAccessSiteResource.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { db } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { roleSiteResources, userSiteResources } from "@server/db";
|
||||||
|
|
||||||
|
export async function canUserAccessSiteResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleId
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
resourceId: number;
|
||||||
|
roleId: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const roleResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleSiteResources.siteResourceId, resourceId),
|
||||||
|
eq(roleSiteResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(userSiteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, userId),
|
||||||
|
eq(userSiteResources.siteResourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
|
|||||||
You must have a connection string in your config file, as shown above.
|
You must have a connection string in your config file, as shown above.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:pg:generate
|
npm run db:generate
|
||||||
npm run db:pg:push
|
npm run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### SQLite
|
### SQLite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:sqlite:generate
|
npm run db:generate
|
||||||
npm run db:sqlite:push
|
npm run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build Time
|
## Build Time
|
||||||
|
|||||||
3
server/db/migrate.ts
Normal file
3
server/db/migrate.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { runMigrations } from "./";
|
||||||
|
|
||||||
|
await runMigrations();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./driver";
|
export * from "./driver";
|
||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
|
export * from "./migrate";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path";
|
|||||||
|
|
||||||
const migrationsFolder = path.join("server/migrations");
|
const migrationsFolder = path.join("server/migrations");
|
||||||
|
|
||||||
const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
console.log("Running migrations...");
|
console.log("Running migrations...");
|
||||||
try {
|
try {
|
||||||
await migrate(db as any, {
|
await migrate(db as any, {
|
||||||
@@ -17,5 +17,3 @@ const runMigrations = async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runMigrations();
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import {
|
|
||||||
pgTable,
|
|
||||||
serial,
|
|
||||||
varchar,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
bigint,
|
|
||||||
real,
|
|
||||||
text,
|
|
||||||
index,
|
|
||||||
uniqueIndex
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { alias } from "yargs";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
real,
|
||||||
|
serial,
|
||||||
|
text,
|
||||||
|
varchar
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const domains = pgTable("domains", {
|
export const domains = pgTable("domains", {
|
||||||
domainId: varchar("domainId").primaryKey(),
|
domainId: varchar("domainId").primaryKey(),
|
||||||
@@ -55,7 +53,11 @@ export const orgs = pgTable("orgs", {
|
|||||||
.default(0),
|
.default(0),
|
||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0)
|
.default(0),
|
||||||
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
|
isBillingOrg: boolean("isBillingOrg"),
|
||||||
|
billingOrgId: varchar("billingOrgId")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgDomains = pgTable("orgDomains", {
|
export const orgDomains = pgTable("orgDomains", {
|
||||||
@@ -142,7 +144,8 @@ export const resources = pgTable("resources", {
|
|||||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||||
maintenanceTitle: text("maintenanceTitle"),
|
maintenanceTitle: text("maintenanceTitle"),
|
||||||
maintenanceMessage: text("maintenanceMessage"),
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
|
postAuthPath: text("postAuthPath")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
@@ -187,7 +190,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||||
hcMethod: varchar("hcMethod").default("GET"),
|
hcMethod: varchar("hcMethod").default("GET"),
|
||||||
hcStatus: integer("hcStatus"), // http code
|
hcStatus: integer("hcStatus"), // http code
|
||||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
hcTlsServerName: text("hcTlsServerName")
|
hcTlsServerName: text("hcTlsServerName")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +222,7 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: varchar("niceId").notNull(),
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
|
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||||
protocol: varchar("protocol"), // only for port mode
|
protocol: varchar("protocol"), // only for port mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
@@ -327,7 +332,8 @@ export const userOrgs = pgTable("userOrgs", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => roles.roleId),
|
.references(() => roles.roleId),
|
||||||
isOwner: boolean("isOwner").notNull().default(false),
|
isOwner: boolean("isOwner").notNull().default(false),
|
||||||
autoProvisioned: boolean("autoProvisioned").default(false)
|
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||||
|
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||||
@@ -983,6 +989,16 @@ export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||||
|
messageId: serial("messageId").primaryKey(),
|
||||||
|
wsClientId: varchar("clientId"),
|
||||||
|
messageType: varchar("messageType"),
|
||||||
|
sentAt: bigint("sentAt", { mode: "number" }).notNull(),
|
||||||
|
receivedAt: bigint("receivedAt", { mode: "number" }),
|
||||||
|
error: text("error"),
|
||||||
|
complete: boolean("complete").notNull().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1043,3 +1059,4 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
|||||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||||
|
export type RoundTripMessageTracker = InferSelectModel<typeof roundTripMessageTracker>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./driver";
|
export * from "./driver";
|
||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
|
export * from "./migrate";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path";
|
|||||||
|
|
||||||
const migrationsFolder = path.join("server/migrations");
|
const migrationsFolder = path.join("server/migrations");
|
||||||
|
|
||||||
const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
console.log("Running migrations...");
|
console.log("Running migrations...");
|
||||||
try {
|
try {
|
||||||
migrate(db as any, {
|
migrate(db as any, {
|
||||||
@@ -16,5 +16,3 @@ const runMigrations = async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runMigrations();
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", {
|
|||||||
subscriptionItemId: integer("subscriptionItemId").primaryKey({
|
subscriptionItemId: integer("subscriptionItemId").primaryKey({
|
||||||
autoIncrement: true
|
autoIncrement: true
|
||||||
}),
|
}),
|
||||||
|
stripeSubscriptionItemId: text("stripeSubscriptionItemId"),
|
||||||
subscriptionId: text("subscriptionId")
|
subscriptionId: text("subscriptionId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => subscriptions.subscriptionId, {
|
.references(() => subscriptions.subscriptionId, {
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
sqliteTable,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
index,
|
|
||||||
uniqueIndex
|
|
||||||
} from "drizzle-orm/sqlite-core";
|
|
||||||
import { no } from "zod/v4/locales";
|
|
||||||
|
|
||||||
export const domains = sqliteTable("domains", {
|
export const domains = sqliteTable("domains", {
|
||||||
domainId: text("domainId").primaryKey(),
|
domainId: text("domainId").primaryKey(),
|
||||||
@@ -52,7 +45,11 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
.default(0),
|
.default(0),
|
||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0)
|
.default(0),
|
||||||
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
|
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||||
|
billingOrgId: text("billingOrgId")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDomains = sqliteTable("userDomains", {
|
export const userDomains = sqliteTable("userDomains", {
|
||||||
@@ -162,7 +159,8 @@ export const resources = sqliteTable("resources", {
|
|||||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||||
maintenanceTitle: text("maintenanceTitle"),
|
maintenanceTitle: text("maintenanceTitle"),
|
||||||
maintenanceMessage: text("maintenanceMessage"),
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
|
postAuthPath: text("postAuthPath")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
@@ -213,7 +211,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
}).default(true),
|
}).default(true),
|
||||||
hcMethod: text("hcMethod").default("GET"),
|
hcMethod: text("hcMethod").default("GET"),
|
||||||
hcStatus: integer("hcStatus"), // http code
|
hcStatus: integer("hcStatus"), // http code
|
||||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
hcTlsServerName: text("hcTlsServerName")
|
hcTlsServerName: text("hcTlsServerName")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
mode: text("mode").notNull(), // "host" | "cidr" | "port"
|
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||||
protocol: text("protocol"), // only for port mode
|
protocol: text("protocol"), // only for port mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
@@ -637,7 +637,8 @@ export const userOrgs = sqliteTable("userOrgs", {
|
|||||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||||
autoProvisioned: integer("autoProvisioned", {
|
autoProvisioned: integer("autoProvisioned", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).default(false)
|
}).default(false),
|
||||||
|
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||||
@@ -1079,6 +1080,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||||
|
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
||||||
|
wsClientId: text("clientId"),
|
||||||
|
messageType: text("messageType"),
|
||||||
|
sentAt: integer("sentAt").notNull(),
|
||||||
|
receivedAt: integer("receivedAt"),
|
||||||
|
error: text("error"),
|
||||||
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1140,3 +1151,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
|||||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||||
|
export type RoundTripMessageTracker = InferSelectModel<
|
||||||
|
typeof roundTripMessageTracker
|
||||||
|
>;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum FeatureId {
|
|||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
|
ORGINIZATIONS = "organizations",
|
||||||
TIER1 = "tier1"
|
TIER1 = "tier1"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
|
|||||||
return "Domains";
|
return "Domains";
|
||||||
case FeatureId.REMOTE_EXIT_NODES:
|
case FeatureId.REMOTE_EXIT_NODES:
|
||||||
return "Remote Exit Nodes";
|
return "Remote Exit Nodes";
|
||||||
|
case FeatureId.ORGINIZATIONS:
|
||||||
|
return "Organizations";
|
||||||
case FeatureId.TIER1:
|
case FeatureId.TIER1:
|
||||||
return "Home Lab";
|
return "Home Lab";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -7,18 +7,12 @@ export type LimitSet = Partial<{
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const sandboxLimitSet: LimitSet = {
|
|
||||||
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
|
||||||
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" },
|
|
||||||
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
|
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
|
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
|
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||||
|
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1LimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
@@ -26,6 +20,7 @@ export const tier1LimitSet: LimitSet = {
|
|||||||
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
|
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2LimitSet: LimitSet = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
@@ -45,6 +40,10 @@ export const tier2LimitSet: LimitSet = {
|
|||||||
value: 3,
|
value: 3,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
|
value: 1,
|
||||||
|
description: "Team limit"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3LimitSet: LimitSet = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
@@ -64,4 +63,8 @@ export const tier3LimitSet: LimitSet = {
|
|||||||
value: 20,
|
value: 20,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
|
value: 5,
|
||||||
|
description: "Business limit"
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export enum TierFeature {
|
|||||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||||
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
|
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
||||||
|
SshPam = "sshPam"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -46,5 +47,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
"tier3",
|
"tier3",
|
||||||
"enterprise"
|
"enterprise"
|
||||||
],
|
],
|
||||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"]
|
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||||
|
[TierFeature.SshPam]: ["enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
import { eq, sql, and } from "drizzle-orm";
|
import { eq, sql, and } from "drizzle-orm";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
usage,
|
usage,
|
||||||
customers,
|
customers,
|
||||||
sites,
|
|
||||||
newts,
|
|
||||||
limits,
|
limits,
|
||||||
Usage,
|
Usage,
|
||||||
Limit,
|
Limit,
|
||||||
Transaction
|
Transaction,
|
||||||
|
orgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { FeatureId, getFeatureMeterId } from "./features";
|
import { FeatureId, getFeatureMeterId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { s3Client } from "@server/lib/s3";
|
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
|
|
||||||
interface StripeEvent {
|
|
||||||
identifier?: string;
|
|
||||||
timestamp: number;
|
|
||||||
event_name: string;
|
|
||||||
payload: {
|
|
||||||
value: number;
|
|
||||||
stripe_customer_id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
return true;
|
return true;
|
||||||
@@ -37,41 +22,11 @@ export function noop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UsageService {
|
export class UsageService {
|
||||||
private bucketName: string | undefined;
|
|
||||||
private events: StripeEvent[] = [];
|
|
||||||
private lastUploadTime: number = Date.now();
|
|
||||||
private isUploading: boolean = false;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this.bucketName = process.env.S3_BUCKET || undefined;
|
|
||||||
|
|
||||||
// // Periodically check and upload events
|
|
||||||
// setInterval(() => {
|
|
||||||
// this.checkAndUploadEvents().catch((err) => {
|
|
||||||
// logger.error("Error in periodic event upload:", err);
|
|
||||||
// });
|
|
||||||
// }, 30000); // every 30 seconds
|
|
||||||
|
|
||||||
// // Handle graceful shutdown on SIGTERM
|
|
||||||
// process.on("SIGTERM", async () => {
|
|
||||||
// logger.info(
|
|
||||||
// "SIGTERM received, uploading events before shutdown..."
|
|
||||||
// );
|
|
||||||
// await this.forceUpload();
|
|
||||||
// logger.info("Events uploaded, proceeding with shutdown");
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // Handle SIGINT as well (Ctrl+C)
|
|
||||||
// process.on("SIGINT", async () => {
|
|
||||||
// logger.info("SIGINT received, uploading events before shutdown...");
|
|
||||||
// await this.forceUpload();
|
|
||||||
// logger.info("Events uploaded, proceeding with shutdown");
|
|
||||||
// process.exit(0);
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +46,8 @@ export class UsageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||||
|
|
||||||
// Truncate value to 11 decimal places
|
// Truncate value to 11 decimal places
|
||||||
value = this.truncateValue(value);
|
value = this.truncateValue(value);
|
||||||
|
|
||||||
@@ -100,20 +57,10 @@ export class UsageService {
|
|||||||
|
|
||||||
while (attempt <= maxRetries) {
|
while (attempt <= maxRetries) {
|
||||||
try {
|
try {
|
||||||
// Get subscription data for this org (with caching)
|
|
||||||
const customerId = await this.getCustomerId(orgId, featureId);
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
logger.warn(
|
|
||||||
`No subscription data found for org ${orgId} and feature ${featureId}`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage;
|
let usage;
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgId,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
value,
|
value,
|
||||||
transaction
|
transaction
|
||||||
@@ -121,7 +68,7 @@ export class UsageService {
|
|||||||
} else {
|
} else {
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgId,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
value,
|
value,
|
||||||
trx
|
trx
|
||||||
@@ -129,11 +76,6 @@ export class UsageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log event for Stripe
|
|
||||||
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
|
|
||||||
// await this.logStripeEvent(featureId, value, customerId);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return usage || null;
|
return usage || null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Check if this is a deadlock error
|
// Check if this is a deadlock error
|
||||||
@@ -150,7 +92,7 @@ export class UsageService {
|
|||||||
const delay = baseDelay + jitter;
|
const delay = baseDelay + jitter;
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
|
`Deadlock detected for ${orgIdToUse}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
@@ -158,7 +100,7 @@ export class UsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`,
|
`Failed to add usage for ${orgIdToUse}/${featureId} after ${attempt} attempts:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -169,7 +111,7 @@ export class UsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async internalAddUsage(
|
private async internalAddUsage(
|
||||||
orgId: string,
|
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
||||||
featureId: FeatureId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
@@ -188,17 +130,22 @@ export class UsageService {
|
|||||||
featureId,
|
featureId,
|
||||||
orgId,
|
orgId,
|
||||||
meterId,
|
meterId,
|
||||||
latestValue: value,
|
instantaneousValue: value || 0,
|
||||||
|
latestValue: value || 0,
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: usage.usageId,
|
target: usage.usageId,
|
||||||
set: {
|
set: {
|
||||||
latestValue: sql`${usage.latestValue} + ${value}`
|
instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}`
|
||||||
|
);
|
||||||
|
|
||||||
return returnUsage;
|
return returnUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,18 +168,10 @@ export class UsageService {
|
|||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
if (!customerId) {
|
|
||||||
customerId =
|
|
||||||
(await this.getCustomerId(orgId, featureId)) || undefined;
|
|
||||||
if (!customerId) {
|
|
||||||
logger.warn(
|
|
||||||
`No subscription data found for org ${orgId} and feature ${featureId}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
|
try {
|
||||||
// Truncate value to 11 decimal places if provided
|
// Truncate value to 11 decimal places if provided
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
value = this.truncateValue(value);
|
value = this.truncateValue(value);
|
||||||
@@ -242,7 +181,7 @@ export class UsageService {
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Get existing meter record
|
// Get existing meter record
|
||||||
const usageId = `${orgId}-${featureId}`;
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
// Get current usage record
|
// Get current usage record
|
||||||
[currentUsage] = await trx
|
[currentUsage] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -264,7 +203,7 @@ export class UsageService {
|
|||||||
await trx.insert(usage).values({
|
await trx.insert(usage).values({
|
||||||
usageId,
|
usageId,
|
||||||
featureId,
|
featureId,
|
||||||
orgId,
|
orgId: orgIdToUse,
|
||||||
meterId,
|
meterId,
|
||||||
instantaneousValue: value || 0,
|
instantaneousValue: value || 0,
|
||||||
latestValue: value || 0,
|
latestValue: value || 0,
|
||||||
@@ -278,7 +217,7 @@ export class UsageService {
|
|||||||
// }
|
// }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to update count usage for ${orgId}/${featureId}:`,
|
`Failed to update count usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,7 +227,9 @@ export class UsageService {
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId
|
featureId: FeatureId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const cacheKey = `customer_${orgId}_${featureId}`;
|
let orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
|
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
|
||||||
const cached = cache.get<string>(cacheKey);
|
const cached = cache.get<string>(cacheKey);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -302,7 +243,7 @@ export class UsageService {
|
|||||||
customerId: customers.customerId
|
customerId: customers.customerId
|
||||||
})
|
})
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(eq(customers.orgId, orgId))
|
.where(eq(customers.orgId, orgIdToUse))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -317,112 +258,13 @@ export class UsageService {
|
|||||||
return customerId;
|
return customerId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to get subscription data for ${orgId}/${featureId}:`,
|
`Failed to get subscription data for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async logStripeEvent(
|
|
||||||
featureId: FeatureId,
|
|
||||||
value: number,
|
|
||||||
customerId: string
|
|
||||||
): Promise<void> {
|
|
||||||
// Truncate value to 11 decimal places before sending to Stripe
|
|
||||||
const truncatedValue = this.truncateValue(value);
|
|
||||||
|
|
||||||
const event: StripeEvent = {
|
|
||||||
identifier: uuidv4(),
|
|
||||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
||||||
event_name: featureId,
|
|
||||||
payload: {
|
|
||||||
value: truncatedValue,
|
|
||||||
stripe_customer_id: customerId
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.addEventToMemory(event);
|
|
||||||
await this.checkAndUploadEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
private addEventToMemory(event: StripeEvent): void {
|
|
||||||
if (!this.bucketName) {
|
|
||||||
logger.warn(
|
|
||||||
"S3 bucket name is not configured, skipping event storage."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkAndUploadEvents(): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
const timeSinceLastUpload = now - this.lastUploadTime;
|
|
||||||
|
|
||||||
// Check if at least 1 minute has passed since last upload
|
|
||||||
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
|
|
||||||
await this.uploadEventsToS3();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadEventsToS3(): Promise<void> {
|
|
||||||
if (!this.bucketName) {
|
|
||||||
logger.warn(
|
|
||||||
"S3 bucket name is not configured, skipping S3 upload."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.events.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already uploading
|
|
||||||
if (this.isUploading) {
|
|
||||||
logger.debug("Already uploading events, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isUploading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Take a snapshot of current events and clear the array
|
|
||||||
const eventsToUpload = [...this.events];
|
|
||||||
this.events = [];
|
|
||||||
this.lastUploadTime = Date.now();
|
|
||||||
|
|
||||||
const fileName = this.generateEventFileName();
|
|
||||||
const fileContent = JSON.stringify(eventsToUpload, null, 2);
|
|
||||||
|
|
||||||
// Upload to S3
|
|
||||||
const uploadCommand = new PutObjectCommand({
|
|
||||||
Bucket: this.bucketName,
|
|
||||||
Key: fileName,
|
|
||||||
Body: fileContent,
|
|
||||||
ContentType: "application/json"
|
|
||||||
});
|
|
||||||
|
|
||||||
await s3Client.send(uploadCommand);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to upload events to S3:", error);
|
|
||||||
// Note: Events are lost if upload fails. In a production system,
|
|
||||||
// you might want to add the events back to the array or implement retry logic
|
|
||||||
} finally {
|
|
||||||
this.isUploading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateEventFileName(): string {
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
||||||
const uuid = uuidv4().substring(0, 8);
|
|
||||||
return `events-${timestamp}-${uuid}.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUsage(
|
public async getUsage(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId,
|
featureId: FeatureId,
|
||||||
@@ -432,7 +274,9 @@ export class UsageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageId = `${orgId}-${featureId}`;
|
let orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
@@ -444,7 +288,7 @@ export class UsageService {
|
|||||||
if (!result) {
|
if (!result) {
|
||||||
// Lets create one if it doesn't exist using upsert to handle race conditions
|
// Lets create one if it doesn't exist using upsert to handle race conditions
|
||||||
logger.info(
|
logger.info(
|
||||||
`Creating new usage record for ${orgId}/${featureId}`
|
`Creating new usage record for ${orgIdToUse}/${featureId}`
|
||||||
);
|
);
|
||||||
const meterId = getFeatureMeterId(featureId);
|
const meterId = getFeatureMeterId(featureId);
|
||||||
|
|
||||||
@@ -454,7 +298,7 @@ export class UsageService {
|
|||||||
.values({
|
.values({
|
||||||
usageId,
|
usageId,
|
||||||
featureId,
|
featureId,
|
||||||
orgId,
|
orgId: orgIdToUse,
|
||||||
meterId,
|
meterId,
|
||||||
latestValue: 0,
|
latestValue: 0,
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
@@ -476,7 +320,7 @@ export class UsageService {
|
|||||||
} catch (insertError) {
|
} catch (insertError) {
|
||||||
// Fallback: try to fetch existing record in case of any insert issues
|
// Fallback: try to fetch existing record in case of any insert issues
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`,
|
`Insert failed for ${orgIdToUse}/${featureId}, attempting to fetch existing record:`,
|
||||||
insertError
|
insertError
|
||||||
);
|
);
|
||||||
const [existingUsage] = await trx
|
const [existingUsage] = await trx
|
||||||
@@ -491,19 +335,41 @@ export class UsageService {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to get usage for ${orgId}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async forceUpload(): Promise<void> {
|
public async getBillingOrg(
|
||||||
if (this.events.length > 0) {
|
orgId: string,
|
||||||
// Force upload regardless of time
|
trx: Transaction | typeof db = db
|
||||||
this.lastUploadTime = 0; // Reset to force upload
|
): Promise<string> {
|
||||||
await this.uploadEventsToS3();
|
let orgIdToUse = orgId;
|
||||||
|
|
||||||
|
// get the org
|
||||||
|
const [org] = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!org.isBillingOrg) {
|
||||||
|
if (org.billingOrgId) {
|
||||||
|
orgIdToUse = org.billingOrgId;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Organization ${orgId} is not a billing org and does not have a billingOrgId set`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orgIdToUse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
@@ -515,6 +381,9 @@ export class UsageService {
|
|||||||
if (noop()) {
|
if (noop()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
@@ -528,7 +397,7 @@ export class UsageService {
|
|||||||
.from(limits)
|
.from(limits)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(limits.orgId, orgId),
|
eq(limits.orgId, orgIdToUse),
|
||||||
eq(limits.featureId, featureId)
|
eq(limits.featureId, featureId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -537,11 +406,11 @@ export class UsageService {
|
|||||||
orgLimits = await trx
|
orgLimits = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(limits)
|
.from(limits)
|
||||||
.where(eq(limits.orgId, orgId));
|
.where(eq(limits.orgId, orgIdToUse));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgLimits.length === 0) {
|
if (orgLimits.length === 0) {
|
||||||
logger.debug(`No limits set for org ${orgId}`);
|
logger.debug(`No limits set for org ${orgIdToUse}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +421,7 @@ export class UsageService {
|
|||||||
currentUsage = usage;
|
currentUsage = usage;
|
||||||
} else {
|
} else {
|
||||||
currentUsage = await this.getUsage(
|
currentUsage = await this.getUsage(
|
||||||
orgId,
|
orgIdToUse,
|
||||||
limit.featureId as FeatureId,
|
limit.featureId as FeatureId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
@@ -563,10 +432,10 @@ export class UsageService {
|
|||||||
currentUsage?.latestValue ||
|
currentUsage?.latestValue ||
|
||||||
0;
|
0;
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}`
|
`Current usage for org ${orgIdToUse} on feature ${limit.featureId}: ${usageValue}`
|
||||||
);
|
);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}`
|
`Limit for org ${orgIdToUse} on feature ${limit.featureId}: ${limit.value}`
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
currentUsage &&
|
currentUsage &&
|
||||||
@@ -574,7 +443,7 @@ export class UsageService {
|
|||||||
usageValue > limit.value
|
usageValue > limit.value
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Org ${orgId} has exceeded limit for ${limit.featureId}: ` +
|
`Org ${orgIdToUse} has exceeded limit for ${limit.featureId}: ` +
|
||||||
`${usageValue} > ${limit.value}`
|
`${usageValue} > ${limit.value}`
|
||||||
);
|
);
|
||||||
hasExceededLimits = true;
|
hasExceededLimits = true;
|
||||||
@@ -582,7 +451,7 @@ export class UsageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error checking limits for org ${orgId}:`, error);
|
logger.error(`Error checking limits for org ${orgIdToUse}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasExceededLimits;
|
return hasExceededLimits;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.15.0";
|
export const APP_VERSION = "1.15.4";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { isValidCIDR } from "@server/lib/validators";
|
|
||||||
import { getNextAvailableOrgSubnet } from "@server/lib/ip";
|
|
||||||
import {
|
|
||||||
actions,
|
|
||||||
apiKeyOrg,
|
|
||||||
apiKeys,
|
|
||||||
db,
|
|
||||||
domains,
|
|
||||||
Org,
|
|
||||||
orgDomains,
|
|
||||||
orgs,
|
|
||||||
roleActions,
|
|
||||||
roles,
|
|
||||||
userOrgs
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { defaultRoleAllowedActions } from "@server/routers/role";
|
|
||||||
import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing";
|
|
||||||
import { createCustomer } from "#dynamic/lib/billing";
|
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
export async function createUserAccountOrg(
|
|
||||||
userId: string,
|
|
||||||
userEmail: string
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
org?: {
|
|
||||||
orgId: string;
|
|
||||||
name: string;
|
|
||||||
subnet: string;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
// const subnet = await getNextAvailableOrgSubnet();
|
|
||||||
const orgId = "org_" + userId;
|
|
||||||
const name = `${userEmail}'s Organization`;
|
|
||||||
|
|
||||||
// if (!isValidCIDR(subnet)) {
|
|
||||||
// return {
|
|
||||||
// success: false,
|
|
||||||
// error: "Invalid subnet format. Please provide a valid CIDR notation."
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // make sure the subnet is unique
|
|
||||||
// const subnetExists = await db
|
|
||||||
// .select()
|
|
||||||
// .from(orgs)
|
|
||||||
// .where(eq(orgs.subnet, subnet))
|
|
||||||
// .limit(1);
|
|
||||||
|
|
||||||
// if (subnetExists.length > 0) {
|
|
||||||
// return { success: false, error: `Subnet ${subnet} already exists` };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// make sure the orgId is unique
|
|
||||||
const orgExists = await db
|
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (orgExists.length > 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Organization with ID ${orgId} already exists`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let error = "";
|
|
||||||
let org: Org | null = null;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const allDomains = await trx
|
|
||||||
.select()
|
|
||||||
.from(domains)
|
|
||||||
.where(eq(domains.configManaged, true));
|
|
||||||
|
|
||||||
const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group;
|
|
||||||
|
|
||||||
const newOrg = await trx
|
|
||||||
.insert(orgs)
|
|
||||||
.values({
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
// subnet
|
|
||||||
subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs?
|
|
||||||
utilitySubnet: utilitySubnet,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (newOrg.length === 0) {
|
|
||||||
error = "Failed to create organization";
|
|
||||||
trx.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
org = newOrg[0];
|
|
||||||
|
|
||||||
// Create admin role within the same transaction
|
|
||||||
const [insertedRole] = await trx
|
|
||||||
.insert(roles)
|
|
||||||
.values({
|
|
||||||
orgId: newOrg[0].orgId,
|
|
||||||
isAdmin: true,
|
|
||||||
name: "Admin",
|
|
||||||
description: "Admin role with the most permissions"
|
|
||||||
})
|
|
||||||
.returning({ roleId: roles.roleId });
|
|
||||||
|
|
||||||
if (!insertedRole || !insertedRole.roleId) {
|
|
||||||
error = "Failed to create Admin role";
|
|
||||||
trx.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleId = insertedRole.roleId;
|
|
||||||
|
|
||||||
// Get all actions and create role actions
|
|
||||||
const actionIds = await trx.select().from(actions).execute();
|
|
||||||
|
|
||||||
if (actionIds.length > 0) {
|
|
||||||
await trx.insert(roleActions).values(
|
|
||||||
actionIds.map((action) => ({
|
|
||||||
roleId,
|
|
||||||
actionId: action.actionId,
|
|
||||||
orgId: newOrg[0].orgId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allDomains.length) {
|
|
||||||
await trx.insert(orgDomains).values(
|
|
||||||
allDomains.map((domain) => ({
|
|
||||||
orgId: newOrg[0].orgId,
|
|
||||||
domainId: domain.domainId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(userOrgs).values({
|
|
||||||
userId,
|
|
||||||
orgId: newOrg[0].orgId,
|
|
||||||
roleId: roleId,
|
|
||||||
isOwner: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberRole = await trx
|
|
||||||
.insert(roles)
|
|
||||||
.values({
|
|
||||||
name: "Member",
|
|
||||||
description: "Members can only view resources",
|
|
||||||
orgId
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await trx.insert(roleActions).values(
|
|
||||||
defaultRoleAllowedActions.map((action) => ({
|
|
||||||
roleId: memberRole[0].roleId,
|
|
||||||
actionId: action,
|
|
||||||
orgId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
return { success: false, error: "Failed to create org" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Failed to create org: ${error}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure we have the stripe customer
|
|
||||||
const customerId = await createCustomer(orgId, userEmail);
|
|
||||||
|
|
||||||
if (customerId) {
|
|
||||||
await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
org: {
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
// subnet
|
|
||||||
subnet: "100.90.128.0/24"
|
|
||||||
},
|
|
||||||
success: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
241
server/lib/deleteOrg.ts
Normal file
241
server/lib/deleteOrg.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
clients,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
domains,
|
||||||
|
exitNodeOrgs,
|
||||||
|
exitNodes,
|
||||||
|
olms,
|
||||||
|
orgDomains,
|
||||||
|
orgs,
|
||||||
|
remoteExitNodes,
|
||||||
|
resources,
|
||||||
|
sites,
|
||||||
|
userOrgs
|
||||||
|
} from "@server/db";
|
||||||
|
import { newts, newtSessions } from "@server/db";
|
||||||
|
import { eq, and, inArray, sql, count, countDistinct } from "drizzle-orm";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
|
import { deletePeer } from "@server/routers/gerbil/peers";
|
||||||
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
|
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||||
|
import { usageService } from "./billing/usageService";
|
||||||
|
import { FeatureId } from "./billing";
|
||||||
|
|
||||||
|
export type DeleteOrgByIdResult = {
|
||||||
|
deletedNewtIds: string[];
|
||||||
|
olmsToTerminate: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one organization and its related data. Returns ids for termination
|
||||||
|
* messages; caller should call sendTerminationMessages with the result.
|
||||||
|
* Throws if org not found.
|
||||||
|
*/
|
||||||
|
export async function deleteOrgById(
|
||||||
|
orgId: string
|
||||||
|
): Promise<DeleteOrgByIdResult> {
|
||||||
|
const [org] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Organization with ID ${orgId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgSites = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const orgClients = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.orgId, orgId));
|
||||||
|
|
||||||
|
const deletedNewtIds: string[] = [];
|
||||||
|
const olmsToTerminate: string[] = [];
|
||||||
|
|
||||||
|
let domainCount: number | null = null;
|
||||||
|
let siteCount: number | null = null;
|
||||||
|
let userCount: number | null = null;
|
||||||
|
let remoteExitNodeCount: number | null = null;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (const site of orgSites) {
|
||||||
|
if (site.pubKey) {
|
||||||
|
if (site.type == "wireguard") {
|
||||||
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
|
} else if (site.type == "newt") {
|
||||||
|
const [deletedNewt] = await trx
|
||||||
|
.delete(newts)
|
||||||
|
.where(eq(newts.siteId, site.siteId))
|
||||||
|
.returning();
|
||||||
|
if (deletedNewt) {
|
||||||
|
deletedNewtIds.push(deletedNewt.newtId);
|
||||||
|
await trx
|
||||||
|
.delete(newtSessions)
|
||||||
|
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`Deleting site ${site.siteId}`);
|
||||||
|
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
||||||
|
}
|
||||||
|
for (const client of orgClients) {
|
||||||
|
const [olm] = await trx
|
||||||
|
.select()
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.clientId, client.clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (olm) {
|
||||||
|
olmsToTerminate.push(olm.olmId);
|
||||||
|
}
|
||||||
|
logger.info(`Deleting client ${client.clientId}`);
|
||||||
|
await trx
|
||||||
|
.delete(clients)
|
||||||
|
.where(eq(clients.clientId, client.clientId));
|
||||||
|
await trx
|
||||||
|
.delete(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await trx
|
||||||
|
.delete(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const allOrgDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgDomains.orgId, orgId),
|
||||||
|
eq(domains.configManaged, false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const domainIdsToDelete: string[] = [];
|
||||||
|
for (const orgDomain of allOrgDomains) {
|
||||||
|
const domainId = orgDomain.domains.domainId;
|
||||||
|
const orgCount = await trx
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
|
if (orgCount[0].count === 1) {
|
||||||
|
domainIdsToDelete.push(domainId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (domainIdsToDelete.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(domains)
|
||||||
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
|
}
|
||||||
|
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||||
|
|
||||||
|
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||||
|
|
||||||
|
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
|
if (org.billingOrgId) {
|
||||||
|
const billingOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||||
|
|
||||||
|
if (billingOrgs.length > 0) {
|
||||||
|
const billingOrgIds = billingOrgs.map((org) => org.orgId);
|
||||||
|
|
||||||
|
const [domainCountRes] = await trx
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(inArray(orgDomains.orgId, billingOrgIds));
|
||||||
|
|
||||||
|
domainCount = domainCountRes.count;
|
||||||
|
|
||||||
|
const [siteCountRes] = await trx
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(sites)
|
||||||
|
.where(inArray(sites.orgId, billingOrgIds));
|
||||||
|
|
||||||
|
siteCount = siteCountRes.count;
|
||||||
|
|
||||||
|
const [userCountRes] = await trx
|
||||||
|
.select({ count: countDistinct(userOrgs.userId) })
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(inArray(userOrgs.orgId, billingOrgIds));
|
||||||
|
|
||||||
|
userCount = userCountRes.count;
|
||||||
|
|
||||||
|
const [remoteExitNodeCountRes] = await trx
|
||||||
|
.select({ count: countDistinct(exitNodeOrgs.exitNodeId) })
|
||||||
|
.from(exitNodeOrgs)
|
||||||
|
.where(inArray(exitNodeOrgs.orgId, billingOrgIds));
|
||||||
|
|
||||||
|
remoteExitNodeCount = remoteExitNodeCountRes.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (org.billingOrgId) {
|
||||||
|
usageService.updateCount(
|
||||||
|
org.billingOrgId,
|
||||||
|
FeatureId.DOMAINS,
|
||||||
|
domainCount ?? 0
|
||||||
|
);
|
||||||
|
usageService.updateCount(
|
||||||
|
org.billingOrgId,
|
||||||
|
FeatureId.SITES,
|
||||||
|
siteCount ?? 0
|
||||||
|
);
|
||||||
|
usageService.updateCount(
|
||||||
|
org.billingOrgId,
|
||||||
|
FeatureId.USERS,
|
||||||
|
userCount ?? 0
|
||||||
|
);
|
||||||
|
usageService.updateCount(
|
||||||
|
org.billingOrgId,
|
||||||
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
|
remoteExitNodeCount ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deletedNewtIds, olmsToTerminate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
|
||||||
|
for (const newtId of result.deletedNewtIds) {
|
||||||
|
sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch(
|
||||||
|
(error) => {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send termination message to newt:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const olmId of result.olmsToTerminate) {
|
||||||
|
sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch(
|
||||||
|
(error) => {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send termination message to olm:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/lib/normalizePostAuthPath.ts
Normal file
18
server/lib/normalizePostAuthPath.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Normalizes a post-authentication path for safe use when building redirect URLs.
|
||||||
|
* Returns a path that starts with / and does not allow open redirects (no //, no :).
|
||||||
|
*/
|
||||||
|
export function normalizePostAuthPath(path: string | null | undefined): string | null {
|
||||||
|
if (path == null || typeof path !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Reject protocol-relative (//) or scheme (:) to avoid open redirect
|
||||||
|
if (trimmed.includes("//") || trimmed.includes(":")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
|
}
|
||||||
142
server/lib/userOrg.ts
Normal file
142
server/lib/userOrg.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
Org,
|
||||||
|
orgs,
|
||||||
|
resources,
|
||||||
|
siteResources,
|
||||||
|
sites,
|
||||||
|
Transaction,
|
||||||
|
UserOrg,
|
||||||
|
userOrgs,
|
||||||
|
userResources,
|
||||||
|
userSiteResources,
|
||||||
|
userSites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and, inArray, ne, exists } from "drizzle-orm";
|
||||||
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
|
import { FeatureId } from "@server/lib/billing";
|
||||||
|
|
||||||
|
export async function assignUserToOrg(
|
||||||
|
org: Org,
|
||||||
|
values: typeof userOrgs.$inferInsert,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
) {
|
||||||
|
const [userOrg] = await trx.insert(userOrgs).values(values).returning();
|
||||||
|
|
||||||
|
// calculate if the user is in any other of the orgs before we count it as an add to the billing org
|
||||||
|
if (org.billingOrgId) {
|
||||||
|
const otherBillingOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgs.billingOrgId, org.billingOrgId),
|
||||||
|
ne(orgs.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||||
|
|
||||||
|
const orgsInBillingDomainThatTheUserIsStillIn = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userOrg.userId),
|
||||||
|
inArray(userOrgs.orgId, billingOrgIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
||||||
|
await usageService.add(org.orgId, FeatureId.USERS, 1, trx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeUserFromOrg(
|
||||||
|
org: Org,
|
||||||
|
userId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
) {
|
||||||
|
await trx
|
||||||
|
.delete(userOrgs)
|
||||||
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
||||||
|
|
||||||
|
await trx.delete(userResources).where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
exists(
|
||||||
|
trx
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, userResources.resourceId),
|
||||||
|
eq(resources.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.delete(userSiteResources).where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, userId),
|
||||||
|
exists(
|
||||||
|
trx
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
userSiteResources.siteResourceId
|
||||||
|
),
|
||||||
|
eq(siteResources.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.delete(userSites).where(
|
||||||
|
and(
|
||||||
|
eq(userSites.userId, userId),
|
||||||
|
exists(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.siteId, userSites.siteId),
|
||||||
|
eq(sites.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
|
||||||
|
if (org.billingOrgId) {
|
||||||
|
const billingOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||||
|
|
||||||
|
const billingOrgIds = billingOrgs.map((o) => o.orgId);
|
||||||
|
|
||||||
|
const orgsInBillingDomainThatTheUserIsStillIn = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
inArray(userOrgs.orgId, billingOrgIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
||||||
|
await usageService.add(org.orgId, FeatureId.USERS, -1, trx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,5 +16,6 @@ export enum OpenAPITags {
|
|||||||
Client = "Client",
|
Client = "Client",
|
||||||
ApiKey = "API Key",
|
ApiKey = "API Key",
|
||||||
Domain = "Domain",
|
Domain = "Domain",
|
||||||
Blueprint = "Blueprint"
|
Blueprint = "Blueprint",
|
||||||
|
Ssh = "SSH"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db, customers, subscriptions } from "@server/db";
|
import { db, customers, subscriptions, orgs } from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
import { Tier } from "@server/types/Tiers";
|
import { Tier } from "@server/types/Tiers";
|
||||||
import { eq, and, ne } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
|
|
||||||
@@ -27,37 +28,60 @@ export async function getOrgTierData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const [org] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return { tier, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
let orgIdToUse = org.orgId;
|
||||||
|
if (!org.isBillingOrg) {
|
||||||
|
if (!org.billingOrgId) {
|
||||||
|
logger.warn(
|
||||||
|
`Org ${orgId} is not a billing org and does not have a billingOrgId`
|
||||||
|
);
|
||||||
|
return { tier, active };
|
||||||
|
}
|
||||||
|
orgIdToUse = org.billingOrgId;
|
||||||
|
}
|
||||||
|
|
||||||
// Get customer for org
|
// Get customer for org
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(eq(customers.orgId, orgId))
|
.where(eq(customers.orgId, orgIdToUse))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (customer) {
|
if (!customer) {
|
||||||
// Query for active subscriptions that are not license type
|
return { tier, active };
|
||||||
const [subscription] = await db
|
}
|
||||||
.select()
|
|
||||||
.from(subscriptions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(subscriptions.customerId, customer.customerId),
|
|
||||||
eq(subscriptions.status, "active"),
|
|
||||||
ne(subscriptions.type, "license")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (subscription) {
|
// Query for active subscriptions that are not license type
|
||||||
// Validate that subscription.type is one of the expected tier values
|
const [subscription] = await db
|
||||||
if (
|
.select()
|
||||||
subscription.type === "tier1" ||
|
.from(subscriptions)
|
||||||
subscription.type === "tier2" ||
|
.where(
|
||||||
subscription.type === "tier3"
|
and(
|
||||||
) {
|
eq(subscriptions.customerId, customer.customerId),
|
||||||
tier = subscription.type;
|
eq(subscriptions.status, "active"),
|
||||||
active = true;
|
ne(subscriptions.type, "license")
|
||||||
}
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Validate that subscription.type is one of the expected tier values
|
||||||
|
if (
|
||||||
|
subscription.type === "tier1" ||
|
||||||
|
subscription.type === "tier2" ||
|
||||||
|
subscription.type === "tier3"
|
||||||
|
) {
|
||||||
|
tier = subscription.type;
|
||||||
|
active = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export class PrivateConfig {
|
|||||||
this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
|
this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.rawPrivateConfig.app.identity_provider_mode) {
|
||||||
|
process.env.IDENTITY_PROVIDER_MODE =
|
||||||
|
this.rawPrivateConfig.app.identity_provider_mode;
|
||||||
|
}
|
||||||
|
|
||||||
process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
|
process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
|
||||||
?.logo?.auth_page?.width
|
?.logo?.auth_page?.width
|
||||||
? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
|
? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
|
||||||
@@ -129,10 +134,6 @@ export class PrivateConfig {
|
|||||||
process.env.USE_PANGOLIN_DNS =
|
process.env.USE_PANGOLIN_DNS =
|
||||||
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
||||||
}
|
}
|
||||||
if (this.rawPrivateConfig.flags.use_org_only_idp) {
|
|
||||||
process.env.USE_ORG_ONLY_IDP =
|
|
||||||
this.rawPrivateConfig.flags.use_org_only_idp.toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRawPrivateConfig() {
|
public getRawPrivateConfig() {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const privateConfigSchema = z.object({
|
|||||||
app: z
|
app: z
|
||||||
.object({
|
.object({
|
||||||
region: z.string().optional().default("default"),
|
region: z.string().optional().default("default"),
|
||||||
base_domain: z.string().optional()
|
base_domain: z.string().optional(),
|
||||||
|
identity_provider_mode: z.enum(["global", "org"]).optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default({
|
.default({
|
||||||
@@ -95,7 +96,7 @@ export const privateConfigSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enable_redis: z.boolean().optional().default(false),
|
enable_redis: z.boolean().optional().default(false),
|
||||||
use_pangolin_dns: z.boolean().optional().default(false),
|
use_pangolin_dns: z.boolean().optional().default(false),
|
||||||
use_org_only_idp: z.boolean().optional().default(false),
|
use_org_only_idp: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
@@ -181,7 +182,29 @@ export const privateConfigSchema = z.object({
|
|||||||
// localFilePath: z.string().optional()
|
// localFilePath: z.string().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
});
|
})
|
||||||
|
.transform((data) => {
|
||||||
|
// this to maintain backwards compatibility with the old config file
|
||||||
|
const identityProviderMode = data.app?.identity_provider_mode;
|
||||||
|
const useOrgOnlyIdp = data.flags?.use_org_only_idp;
|
||||||
|
|
||||||
|
if (identityProviderMode !== undefined) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (useOrgOnlyIdp === true) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
app: { ...data.app, identity_provider_mode: "org" as const }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (useOrgOnlyIdp === false) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
app: { ...data.app, identity_provider_mode: "global" as const }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
export function readPrivateConfigFile() {
|
export function readPrivateConfigFile() {
|
||||||
if (build == "oss") {
|
if (build == "oss") {
|
||||||
|
|||||||
442
server/private/lib/sshCA.ts
Normal file
442
server/private/lib/sshCA.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
/*
|
||||||
|
* 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 * as crypto from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH CA "Server" - Pure TypeScript Implementation
|
||||||
|
*
|
||||||
|
* This module provides basic SSH Certificate Authority functionality using
|
||||||
|
* only Node.js built-in crypto module. No external dependencies or subprocesses.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. generateCA() - Creates a new CA key pair, returns CA info including the
|
||||||
|
* TrustedUserCAKeys line to add to servers
|
||||||
|
* 2. signPublicKey() - Signs a user's public key with the CA, returns a certificate
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH Wire Format Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a string in SSH wire format (4-byte length prefix + data)
|
||||||
|
*/
|
||||||
|
function encodeString(data: Buffer | string): Buffer {
|
||||||
|
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(buf.length, 0);
|
||||||
|
return Buffer.concat([len, buf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a uint32 in SSH wire format (big-endian)
|
||||||
|
*/
|
||||||
|
function encodeUInt32(value: number): Buffer {
|
||||||
|
const buf = Buffer.alloc(4);
|
||||||
|
buf.writeUInt32BE(value, 0);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a uint64 in SSH wire format (big-endian)
|
||||||
|
*/
|
||||||
|
function encodeUInt64(value: bigint): Buffer {
|
||||||
|
const buf = Buffer.alloc(8);
|
||||||
|
buf.writeBigUInt64BE(value, 0);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a string from SSH wire format at the given offset
|
||||||
|
* Returns the string buffer and the new offset
|
||||||
|
*/
|
||||||
|
function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
|
||||||
|
const len = data.readUInt32BE(offset);
|
||||||
|
const value = data.subarray(offset + 4, offset + 4 + len);
|
||||||
|
return { value, newOffset: offset + 4 + len };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH Public Key Parsing/Encoding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an OpenSSH public key line (e.g., "ssh-ed25519 AAAA... comment")
|
||||||
|
*/
|
||||||
|
function parseOpenSSHPublicKey(pubKeyLine: string): {
|
||||||
|
keyType: string;
|
||||||
|
keyData: Buffer;
|
||||||
|
comment: string;
|
||||||
|
} {
|
||||||
|
const parts = pubKeyLine.trim().split(/\s+/);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new Error("Invalid public key format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyType = parts[0];
|
||||||
|
const keyData = Buffer.from(parts[1], "base64");
|
||||||
|
const comment = parts.slice(2).join(" ") || "";
|
||||||
|
|
||||||
|
// Verify the key type in the blob matches
|
||||||
|
const { value: blobKeyType } = decodeString(keyData, 0);
|
||||||
|
if (blobKeyType.toString("utf8") !== keyType) {
|
||||||
|
throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keyType, keyData, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an Ed25519 public key in OpenSSH format
|
||||||
|
*/
|
||||||
|
function encodeEd25519PublicKey(publicKey: Buffer): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
encodeString("ssh-ed25519"),
|
||||||
|
encodeString(publicKey)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a public key blob as an OpenSSH public key line
|
||||||
|
*/
|
||||||
|
function formatOpenSSHPublicKey(keyBlob: Buffer, comment: string = ""): string {
|
||||||
|
const { value: keyType } = decodeString(keyBlob, 0);
|
||||||
|
const base64 = keyBlob.toString("base64");
|
||||||
|
return `${keyType.toString("utf8")} ${base64}${comment ? " " + comment : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH Certificate Building
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CertificateOptions {
|
||||||
|
/** Serial number for the certificate */
|
||||||
|
serial?: bigint;
|
||||||
|
/** Certificate type: 1 = user, 2 = host */
|
||||||
|
certType?: number;
|
||||||
|
/** Key ID (usually username or identifier) */
|
||||||
|
keyId: string;
|
||||||
|
/** List of valid principals (usernames the cert is valid for) */
|
||||||
|
validPrincipals: string[];
|
||||||
|
/** Valid after timestamp (seconds since epoch) */
|
||||||
|
validAfter?: bigint;
|
||||||
|
/** Valid before timestamp (seconds since epoch) */
|
||||||
|
validBefore?: bigint;
|
||||||
|
/** Critical options (usually empty for user certs) */
|
||||||
|
criticalOptions?: Map<string, string>;
|
||||||
|
/** Extensions to enable */
|
||||||
|
extensions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the extensions section of the certificate
|
||||||
|
*/
|
||||||
|
function buildExtensions(extensions: string[]): Buffer {
|
||||||
|
// Extensions are a series of name-value pairs, sorted by name
|
||||||
|
// For boolean extensions, the value is empty
|
||||||
|
const sortedExtensions = [...extensions].sort();
|
||||||
|
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const ext of sortedExtensions) {
|
||||||
|
parts.push(encodeString(ext));
|
||||||
|
parts.push(encodeString("")); // Empty value for boolean extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeString(Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the critical options section
|
||||||
|
*/
|
||||||
|
function buildCriticalOptions(options: Map<string, string>): Buffer {
|
||||||
|
const sortedKeys = [...options.keys()].sort();
|
||||||
|
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
parts.push(encodeString(key));
|
||||||
|
parts.push(encodeString(encodeString(options.get(key)!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeString(Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the valid principals section
|
||||||
|
*/
|
||||||
|
function buildPrincipals(principals: string[]): Buffer {
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const principal of principals) {
|
||||||
|
parts.push(encodeString(principal));
|
||||||
|
}
|
||||||
|
return encodeString(Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the raw Ed25519 public key from an OpenSSH public key blob
|
||||||
|
*/
|
||||||
|
function extractEd25519PublicKey(keyBlob: Buffer): Buffer {
|
||||||
|
const { newOffset } = decodeString(keyBlob, 0); // Skip key type
|
||||||
|
const { value: publicKey } = decodeString(keyBlob, newOffset);
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CA Interface
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CAKeyPair {
|
||||||
|
/** CA private key in PEM format (keep this secret!) */
|
||||||
|
privateKeyPem: string;
|
||||||
|
/** CA public key in PEM format */
|
||||||
|
publicKeyPem: string;
|
||||||
|
/** CA public key in OpenSSH format (for TrustedUserCAKeys) */
|
||||||
|
publicKeyOpenSSH: string;
|
||||||
|
/** Raw CA public key bytes (Ed25519) */
|
||||||
|
publicKeyRaw: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedCertificate {
|
||||||
|
/** The certificate in OpenSSH format (save as id_ed25519-cert.pub or similar) */
|
||||||
|
certificate: string;
|
||||||
|
/** The certificate type string */
|
||||||
|
certType: string;
|
||||||
|
/** Serial number */
|
||||||
|
serial: bigint;
|
||||||
|
/** Key ID */
|
||||||
|
keyId: string;
|
||||||
|
/** Valid principals */
|
||||||
|
validPrincipals: string[];
|
||||||
|
/** Valid from timestamp */
|
||||||
|
validAfter: Date;
|
||||||
|
/** Valid until timestamp */
|
||||||
|
validBefore: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new SSH Certificate Authority key pair.
|
||||||
|
*
|
||||||
|
* Returns the CA keys and the line to add to /etc/ssh/sshd_config:
|
||||||
|
* TrustedUserCAKeys /etc/ssh/ca.pub
|
||||||
|
*
|
||||||
|
* Then save the publicKeyOpenSSH to /etc/ssh/ca.pub on the server.
|
||||||
|
*
|
||||||
|
* @param comment - Optional comment for the CA public key
|
||||||
|
* @returns CA key pair and configuration info
|
||||||
|
*/
|
||||||
|
export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
|
||||||
|
// Generate Ed25519 key pair
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
|
||||||
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
|
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get raw public key bytes
|
||||||
|
const pubKeyObj = crypto.createPublicKey(publicKey);
|
||||||
|
const rawPubKey = pubKeyObj.export({ type: "spki", format: "der" });
|
||||||
|
// Ed25519 SPKI format: 12 byte header + 32 byte key
|
||||||
|
const ed25519PubKey = rawPubKey.subarray(rawPubKey.length - 32);
|
||||||
|
|
||||||
|
// Create OpenSSH format public key
|
||||||
|
const pubKeyBlob = encodeEd25519PublicKey(ed25519PubKey);
|
||||||
|
const publicKeyOpenSSH = formatOpenSSHPublicKey(pubKeyBlob, comment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKeyPem: privateKey,
|
||||||
|
publicKeyPem: publicKey,
|
||||||
|
publicKeyOpenSSH,
|
||||||
|
publicKeyRaw: ed25519PubKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and decrypt the SSH CA keys for an organization.
|
||||||
|
*
|
||||||
|
* @param orgId - Organization ID
|
||||||
|
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
|
||||||
|
* @returns CA key pair or null if not found
|
||||||
|
*/
|
||||||
|
export async function getOrgCAKeys(
|
||||||
|
orgId: string,
|
||||||
|
decryptionKey: string
|
||||||
|
): Promise<CAKeyPair | null> {
|
||||||
|
const { db, orgs } = await import("@server/db");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const { decrypt } = await import("@server/lib/crypto");
|
||||||
|
|
||||||
|
const [org] = await db
|
||||||
|
.select({
|
||||||
|
sshCaPrivateKey: orgs.sshCaPrivateKey,
|
||||||
|
sshCaPublicKey: orgs.sshCaPublicKey
|
||||||
|
})
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org || !org.sshCaPrivateKey || !org.sshCaPublicKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyPem = decrypt(org.sshCaPrivateKey, decryptionKey);
|
||||||
|
|
||||||
|
// Extract raw public key from the OpenSSH format
|
||||||
|
const { keyData } = parseOpenSSHPublicKey(org.sshCaPublicKey);
|
||||||
|
const { newOffset } = decodeString(keyData, 0); // Skip key type
|
||||||
|
const { value: publicKeyRaw } = decodeString(keyData, newOffset);
|
||||||
|
|
||||||
|
// Get PEM format of public key
|
||||||
|
const pubKeyObj = crypto.createPublicKey({
|
||||||
|
key: privateKeyPem,
|
||||||
|
format: "pem"
|
||||||
|
});
|
||||||
|
const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKeyPem,
|
||||||
|
publicKeyPem,
|
||||||
|
publicKeyOpenSSH: org.sshCaPublicKey,
|
||||||
|
publicKeyRaw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a user's SSH public key with the CA, producing a certificate.
|
||||||
|
*
|
||||||
|
* The resulting certificate should be saved alongside the user's private key
|
||||||
|
* with a -cert.pub suffix. For example:
|
||||||
|
* - Private key: ~/.ssh/id_ed25519
|
||||||
|
* - Certificate: ~/.ssh/id_ed25519-cert.pub
|
||||||
|
*
|
||||||
|
* @param caPrivateKeyPem - CA private key in PEM format
|
||||||
|
* @param userPublicKeyLine - User's public key in OpenSSH format
|
||||||
|
* @param options - Certificate options (principals, validity, etc.)
|
||||||
|
* @returns Signed certificate
|
||||||
|
*/
|
||||||
|
export function signPublicKey(
|
||||||
|
caPrivateKeyPem: string,
|
||||||
|
userPublicKeyLine: string,
|
||||||
|
options: CertificateOptions
|
||||||
|
): SignedCertificate {
|
||||||
|
// Parse the user's public key
|
||||||
|
const { keyType, keyData } = parseOpenSSHPublicKey(userPublicKeyLine);
|
||||||
|
|
||||||
|
// Determine certificate type string
|
||||||
|
let certTypeString: string;
|
||||||
|
if (keyType === "ssh-ed25519") {
|
||||||
|
certTypeString = "ssh-ed25519-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ssh-rsa") {
|
||||||
|
certTypeString = "ssh-rsa-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ecdsa-sha2-nistp256") {
|
||||||
|
certTypeString = "ecdsa-sha2-nistp256-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ecdsa-sha2-nistp384") {
|
||||||
|
certTypeString = "ecdsa-sha2-nistp384-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ecdsa-sha2-nistp521") {
|
||||||
|
certTypeString = "ecdsa-sha2-nistp521-cert-v01@openssh.com";
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported key type: ${keyType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CA public key from private key
|
||||||
|
const caPrivKey = crypto.createPrivateKey(caPrivateKeyPem);
|
||||||
|
const caPubKey = crypto.createPublicKey(caPrivKey);
|
||||||
|
const caRawPubKey = caPubKey.export({ type: "spki", format: "der" });
|
||||||
|
const caEd25519PubKey = caRawPubKey.subarray(caRawPubKey.length - 32);
|
||||||
|
const caPubKeyBlob = encodeEd25519PublicKey(caEd25519PubKey);
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
const serial = options.serial ?? BigInt(Date.now());
|
||||||
|
const certType = options.certType ?? 1; // 1 = user cert
|
||||||
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
|
const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago
|
||||||
|
const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now
|
||||||
|
|
||||||
|
// Default extensions for user certificates
|
||||||
|
const defaultExtensions = [
|
||||||
|
"permit-X11-forwarding",
|
||||||
|
"permit-agent-forwarding",
|
||||||
|
"permit-port-forwarding",
|
||||||
|
"permit-pty",
|
||||||
|
"permit-user-rc"
|
||||||
|
];
|
||||||
|
const extensions = options.extensions ?? defaultExtensions;
|
||||||
|
const criticalOptions = options.criticalOptions ?? new Map();
|
||||||
|
|
||||||
|
// Generate nonce (random bytes)
|
||||||
|
const nonce = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
// Extract the public key portion from the user's key blob
|
||||||
|
// For Ed25519: skip the key type string, get the public key (already encoded)
|
||||||
|
let userKeyPortion: Buffer;
|
||||||
|
if (keyType === "ssh-ed25519") {
|
||||||
|
// Skip the key type string, take the rest (which is encodeString(32-byte-key))
|
||||||
|
const { newOffset } = decodeString(keyData, 0);
|
||||||
|
userKeyPortion = keyData.subarray(newOffset);
|
||||||
|
} else {
|
||||||
|
// For other key types, extract everything after the key type
|
||||||
|
const { newOffset } = decodeString(keyData, 0);
|
||||||
|
userKeyPortion = keyData.subarray(newOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the certificate body (to be signed)
|
||||||
|
const certBody = Buffer.concat([
|
||||||
|
encodeString(certTypeString),
|
||||||
|
encodeString(nonce),
|
||||||
|
userKeyPortion,
|
||||||
|
encodeUInt64(serial),
|
||||||
|
encodeUInt32(certType),
|
||||||
|
encodeString(options.keyId),
|
||||||
|
buildPrincipals(options.validPrincipals),
|
||||||
|
encodeUInt64(validAfter),
|
||||||
|
encodeUInt64(validBefore),
|
||||||
|
buildCriticalOptions(criticalOptions),
|
||||||
|
buildExtensions(extensions),
|
||||||
|
encodeString(""), // reserved
|
||||||
|
encodeString(caPubKeyBlob) // signature key (CA public key)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sign the certificate body
|
||||||
|
const signature = crypto.sign(null, certBody, caPrivKey);
|
||||||
|
|
||||||
|
// Build the full signature blob (algorithm + signature)
|
||||||
|
const signatureBlob = Buffer.concat([
|
||||||
|
encodeString("ssh-ed25519"),
|
||||||
|
encodeString(signature)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build complete certificate
|
||||||
|
const certificate = Buffer.concat([
|
||||||
|
certBody,
|
||||||
|
encodeString(signatureBlob)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Format as OpenSSH certificate line
|
||||||
|
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate: certLine,
|
||||||
|
certType: certTypeString,
|
||||||
|
serial,
|
||||||
|
keyId: options.keyId,
|
||||||
|
validPrincipals: options.validPrincipals,
|
||||||
|
validAfter: new Date(Number(validAfter) * 1000),
|
||||||
|
validBefore: new Date(Number(validBefore) * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { approvals, db, type Approval } from "@server/db";
|
import { approvals, db, type Approval } from "@server/db";
|
||||||
import { eq, sql, and } from "drizzle-orm";
|
import { eq, sql, and, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -88,7 +88,7 @@ export async function countApprovals(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(approvals.orgId, orgId),
|
eq(approvals.orgId, orgId),
|
||||||
sql`${approvals.decision} in ${state}`
|
inArray(approvals.decision, state)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
type Approval
|
type Approval
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
@@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.strictObject({
|
||||||
limit: z
|
limit: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().nonnegative()),
|
cursorPending: z.coerce // pending cursor
|
||||||
offset: z
|
.number<string>()
|
||||||
.string()
|
.int()
|
||||||
|
.max(1) // 0 means non pending
|
||||||
|
.min(0) // 1 means pending
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(undefined),
|
||||||
.transform(Number)
|
cursorTimestamp: z.coerce
|
||||||
.pipe(z.int().nonnegative()),
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
approvalState: z
|
approvalState: z
|
||||||
.enum(["pending", "approved", "denied", "all"])
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -61,13 +69,21 @@ const querySchema = z.strictObject({
|
|||||||
.pipe(z.number().int().positive().optional())
|
.pipe(z.number().int().positive().optional())
|
||||||
});
|
});
|
||||||
|
|
||||||
async function queryApprovals(
|
async function queryApprovals({
|
||||||
orgId: string,
|
orgId,
|
||||||
limit: number,
|
limit,
|
||||||
offset: number,
|
approvalState,
|
||||||
approvalState: z.infer<typeof querySchema>["approvalState"],
|
cursorPending,
|
||||||
clientId?: number
|
cursorTimestamp,
|
||||||
) {
|
clientId
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
limit: number;
|
||||||
|
approvalState: z.infer<typeof querySchema>["approvalState"];
|
||||||
|
cursorPending?: number;
|
||||||
|
cursorTimestamp?: number;
|
||||||
|
clientId?: number;
|
||||||
|
}) {
|
||||||
let state: Array<Approval["decision"]> = [];
|
let state: Array<Approval["decision"]> = [];
|
||||||
switch (approvalState) {
|
switch (approvalState) {
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -83,6 +99,26 @@ async function queryApprovals(
|
|||||||
state = ["approved", "denied", "pending"];
|
state = ["approved", "denied", "pending"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
eq(approvals.orgId, orgId),
|
||||||
|
sql`${approvals.decision} in ${state}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
conditions.push(eq(approvals.clientId, clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
|
||||||
|
|
||||||
|
if (cursorPending != null && cursorTimestamp != null) {
|
||||||
|
// https://stackoverflow.com/a/79720298/10322846
|
||||||
|
// composite cursor, next data means (pending, timestamp) <= cursor
|
||||||
|
conditions.push(
|
||||||
|
lte(pendingSortKey, cursorPending),
|
||||||
|
lte(approvals.timestamp, cursorTimestamp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.select({
|
.select({
|
||||||
approvalId: approvals.approvalId,
|
approvalId: approvals.approvalId,
|
||||||
@@ -105,7 +141,8 @@ async function queryApprovals(
|
|||||||
fingerprintArch: currentFingerprint.arch,
|
fingerprintArch: currentFingerprint.arch,
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||||
fingerprintUsername: currentFingerprint.username,
|
fingerprintUsername: currentFingerprint.username,
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
fingerprintHostname: currentFingerprint.hostname,
|
||||||
|
timestamp: approvals.timestamp
|
||||||
})
|
})
|
||||||
.from(approvals)
|
.from(approvals)
|
||||||
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
||||||
@@ -118,22 +155,12 @@ async function queryApprovals(
|
|||||||
)
|
)
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||||
.where(
|
.where(and(...conditions))
|
||||||
and(
|
.orderBy(desc(pendingSortKey), desc(approvals.timestamp))
|
||||||
eq(approvals.orgId, orgId),
|
.limit(limit + 1); // the `+1` is used for the cursor
|
||||||
sql`${approvals.decision} in ${state}`,
|
|
||||||
...(clientId ? [eq(approvals.clientId, clientId)] : [])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(
|
|
||||||
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
|
|
||||||
desc(approvals.timestamp)
|
|
||||||
)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
// Process results to format device names and build fingerprint objects
|
// Process results to format device names and build fingerprint objects
|
||||||
return res.map((approval) => {
|
const approvalsList = res.slice(0, limit).map((approval) => {
|
||||||
const model = approval.deviceModel || null;
|
const model = approval.deviceModel || null;
|
||||||
const deviceName = approval.clientName
|
const deviceName = approval.clientName
|
||||||
? getUserDeviceName(model, approval.clientName)
|
? getUserDeviceName(model, approval.clientName)
|
||||||
@@ -152,15 +179,15 @@ async function queryApprovals(
|
|||||||
|
|
||||||
const fingerprint = hasFingerprintData
|
const fingerprint = hasFingerprintData
|
||||||
? {
|
? {
|
||||||
platform: approval.fingerprintPlatform || null,
|
platform: approval.fingerprintPlatform ?? null,
|
||||||
osVersion: approval.fingerprintOsVersion || null,
|
osVersion: approval.fingerprintOsVersion ?? null,
|
||||||
kernelVersion: approval.fingerprintKernelVersion || null,
|
kernelVersion: approval.fingerprintKernelVersion ?? null,
|
||||||
arch: approval.fingerprintArch || null,
|
arch: approval.fingerprintArch ?? null,
|
||||||
deviceModel: approval.deviceModel || null,
|
deviceModel: approval.deviceModel ?? null,
|
||||||
serialNumber: approval.fingerprintSerialNumber || null,
|
serialNumber: approval.fingerprintSerialNumber ?? null,
|
||||||
username: approval.fingerprintUsername || null,
|
username: approval.fingerprintUsername ?? null,
|
||||||
hostname: approval.fingerprintHostname || null
|
hostname: approval.fingerprintHostname ?? null
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -183,11 +210,30 @@ async function queryApprovals(
|
|||||||
niceId: approval.niceId || null
|
niceId: approval.niceId || null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
let nextCursorPending: number | null = null;
|
||||||
|
let nextCursorTimestamp: number | null = null;
|
||||||
|
if (res.length > limit) {
|
||||||
|
const lastItem = res[limit];
|
||||||
|
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
|
||||||
|
nextCursorTimestamp = lastItem.timestamp;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
approvalsList,
|
||||||
|
nextCursorPending,
|
||||||
|
nextCursorTimestamp
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListApprovalsResponse = {
|
export type ListApprovalsResponse = {
|
||||||
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>;
|
approvals: NonNullable<
|
||||||
pagination: { total: number; limit: number; offset: number };
|
Awaited<ReturnType<typeof queryApprovals>>
|
||||||
|
>["approvalsList"];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
cursorPending: number | null;
|
||||||
|
cursorTimestamp: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listApprovals(
|
export async function listApprovals(
|
||||||
@@ -215,17 +261,25 @@ export async function listApprovals(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, approvalState, clientId } = parsedQuery.data;
|
const {
|
||||||
|
limit,
|
||||||
|
cursorPending,
|
||||||
|
cursorTimestamp,
|
||||||
|
approvalState,
|
||||||
|
clientId
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
const approvalsList = await queryApprovals(
|
const { approvalsList, nextCursorPending, nextCursorTimestamp } =
|
||||||
orgId.toString(),
|
await queryApprovals({
|
||||||
limit,
|
orgId: orgId.toString(),
|
||||||
offset,
|
limit,
|
||||||
approvalState,
|
cursorPending,
|
||||||
clientId
|
cursorTimestamp,
|
||||||
);
|
approvalState,
|
||||||
|
clientId
|
||||||
|
});
|
||||||
|
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -237,7 +291,8 @@ export async function listApprovals(
|
|||||||
pagination: {
|
pagination: {
|
||||||
total: count,
|
total: count,
|
||||||
limit,
|
limit,
|
||||||
offset
|
cursorPending: nextCursorPending,
|
||||||
|
cursorTimestamp: nextCursorTimestamp
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -15,9 +15,119 @@ import { SubscriptionType } from "./hooks/getSubType";
|
|||||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { Tier } from "@server/types/Tiers";
|
import { Tier } from "@server/types/Tiers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
idp,
|
||||||
|
idpOrg,
|
||||||
|
loginPage,
|
||||||
|
loginPageBranding,
|
||||||
|
loginPageBrandingOrg,
|
||||||
|
loginPageOrg,
|
||||||
|
orgs,
|
||||||
|
resources,
|
||||||
|
roles
|
||||||
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maximum allowed retention days for a given tier
|
||||||
|
* Returns null for enterprise tier (unlimited)
|
||||||
|
*/
|
||||||
|
function getMaxRetentionDaysForTier(tier: Tier | null): number | null {
|
||||||
|
if (!tier) {
|
||||||
|
return 3; // Free tier
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tier) {
|
||||||
|
case "tier1":
|
||||||
|
return 7;
|
||||||
|
case "tier2":
|
||||||
|
return 30;
|
||||||
|
case "tier3":
|
||||||
|
return 90;
|
||||||
|
case "enterprise":
|
||||||
|
return null; // No limit
|
||||||
|
default:
|
||||||
|
return 3; // Default to free tier limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap retention days to the maximum allowed for the given tier
|
||||||
|
*/
|
||||||
|
async function capRetentionDays(
|
||||||
|
orgId: string,
|
||||||
|
tier: Tier | null
|
||||||
|
): Promise<void> {
|
||||||
|
const maxRetentionDays = getMaxRetentionDaysForTier(tier);
|
||||||
|
|
||||||
|
// If there's no limit (enterprise tier), no capping needed
|
||||||
|
if (maxRetentionDays === null) {
|
||||||
|
logger.debug(
|
||||||
|
`No retention day limit for org ${orgId} on tier ${tier || "free"}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current org settings
|
||||||
|
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
logger.warn(`Org ${orgId} not found when capping retention days`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<typeof orgs.$inferInsert> = {};
|
||||||
|
let needsUpdate = false;
|
||||||
|
|
||||||
|
// Cap request log retention if it exceeds the limit
|
||||||
|
if (
|
||||||
|
org.settingsLogRetentionDaysRequest !== null &&
|
||||||
|
org.settingsLogRetentionDaysRequest > maxRetentionDays
|
||||||
|
) {
|
||||||
|
updates.settingsLogRetentionDaysRequest = maxRetentionDays;
|
||||||
|
needsUpdate = true;
|
||||||
|
logger.info(
|
||||||
|
`Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap access log retention if it exceeds the limit
|
||||||
|
if (
|
||||||
|
org.settingsLogRetentionDaysAccess !== null &&
|
||||||
|
org.settingsLogRetentionDaysAccess > maxRetentionDays
|
||||||
|
) {
|
||||||
|
updates.settingsLogRetentionDaysAccess = maxRetentionDays;
|
||||||
|
needsUpdate = true;
|
||||||
|
logger.info(
|
||||||
|
`Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap action log retention if it exceeds the limit
|
||||||
|
if (
|
||||||
|
org.settingsLogRetentionDaysAction !== null &&
|
||||||
|
org.settingsLogRetentionDaysAction > maxRetentionDays
|
||||||
|
) {
|
||||||
|
updates.settingsLogRetentionDaysAction = maxRetentionDays;
|
||||||
|
needsUpdate = true;
|
||||||
|
logger.info(
|
||||||
|
`Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates if needed
|
||||||
|
if (needsUpdate) {
|
||||||
|
await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(`No retention day capping needed for org ${orgId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleTierChange(
|
export async function handleTierChange(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
newTier: SubscriptionType | null,
|
newTier: SubscriptionType | null,
|
||||||
@@ -27,6 +137,35 @@ export async function handleTierChange(
|
|||||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get all orgs that have this orgId as their billingOrgId
|
||||||
|
const associatedOrgs = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.billingOrgId, orgId));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Found ${associatedOrgs.length} org(s) associated with billing org ${orgId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loop over all associated orgs and apply tier changes
|
||||||
|
for (const org of associatedOrgs) {
|
||||||
|
await handleTierChangeForOrg(org.orgId, newTier, previousTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Completed tier change handling for all orgs associated with billing org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTierChangeForOrg(
|
||||||
|
orgId: string,
|
||||||
|
newTier: SubscriptionType | null,
|
||||||
|
previousTier?: SubscriptionType | null
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info(
|
||||||
|
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||||
|
);
|
||||||
|
|
||||||
// License subscriptions are handled separately and don't use the tier matrix
|
// License subscriptions are handled separately and don't use the tier matrix
|
||||||
if (newTier === "license") {
|
if (newTier === "license") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -40,6 +179,9 @@ export async function handleTierChange(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Org ${orgId} is reverting to free tier, disabling all paid features`
|
`Org ${orgId} is reverting to free tier, disabling all paid features`
|
||||||
);
|
);
|
||||||
|
// Cap retention days to free tier limits
|
||||||
|
await capRetentionDays(orgId, null);
|
||||||
|
|
||||||
// Disable all features in the tier matrix
|
// Disable all features in the tier matrix
|
||||||
for (const [featureKey] of Object.entries(tierMatrix)) {
|
for (const [featureKey] of Object.entries(tierMatrix)) {
|
||||||
const feature = featureKey as TierFeature;
|
const feature = featureKey as TierFeature;
|
||||||
@@ -57,6 +199,9 @@ export async function handleTierChange(
|
|||||||
// Get the tier (cast as Tier since we've ruled out "license" and null)
|
// Get the tier (cast as Tier since we've ruled out "license" and null)
|
||||||
const tier = newTier as Tier;
|
const tier = newTier as Tier;
|
||||||
|
|
||||||
|
// Cap retention days to the new tier's limits
|
||||||
|
await capRetentionDays(orgId, tier);
|
||||||
|
|
||||||
// Check each feature in the tier matrix
|
// Check each feature in the tier matrix
|
||||||
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
||||||
const feature = featureKey as TierFeature;
|
const feature = featureKey as TierFeature;
|
||||||
@@ -201,9 +346,7 @@ async function disableLoginPageDomain(orgId: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (existingLoginPage) {
|
if (existingLoginPage) {
|
||||||
await db
|
await db.delete(loginPageOrg).where(eq(loginPageOrg.orgId, orgId));
|
||||||
.delete(loginPageOrg)
|
|
||||||
.where(eq(loginPageOrg.orgId, orgId));
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(loginPage)
|
.delete(loginPage)
|
||||||
|
|||||||
@@ -112,11 +112,13 @@ export async function getOrgSubscriptionsData(
|
|||||||
throw new Error(`Not found`);
|
throw new Error(`Not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const billingOrgId = org[0].billingOrgId || org[0].orgId;
|
||||||
|
|
||||||
// Get customer for org
|
// Get customer for org
|
||||||
const customer = await db
|
const customer = await db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(eq(customers.orgId, orgId))
|
.where(eq(customers.orgId, billingOrgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const subscriptionsWithItems: Array<{
|
const subscriptionsWithItems: Array<{
|
||||||
|
|||||||
@@ -85,10 +85,14 @@ export async function getOrgUsage(
|
|||||||
orgId,
|
orgId,
|
||||||
FeatureId.REMOTE_EXIT_NODES
|
FeatureId.REMOTE_EXIT_NODES
|
||||||
);
|
);
|
||||||
const egressData = await usageService.getUsage(
|
const organizations = await usageService.getUsage(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.EGRESS_DATA_MB
|
FeatureId.ORGINIZATIONS
|
||||||
);
|
);
|
||||||
|
// const egressData = await usageService.getUsage(
|
||||||
|
// orgId,
|
||||||
|
// FeatureId.EGRESS_DATA_MB
|
||||||
|
// );
|
||||||
|
|
||||||
if (sites) {
|
if (sites) {
|
||||||
usageData.push(sites);
|
usageData.push(sites);
|
||||||
@@ -96,15 +100,18 @@ export async function getOrgUsage(
|
|||||||
if (users) {
|
if (users) {
|
||||||
usageData.push(users);
|
usageData.push(users);
|
||||||
}
|
}
|
||||||
if (egressData) {
|
// if (egressData) {
|
||||||
usageData.push(egressData);
|
// usageData.push(egressData);
|
||||||
}
|
// }
|
||||||
if (domains) {
|
if (domains) {
|
||||||
usageData.push(domains);
|
usageData.push(domains);
|
||||||
}
|
}
|
||||||
if (remoteExitNodes) {
|
if (remoteExitNodes) {
|
||||||
usageData.push(remoteExitNodes);
|
usageData.push(remoteExitNodes);
|
||||||
}
|
}
|
||||||
|
if (organizations) {
|
||||||
|
usageData.push(organizations);
|
||||||
|
}
|
||||||
|
|
||||||
const orgLimits = await db
|
const orgLimits = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs";
|
|||||||
import * as misc from "#private/routers/misc";
|
import * as misc from "#private/routers/misc";
|
||||||
import * as reKey from "#private/routers/re-key";
|
import * as reKey from "#private/routers/re-key";
|
||||||
import * as approval from "#private/routers/approvals";
|
import * as approval from "#private/routers/approvals";
|
||||||
|
import * as ssh from "#private/routers/ssh";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -506,3 +507,14 @@ authenticated.put(
|
|||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateExitNodeSecret
|
reKey.reGenerateExitNodeSecret
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/ssh/sign-key",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.sshPam),
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
// verifyUserHasAction(ActionsEnum.signSshKey),
|
||||||
|
logActionAudit(ActionsEnum.signSshKey),
|
||||||
|
ssh.signSshKey
|
||||||
|
);
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(
|
||||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense(
|
|||||||
|
|
||||||
const licenseData = req.body;
|
const licenseData = req.body;
|
||||||
|
|
||||||
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
if (
|
||||||
|
licenseData.tier != "big_license" &&
|
||||||
|
licenseData.tier != "small_license"
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
apiResponse.status || HttpCode.BAD_REQUEST,
|
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||||
apiResponse.message || "Failed to create license from Fossorial API"
|
apiResponse.message ||
|
||||||
|
"Failed to create license from Fossorial API"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,8 +117,11 @@ export async function generateNewEnterpriseLicense(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
const tier =
|
||||||
const tierPrice = getLicensePriceSet()[tier]
|
licenseData.tier === "big_license"
|
||||||
|
? LicenseId.BIG_LICENSE
|
||||||
|
: LicenseId.SMALL_LICENSE;
|
||||||
|
const tierPrice = getLicensePriceSet()[tier];
|
||||||
|
|
||||||
const session = await stripe!.checkout.sessions.create({
|
const session = await stripe!.checkout.sessions.create({
|
||||||
client_reference_id: keyId.toString(),
|
client_reference_id: keyId.toString(),
|
||||||
@@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense(
|
|||||||
{
|
{
|
||||||
price: tierPrice, // Use the standard tier
|
price: tierPrice, // Use the standard tier
|
||||||
quantity: 1
|
quantity: 1
|
||||||
},
|
}
|
||||||
], // Start with the standard feature set that matches the free limits
|
], // Start with the standard feature set that matches the free limits
|
||||||
customer: customer.customerId,
|
customer: customer.customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -37,14 +38,36 @@ const bodySchema = z.strictObject({
|
|||||||
.union([
|
.union([
|
||||||
z.literal(""),
|
z.literal(""),
|
||||||
z
|
z
|
||||||
.url("Must be a valid URL")
|
.string()
|
||||||
.superRefine(async (url, ctx) => {
|
.superRefine(async (urlOrPath, ctx) => {
|
||||||
|
const parseResult = z.url().safeParse(urlOrPath);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (build !== "enterprise") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a valid URL"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
validateLocalPath(urlOrPath);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(urlOrPath, {
|
||||||
method: "HEAD"
|
method: "HEAD"
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
return fetch(url, { method: "GET" });
|
return fetch(urlOrPath, { method: "GET" });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import config from "@server/lib/config";
|
|||||||
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||||
|
|
||||||
@@ -92,6 +94,18 @@ export async function createOrgOidcIdp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||||
|
"org"
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
@@ -109,12 +123,14 @@ export async function createOrgOidcIdp(
|
|||||||
|
|
||||||
let { autoProvision } = parsedBody.data;
|
let { autoProvision } = parsedBody.data;
|
||||||
|
|
||||||
const subscribed = await isSubscribed(
|
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
|
||||||
orgId,
|
const subscribed = await isSubscribed(
|
||||||
tierMatrix.deviceApprovals
|
orgId,
|
||||||
);
|
tierMatrix.deviceApprovals
|
||||||
if (!subscribed) {
|
);
|
||||||
autoProvision = false;
|
if (!subscribed) {
|
||||||
|
autoProvision = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { idp, idpOidcConfig, idpOrg } from "@server/db";
|
import { idp, idpOidcConfig, idpOrg } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -59,6 +60,18 @@ export async function deleteOrgIdp(
|
|||||||
|
|
||||||
const { idpId } = parsedParams.data;
|
const { idpId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||||
|
"org"
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if IDP exists
|
// Check if IDP exists
|
||||||
const [existingIdp] = await db
|
const [existingIdp] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { encrypt } from "@server/lib/crypto";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -97,6 +99,18 @@ export async function updateOrgOidcIdp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||||
|
"org"
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
clientId,
|
clientId,
|
||||||
@@ -114,12 +128,15 @@ export async function updateOrgOidcIdp(
|
|||||||
|
|
||||||
let { autoProvision } = parsedBody.data;
|
let { autoProvision } = parsedBody.data;
|
||||||
|
|
||||||
const subscribed = await isSubscribed(
|
if (build == "saas") {
|
||||||
orgId,
|
// this is not paywalled with a ee license because this whole endpoint is restricted
|
||||||
tierMatrix.deviceApprovals
|
const subscribed = await isSubscribed(
|
||||||
);
|
orgId,
|
||||||
if (!subscribed) {
|
tierMatrix.deviceApprovals
|
||||||
autoProvision = false;
|
);
|
||||||
|
if (!subscribed) {
|
||||||
|
autoProvision = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
|
|||||||
@@ -12,7 +12,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
exitNodeOrgs,
|
||||||
|
ExitNode,
|
||||||
|
ExitNodeOrg,
|
||||||
|
orgs
|
||||||
|
} from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { remoteExitNodes } from "@server/db";
|
import { remoteExitNodes } from "@server/db";
|
||||||
@@ -25,7 +32,7 @@ import { createRemoteExitNodeSession } from "#private/auth/sessions/remoteExitNo
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { hashPassword, verifyPassword } from "@server/auth/password";
|
import { hashPassword, verifyPassword } from "@server/auth/password";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||||
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
@@ -169,7 +176,17 @@ export async function createRemoteExitNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
|
const [org] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (!existingExitNode) {
|
if (!existingExitNode) {
|
||||||
@@ -217,19 +234,43 @@ export async function createRemoteExitNode(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
numExitNodeOrgs = await trx
|
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
||||||
.select()
|
if (org.billingOrgId) {
|
||||||
.from(exitNodeOrgs)
|
const otherBillingOrgs = await trx
|
||||||
.where(eq(exitNodeOrgs.orgId, orgId));
|
.select()
|
||||||
});
|
.from(orgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgs.billingOrgId, org.billingOrgId),
|
||||||
|
ne(orgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (numExitNodeOrgs) {
|
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||||
await usageService.updateCount(
|
|
||||||
orgId,
|
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
.select()
|
||||||
numExitNodeOrgs.length
|
.from(exitNodeOrgs)
|
||||||
);
|
.where(
|
||||||
}
|
and(
|
||||||
|
eq(
|
||||||
|
exitNodeOrgs.exitNodeId,
|
||||||
|
existingExitNode.exitNodeId
|
||||||
|
),
|
||||||
|
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||||
|
await usageService.add(
|
||||||
|
orgId,
|
||||||
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
|
1,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db";
|
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes, orgs } from "@server/db";
|
||||||
import { remoteExitNodes } from "@server/db";
|
import { remoteExitNodes } from "@server/db";
|
||||||
import { and, count, eq } from "drizzle-orm";
|
import { and, count, eq, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -50,7 +50,8 @@ export async function deleteRemoteExitNode(
|
|||||||
const [remoteExitNode] = await db
|
const [remoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(remoteExitNodes)
|
.from(remoteExitNodes)
|
||||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
|
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!remoteExitNode) {
|
if (!remoteExitNode) {
|
||||||
return next(
|
return next(
|
||||||
@@ -70,7 +71,17 @@ export async function deleteRemoteExitNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
|
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Org with ID ${orgId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(exitNodeOrgs)
|
.delete(exitNodeOrgs)
|
||||||
@@ -81,38 +92,39 @@ export async function deleteRemoteExitNode(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [remainingExitNodeOrgs] = await trx
|
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
|
||||||
.select({ count: count() })
|
if (org.billingOrgId) {
|
||||||
.from(exitNodeOrgs)
|
const otherBillingOrgs = await trx
|
||||||
.where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!));
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||||
|
|
||||||
if (remainingExitNodeOrgs.count === 0) {
|
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||||
await trx
|
|
||||||
.delete(remoteExitNodes)
|
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||||
|
.select()
|
||||||
|
.from(exitNodeOrgs)
|
||||||
.where(
|
.where(
|
||||||
eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)
|
and(
|
||||||
|
eq(
|
||||||
|
exitNodeOrgs.exitNodeId,
|
||||||
|
remoteExitNode.exitNodeId!
|
||||||
|
),
|
||||||
|
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
await trx
|
|
||||||
.delete(exitNodes)
|
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||||
.where(
|
await usageService.add(
|
||||||
eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!)
|
orgId,
|
||||||
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
|
-1,
|
||||||
|
trx
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
numExitNodeOrgs = await trx
|
|
||||||
.select()
|
|
||||||
.from(exitNodeOrgs)
|
|
||||||
.where(eq(exitNodeOrgs.orgId, orgId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (numExitNodeOrgs) {
|
|
||||||
await usageService.updateCount(
|
|
||||||
orgId,
|
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
|
||||||
numExitNodeOrgs.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
14
server/private/routers/ssh/index.ts
Normal file
14
server/private/routers/ssh/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./signSshKey";
|
||||||
403
server/private/routers/ssh/signSshKey.ts
Normal file
403
server/private/routers/ssh/signSshKey.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq, or, and } from "drizzle-orm";
|
||||||
|
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||||
|
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.strictObject({
|
||||||
|
publicKey: z.string().nonempty(),
|
||||||
|
resourceId: z.number().int().positive().optional(),
|
||||||
|
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const fields = [data.resourceId, data.resource];
|
||||||
|
const definedFields = fields.filter((field) => field !== undefined);
|
||||||
|
return definedFields.length === 1;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Exactly one of resourceId, niceId, or alias must be provided"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SignSshKeyResponse = {
|
||||||
|
certificate: string;
|
||||||
|
messageId: number;
|
||||||
|
sshUsername: string;
|
||||||
|
sshHost: string;
|
||||||
|
resourceId: number;
|
||||||
|
keyId: string;
|
||||||
|
validPrincipals: string[];
|
||||||
|
validAfter: string;
|
||||||
|
validBefore: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// registry.registerPath({
|
||||||
|
// method: "post",
|
||||||
|
// path: "/org/{orgId}/ssh/sign-key",
|
||||||
|
// description: "Sign an SSH public key for access to a resource.",
|
||||||
|
// tags: [OpenAPITags.Org, OpenAPITags.Ssh],
|
||||||
|
// request: {
|
||||||
|
// params: paramsSchema,
|
||||||
|
// body: {
|
||||||
|
// content: {
|
||||||
|
// "application/json": {
|
||||||
|
// schema: bodySchema
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// responses: {}
|
||||||
|
// });
|
||||||
|
|
||||||
|
export async function signSshKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const {
|
||||||
|
publicKey,
|
||||||
|
resourceId,
|
||||||
|
resource: resourceQueryString
|
||||||
|
} = parsedBody.data;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const roleId = req.userOrgRoleId!;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not belong to the specified organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let usernameToUse;
|
||||||
|
if (!userOrg.pamUsername) {
|
||||||
|
if (req.user?.email) {
|
||||||
|
// Extract username from email (first part before @)
|
||||||
|
usernameToUse = req.user?.email.split("@")[0];
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Unable to extract username from email"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (req.user?.username) {
|
||||||
|
usernameToUse = req.user.username;
|
||||||
|
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||||
|
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Username is not valid for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User does not have a valid email or username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if we have a existing user in this org with the same
|
||||||
|
const [existingUserWithSameName] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, usernameToUse)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUserWithSameName) {
|
||||||
|
let foundUniqueUsername = false;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||||
|
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||||
|
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, candidateUsername)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
usernameToUse = candidateUsername;
|
||||||
|
foundUniqueUsername = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundUniqueUsername) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Unable to generate a unique username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
usernameToUse = userOrg.pamUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and decrypt the org's CA keys
|
||||||
|
const caKeys = await getOrgCAKeys(
|
||||||
|
orgId,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!caKeys) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"SSH CA not configured for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the resource exists and belongs to the org
|
||||||
|
// Build the where clause dynamically based on which field is provided
|
||||||
|
let whereClause;
|
||||||
|
if (resourceId !== undefined) {
|
||||||
|
whereClause = eq(siteResources.siteResourceId, resourceId);
|
||||||
|
} else if (resourceQueryString !== undefined) {
|
||||||
|
whereClause = or(
|
||||||
|
eq(siteResources.niceId, resourceQueryString),
|
||||||
|
eq(siteResources.alias, resourceQueryString)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// This should never happen due to the schema validation, but TypeScript doesn't know that
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"One of resourceId, niceId, or alias must be provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(and(whereClause, eq(siteResources.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Resource not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.length > 1) {
|
||||||
|
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Multiple resources found matching the criteria`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resources[0];
|
||||||
|
|
||||||
|
if (resource.orgId !== orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Resource does not belong to the specified organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to the resource
|
||||||
|
const hasAccess = await canUserAccessSiteResource({
|
||||||
|
userId: userId,
|
||||||
|
resourceId: resource.siteResourceId,
|
||||||
|
roleId: roleId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the site
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, resource.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Site associated with resource not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the public key
|
||||||
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
|
// only valid for 5 minutes
|
||||||
|
const validFor = 300n;
|
||||||
|
|
||||||
|
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||||
|
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||||
|
validPrincipals: [usernameToUse, resource.niceId],
|
||||||
|
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||||
|
validBefore: now + validFor
|
||||||
|
});
|
||||||
|
|
||||||
|
const [message] = await db
|
||||||
|
.insert(roundTripMessageTracker)
|
||||||
|
.values({
|
||||||
|
wsClientId: newt.newtId,
|
||||||
|
messageType: `newt/pam/connection`,
|
||||||
|
sentAt: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create message tracker entry"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendToClient(newt.newtId, {
|
||||||
|
type: `newt/pam/connection`,
|
||||||
|
data: {
|
||||||
|
messageId: message.messageId,
|
||||||
|
orgId: orgId,
|
||||||
|
agentPort: 22123,
|
||||||
|
agentHost: resource.destination,
|
||||||
|
caCert: caKeys.publicKeyOpenSSH,
|
||||||
|
username: usernameToUse,
|
||||||
|
niceId: resource.niceId,
|
||||||
|
metadata: {
|
||||||
|
sudo: true, // we are hardcoding these for now but should make configurable from the role or something
|
||||||
|
homedir: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresIn = Number(validFor); // seconds
|
||||||
|
|
||||||
|
let sshHost;
|
||||||
|
if (resource.alias && resource.alias != "") {
|
||||||
|
sshHost = resource.alias;
|
||||||
|
} else {
|
||||||
|
sshHost = resource.destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<SignSshKeyResponse>(res, {
|
||||||
|
data: {
|
||||||
|
certificate: cert.certificate,
|
||||||
|
messageId: message.messageId,
|
||||||
|
sshUsername: usernameToUse,
|
||||||
|
sshHost: sshHost,
|
||||||
|
resourceId: resource.siteResourceId,
|
||||||
|
keyId: cert.keyId,
|
||||||
|
validPrincipals: cert.validPrincipals,
|
||||||
|
validAfter: cert.validAfter.toISOString(),
|
||||||
|
validBefore: cert.validBefore.toISOString(),
|
||||||
|
expiresIn
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "SSH key signed successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error signing SSH key:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred while signing the SSH key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
server/routers/auth/deleteMyAccount.ts
Normal file
236
server/routers/auth/deleteMyAccount.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, orgs, userOrgs, users } from "@server/db";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import {
|
||||||
|
invalidateSession,
|
||||||
|
createBlankSessionTokenCookie
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
|
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||||
|
|
||||||
|
const deleteMyAccountBody = z.strictObject({
|
||||||
|
password: z.string().optional(),
|
||||||
|
code: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeleteMyAccountPreviewResponse = {
|
||||||
|
preview: true;
|
||||||
|
orgs: { orgId: string; name: string }[];
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteMyAccountCodeRequestedResponse = {
|
||||||
|
codeRequested: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteMyAccountSuccessResponse = {
|
||||||
|
success: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function deleteMyAccount(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const { user, session } = await verifySession(req);
|
||||||
|
if (!user || !session) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.serverAdmin) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Server admins cannot delete their account this way"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Account deletion with password is only supported for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = deleteMyAccountBody.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsed.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { password, code } = parsed.data;
|
||||||
|
|
||||||
|
const userId = user.userId;
|
||||||
|
|
||||||
|
const ownedOrgsRows = await db
|
||||||
|
.select({
|
||||||
|
orgId: userOrgs.orgId,
|
||||||
|
isOwner: userOrgs.isOwner,
|
||||||
|
isBillingOrg: orgs.isBillingOrg
|
||||||
|
})
|
||||||
|
.from(userOrgs)
|
||||||
|
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
|
||||||
|
.where(
|
||||||
|
and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true))
|
||||||
|
);
|
||||||
|
|
||||||
|
const orgIds = ownedOrgsRows.map((r) => r.orgId);
|
||||||
|
|
||||||
|
if (build === "saas" && orgIds.length > 0) {
|
||||||
|
const primaryOrgId = ownedOrgsRows.find(
|
||||||
|
(r) => r.isBillingOrg && r.isOwner
|
||||||
|
)?.orgId;
|
||||||
|
if (primaryOrgId) {
|
||||||
|
const { tier, active } = await getOrgTierData(primaryOrgId);
|
||||||
|
if (active && tier) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"You must cancel your subscription before deleting your account"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
const orgsWithNames =
|
||||||
|
orgIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
orgId: orgs.orgId,
|
||||||
|
name: orgs.name
|
||||||
|
})
|
||||||
|
.from(orgs)
|
||||||
|
.where(inArray(orgs.orgId, orgIds))
|
||||||
|
: [];
|
||||||
|
return response<DeleteMyAccountPreviewResponse>(res, {
|
||||||
|
data: {
|
||||||
|
preview: true,
|
||||||
|
orgs: orgsWithNames.map((o) => ({
|
||||||
|
orgId: o.orgId,
|
||||||
|
name: o.name ?? ""
|
||||||
|
})),
|
||||||
|
twoFactorEnabled: user.twoFactorEnabled ?? false
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Preview",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await verifyPassword(
|
||||||
|
password,
|
||||||
|
user.passwordHash!
|
||||||
|
);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid password")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
if (!code) {
|
||||||
|
return response<DeleteMyAccountCodeRequestedResponse>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor code required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const validOTP = await verifyTotpCode(
|
||||||
|
code,
|
||||||
|
user.twoFactorSecret!,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
if (!validOTP) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"The two-factor code you entered is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDeletedNewtIds: string[] = [];
|
||||||
|
const allOlmsToTerminate: string[] = [];
|
||||||
|
|
||||||
|
for (const row of ownedOrgsRows) {
|
||||||
|
try {
|
||||||
|
const result = await deleteOrgById(row.orgId);
|
||||||
|
allDeletedNewtIds.push(...result.deletedNewtIds);
|
||||||
|
allOlmsToTerminate.push(...result.olmsToTerminate);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to delete org ${row.orgId} during account deletion`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to delete organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTerminationMessages({
|
||||||
|
deletedNewtIds: allDeletedNewtIds,
|
||||||
|
olmsToTerminate: allOlmsToTerminate
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.delete(users).where(eq(users.userId, userId));
|
||||||
|
await calculateUserClientsForOrgs(userId, trx);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invalidateSession(session.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to invalidate session after account deletion",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecure = req.protocol === "https";
|
||||||
|
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||||
|
|
||||||
|
return response<DeleteMyAccountSuccessResponse>(res, {
|
||||||
|
data: { success: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Account deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,4 +17,5 @@ export * from "./securityKey";
|
|||||||
export * from "./startDeviceWebAuth";
|
export * from "./startDeviceWebAuth";
|
||||||
export * from "./verifyDeviceWebAuth";
|
export * from "./verifyDeviceWebAuth";
|
||||||
export * from "./pollDeviceWebAuth";
|
export * from "./pollDeviceWebAuth";
|
||||||
export * from "./lookupUser";
|
export * from "./lookupUser";
|
||||||
|
export * from "./deleteMyAccount";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db, users } from "@server/db";
|
import { db, users } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { z } from "zod";
|
import { email, z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -21,7 +21,6 @@ import { hashPassword } from "@server/auth/password";
|
|||||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
||||||
|
|
||||||
@@ -31,7 +30,8 @@ export const signupBodySchema = z.object({
|
|||||||
inviteToken: z.string().optional(),
|
inviteToken: z.string().optional(),
|
||||||
inviteId: z.string().optional(),
|
inviteId: z.string().optional(),
|
||||||
termsAcceptedTimestamp: z.string().nullable().optional(),
|
termsAcceptedTimestamp: z.string().nullable().optional(),
|
||||||
marketingEmailConsent: z.boolean().optional()
|
marketingEmailConsent: z.boolean().optional(),
|
||||||
|
skipVerificationEmail: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
||||||
@@ -62,7 +62,8 @@ export async function signup(
|
|||||||
inviteToken,
|
inviteToken,
|
||||||
inviteId,
|
inviteId,
|
||||||
termsAcceptedTimestamp,
|
termsAcceptedTimestamp,
|
||||||
marketingEmailConsent
|
marketingEmailConsent,
|
||||||
|
skipVerificationEmail
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
@@ -198,26 +199,6 @@ export async function signup(
|
|||||||
// orgId: null,
|
// orgId: null,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
if (build == "saas") {
|
|
||||||
const { success, error, org } = await createUserAccountOrg(
|
|
||||||
userId,
|
|
||||||
email
|
|
||||||
);
|
|
||||||
if (!success) {
|
|
||||||
if (error) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to create user account and organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const sess = await createSession(token, userId);
|
const sess = await createSession(token, userId);
|
||||||
const isSecure = req.protocol === "https";
|
const isSecure = req.protocol === "https";
|
||||||
@@ -235,7 +216,13 @@ export async function signup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
sendEmailVerificationCode(email, userId);
|
if (!skipVerificationEmail) {
|
||||||
|
sendEmailVerificationCode(email, userId);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`User ${email} opted out of verification email during signup.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<SignUpResponse>(res, {
|
return response<SignUpResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
@@ -243,7 +230,9 @@ export async function signup(
|
|||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: `User created successfully. We sent an email to ${email} with a verification code.`,
|
message: skipVerificationEmail
|
||||||
|
? "User created successfully. Please verify your email."
|
||||||
|
: `User created successfully. We sent an email to ${email} with a verification code.`,
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ async function notAllowed(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const subscribed = await isSubscribed(
|
const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature
|
||||||
orgId,
|
orgId,
|
||||||
tierMatrix.loginPageDomain
|
tierMatrix.loginPageDomain
|
||||||
);
|
);
|
||||||
@@ -854,7 +854,7 @@ async function headerAuthChallenged(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
|
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature
|
||||||
if (subscribed) {
|
if (subscribed) {
|
||||||
loginPage = await getOrgLoginPage(orgId);
|
loginPage = await getOrgLoginPage(orgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { generateId } from "@server/auth/sessions/app";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
import { getUniqueClientName } from "@server/db/names";
|
import { getUniqueClientName } from "@server/db/names";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const createClientParamsSchema = z.strictObject({
|
const createClientParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -195,6 +196,12 @@ export async function createClient(
|
|||||||
const randomExitNode =
|
const randomExitNode =
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||||
|
|
||||||
|
if (!randomExitNode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
|
|||||||
export * from "./blockClient";
|
export * from "./blockClient";
|
||||||
export * from "./unblockClient";
|
export * from "./unblockClient";
|
||||||
export * from "./listClients";
|
export * from "./listClients";
|
||||||
|
export * from "./listUserDevices";
|
||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { db, olms, users } from "@server/db";
|
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
currentFingerprint,
|
||||||
|
db,
|
||||||
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
clientSitesAssociationsCache,
|
users
|
||||||
currentFingerprint
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
count,
|
asc,
|
||||||
|
desc,
|
||||||
eq,
|
eq,
|
||||||
inArray,
|
inArray,
|
||||||
isNotNull,
|
|
||||||
isNull,
|
isNull,
|
||||||
|
like,
|
||||||
or,
|
or,
|
||||||
sql
|
sql,
|
||||||
|
type SQL
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
@@ -89,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listClientsSchema = z.object({
|
const listClientsSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().positive()),
|
.openapi({
|
||||||
offset: z
|
type: "integer",
|
||||||
.string()
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative()),
|
.openapi({
|
||||||
filter: z.enum(["user", "machine"]).optional()
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["megabytesIn", "megabytesOut"],
|
||||||
|
description: "Field to sort by"
|
||||||
|
}),
|
||||||
|
order: z
|
||||||
|
.enum(["asc", "desc"])
|
||||||
|
.optional()
|
||||||
|
.default("asc")
|
||||||
|
.catch("asc")
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
default: "asc",
|
||||||
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter by online status"
|
||||||
|
}),
|
||||||
|
status: z.preprocess(
|
||||||
|
(val: string | undefined) => {
|
||||||
|
if (val) {
|
||||||
|
return val.split(","); // the search query array is an array joined by commas
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.array(z.enum(["active", "blocked", "archived"]))
|
||||||
|
.optional()
|
||||||
|
.default(["active"])
|
||||||
|
.catch(["active"])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["active", "blocked", "archived"]
|
||||||
|
},
|
||||||
|
default: ["active"],
|
||||||
|
description:
|
||||||
|
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||||
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryClients(
|
function queryClientsBase() {
|
||||||
orgId: string,
|
|
||||||
accessibleClientIds: number[],
|
|
||||||
filter?: "user" | "machine"
|
|
||||||
) {
|
|
||||||
const conditions = [
|
|
||||||
inArray(clients.clientId, accessibleClientIds),
|
|
||||||
eq(clients.orgId, orgId)
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add filter condition based on filter type
|
|
||||||
if (filter === "user") {
|
|
||||||
conditions.push(isNotNull(clients.userId));
|
|
||||||
} else if (filter === "machine") {
|
|
||||||
conditions.push(isNull(clients.userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.clientId,
|
clientId: clients.clientId,
|
||||||
@@ -142,22 +194,13 @@ function queryClients(
|
|||||||
approvalState: clients.approvalState,
|
approvalState: clients.approvalState,
|
||||||
olmArchived: olms.archived,
|
olmArchived: olms.archived,
|
||||||
archived: clients.archived,
|
archived: clients.archived,
|
||||||
blocked: clients.blocked,
|
blocked: clients.blocked
|
||||||
deviceModel: currentFingerprint.deviceModel,
|
|
||||||
fingerprintPlatform: currentFingerprint.platform,
|
|
||||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
|
||||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
|
||||||
fingerprintArch: currentFingerprint.arch,
|
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
|
||||||
fingerprintUsername: currentFingerprint.username,
|
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(users, eq(clients.userId, users.userId))
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||||
.where(and(...conditions));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSiteAssociations(clientIds: number[]) {
|
async function getSiteAssociations(clientIds: number[]) {
|
||||||
@@ -175,7 +218,7 @@ async function getSiteAssociations(clientIds: number[]) {
|
|||||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||||
sites: Array<{
|
sites: Array<{
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName: string | null;
|
siteName: string | null;
|
||||||
@@ -186,10 +229,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
|||||||
|
|
||||||
type OlmWithUpdateAvailable = ClientWithSites;
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
|
|
||||||
export type ListClientsResponse = {
|
export type ListClientsResponse = PaginatedResponse<{
|
||||||
clients: Array<ClientWithSites>;
|
clients: Array<ClientWithSites>;
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -218,7 +260,8 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, filter } = parsedQuery.data;
|
const { page, pageSize, online, query, status, sort_by, order } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -267,28 +310,73 @@ export async function listClients(
|
|||||||
const accessibleClientIds = accessibleClients.map(
|
const accessibleClientIds = accessibleClients.map(
|
||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
|
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const countConditions = [
|
const conditions = [
|
||||||
inArray(clients.clientId, accessibleClientIds),
|
and(
|
||||||
eq(clients.orgId, orgId)
|
inArray(clients.clientId, accessibleClientIds),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNull(clients.userId)
|
||||||
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (filter === "user") {
|
if (typeof online !== "undefined") {
|
||||||
countConditions.push(isNotNull(clients.userId));
|
conditions.push(eq(clients.online, online));
|
||||||
} else if (filter === "machine") {
|
|
||||||
countConditions.push(isNull(clients.userId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const countQuery = db
|
if (status.length > 0) {
|
||||||
.select({ count: count() })
|
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||||
.from(clients)
|
|
||||||
.where(and(...countConditions));
|
|
||||||
|
|
||||||
const clientsList = await baseQuery.limit(limit).offset(offset);
|
if (status.includes("active")) {
|
||||||
const totalCountResult = await countQuery;
|
filterAggregates.push(
|
||||||
const totalCount = totalCountResult[0].count;
|
and(eq(clients.archived, false), eq(clients.blocked, false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.includes("archived")) {
|
||||||
|
filterAggregates.push(eq(clients.archived, true));
|
||||||
|
}
|
||||||
|
if (status.includes("blocked")) {
|
||||||
|
filterAggregates.push(eq(clients.blocked, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...filterAggregates));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${clients.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${clients.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
|
|
||||||
|
const listMachinesQuery = baseQuery
|
||||||
|
.limit(page)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(clients[sort_by])
|
||||||
|
: desc(clients[sort_by])
|
||||||
|
: asc(clients.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [clientsList, totalCount] = await Promise.all([
|
||||||
|
listMachinesQuery,
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
// Get associated sites for all clients
|
// Get associated sites for all clients
|
||||||
const clientIds = clientsList.map((client) => client.clientId);
|
const clientIds = clientsList.map((client) => client.clientId);
|
||||||
@@ -319,14 +407,8 @@ export async function listClients(
|
|||||||
|
|
||||||
// Merge clients with their site associations and replace name with device name
|
// Merge clients with their site associations and replace name with device name
|
||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
const model = client.deviceModel || null;
|
|
||||||
let newName = client.name;
|
|
||||||
if (filter === "user") {
|
|
||||||
newName = getUserDeviceName(model, client.name);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
name: newName,
|
|
||||||
sites: sitesByClient[client.clientId] || []
|
sites: sitesByClient[client.clientId] || []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -371,8 +453,8 @@ export async function listClients(
|
|||||||
clients: olmsWithUpdates,
|
clients: olmsWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
page,
|
||||||
offset
|
pageSize
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
500
server/routers/client/listUserDevices.ts
Normal file
500
server/routers/client/listUserDevices.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
import { build } from "@server/build";
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
currentFingerprint,
|
||||||
|
db,
|
||||||
|
olms,
|
||||||
|
orgs,
|
||||||
|
roleClients,
|
||||||
|
userClients,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
like,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
type SQL
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import semver from "semver";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
|
async function getLatestOlmVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
|
||||||
|
if (cachedVersion) {
|
||||||
|
return cachedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.github.com/repos/fosrl/olm/tags",
|
||||||
|
{
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = await response.json();
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
logger.warn("No tags found for Olm repository");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
|
olmVersionCache.set("latestOlmVersion", latestVersion);
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
|
||||||
|
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
|
logger.warn("Connection timeout while fetching latest Olm version");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Error fetching latest Olm version:",
|
||||||
|
error.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUserDevicesParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listUserDevicesSchema = z.object({
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(20)
|
||||||
|
.default(20)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.catch(1)
|
||||||
|
.default(1)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["megabytesIn", "megabytesOut"],
|
||||||
|
description: "Field to sort by"
|
||||||
|
}),
|
||||||
|
order: z
|
||||||
|
.enum(["asc", "desc"])
|
||||||
|
.optional()
|
||||||
|
.default("asc")
|
||||||
|
.catch("asc")
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
default: "asc",
|
||||||
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter by online status"
|
||||||
|
}),
|
||||||
|
agent: z
|
||||||
|
.enum([
|
||||||
|
"windows",
|
||||||
|
"android",
|
||||||
|
"cli",
|
||||||
|
"olm",
|
||||||
|
"macos",
|
||||||
|
"ios",
|
||||||
|
"ipados",
|
||||||
|
"unknown"
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: [
|
||||||
|
"windows",
|
||||||
|
"android",
|
||||||
|
"cli",
|
||||||
|
"olm",
|
||||||
|
"macos",
|
||||||
|
"ios",
|
||||||
|
"ipados",
|
||||||
|
"unknown"
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
|
||||||
|
}),
|
||||||
|
status: z.preprocess(
|
||||||
|
(val: string | undefined) => {
|
||||||
|
if (val) {
|
||||||
|
return val.split(","); // the search query array is an array joined by commas
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.array(
|
||||||
|
z.enum(["active", "pending", "denied", "blocked", "archived"])
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default(["active", "pending"])
|
||||||
|
.catch(["active", "pending"])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["active", "pending", "denied", "blocked", "archived"]
|
||||||
|
},
|
||||||
|
default: ["active", "pending"],
|
||||||
|
description:
|
||||||
|
"Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled."
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryUserDevicesBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
orgId: clients.orgId,
|
||||||
|
name: clients.name,
|
||||||
|
pubKey: clients.pubKey,
|
||||||
|
subnet: clients.subnet,
|
||||||
|
megabytesIn: clients.megabytesIn,
|
||||||
|
megabytesOut: clients.megabytesOut,
|
||||||
|
orgName: orgs.name,
|
||||||
|
type: clients.type,
|
||||||
|
online: clients.online,
|
||||||
|
olmVersion: olms.version,
|
||||||
|
userId: clients.userId,
|
||||||
|
username: users.username,
|
||||||
|
userEmail: users.email,
|
||||||
|
niceId: clients.niceId,
|
||||||
|
agent: olms.agent,
|
||||||
|
approvalState: clients.approvalState,
|
||||||
|
olmArchived: olms.archived,
|
||||||
|
archived: clients.archived,
|
||||||
|
blocked: clients.blocked,
|
||||||
|
deviceModel: currentFingerprint.deviceModel,
|
||||||
|
fingerprintPlatform: currentFingerprint.platform,
|
||||||
|
fingerprintOsVersion: currentFingerprint.osVersion,
|
||||||
|
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
||||||
|
fingerprintArch: currentFingerprint.arch,
|
||||||
|
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||||
|
fingerprintUsername: currentFingerprint.username,
|
||||||
|
fingerprintHostname: currentFingerprint.hostname
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||||
|
}
|
||||||
|
|
||||||
|
type OlmWithUpdateAvailable = Awaited<
|
||||||
|
ReturnType<typeof queryUserDevicesBase>
|
||||||
|
>[0] & {
|
||||||
|
olmUpdateAvailable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListUserDevicesResponse = PaginatedResponse<{
|
||||||
|
devices: Array<OlmWithUpdateAvailable>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/user-devices",
|
||||||
|
description: "List all user devices for an organization.",
|
||||||
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
query: listUserDevicesSchema,
|
||||||
|
params: listUserDevicesParamsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listUserDevices(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { page, pageSize, query, sort_by, online, status, agent, order } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessibleClients;
|
||||||
|
if (req.user) {
|
||||||
|
accessibleClients = await db
|
||||||
|
.select({
|
||||||
|
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||||
|
})
|
||||||
|
.from(userClients)
|
||||||
|
.fullJoin(
|
||||||
|
roleClients,
|
||||||
|
eq(userClients.clientId, roleClients.clientId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userClients.userId, req.user!.userId),
|
||||||
|
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
accessibleClients = await db
|
||||||
|
.select({ clientId: clients.clientId })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleClientIds = accessibleClients.map(
|
||||||
|
(client) => client.clientId
|
||||||
|
);
|
||||||
|
// Get client count with filter
|
||||||
|
const conditions = [
|
||||||
|
and(
|
||||||
|
inArray(clients.clientId, accessibleClientIds),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNotNull(clients.userId)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${clients.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${clients.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${users.email})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof online !== "undefined") {
|
||||||
|
conditions.push(eq(clients.online, online));
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentValueMap = {
|
||||||
|
windows: "Pangolin Windows",
|
||||||
|
android: "Pangolin Android",
|
||||||
|
ios: "Pangolin iOS",
|
||||||
|
ipados: "Pangolin iPadOS",
|
||||||
|
macos: "Pangolin macOS",
|
||||||
|
cli: "Pangolin CLI",
|
||||||
|
olm: "Olm CLI"
|
||||||
|
} satisfies Record<
|
||||||
|
Exclude<typeof agent, undefined | "unknown">,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
if (typeof agent !== "undefined") {
|
||||||
|
if (agent === "unknown") {
|
||||||
|
conditions.push(isNull(olms.agent));
|
||||||
|
} else {
|
||||||
|
conditions.push(eq(olms.agent, agentValueMap[agent]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.length > 0) {
|
||||||
|
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||||
|
|
||||||
|
if (status.includes("active")) {
|
||||||
|
filterAggregates.push(
|
||||||
|
and(
|
||||||
|
eq(clients.archived, false),
|
||||||
|
eq(clients.blocked, false),
|
||||||
|
build !== "oss"
|
||||||
|
? or(
|
||||||
|
eq(clients.approvalState, "approved"),
|
||||||
|
isNull(clients.approvalState) // approval state of `NULL` means approved by default
|
||||||
|
)
|
||||||
|
: undefined // undefined are automatically ignored by `drizzle-orm`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.includes("archived")) {
|
||||||
|
filterAggregates.push(eq(clients.archived, true));
|
||||||
|
}
|
||||||
|
if (status.includes("blocked")) {
|
||||||
|
filterAggregates.push(eq(clients.blocked, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build !== "oss") {
|
||||||
|
if (status.includes("pending")) {
|
||||||
|
filterAggregates.push(eq(clients.approvalState, "pending"));
|
||||||
|
}
|
||||||
|
if (status.includes("denied")) {
|
||||||
|
filterAggregates.push(eq(clients.approvalState, "denied"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...filterAggregates));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryUserDevicesBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
|
|
||||||
|
const listDevicesQuery = baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(clients[sort_by])
|
||||||
|
: desc(clients[sort_by])
|
||||||
|
: asc(clients.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [clientsList, totalCount] = await Promise.all([
|
||||||
|
listDevicesQuery,
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge clients with their site associations and replace name with device name
|
||||||
|
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
|
||||||
|
(client) => {
|
||||||
|
const model = client.deviceModel || null;
|
||||||
|
const newName = getUserDeviceName(model, client.name);
|
||||||
|
const OlmWithUpdate: OlmWithUpdateAvailable = {
|
||||||
|
...client,
|
||||||
|
name: newName
|
||||||
|
};
|
||||||
|
// Initially set to false, will be updated if version check succeeds
|
||||||
|
OlmWithUpdate.olmUpdateAvailable = false;
|
||||||
|
return OlmWithUpdate;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to get the latest version, but don't block if it fails
|
||||||
|
try {
|
||||||
|
const latestOlmVersion = await getLatestOlmVersion();
|
||||||
|
|
||||||
|
if (latestOlmVersion) {
|
||||||
|
olmsWithUpdates.forEach((client) => {
|
||||||
|
try {
|
||||||
|
client.olmUpdateAvailable = semver.lt(
|
||||||
|
client.olmVersion ? client.olmVersion : "",
|
||||||
|
latestOlmVersion
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
client.olmUpdateAvailable = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log the error but don't let it block the response
|
||||||
|
logger.warn(
|
||||||
|
"Failed to check for OLM updates, continuing without update info:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<ListUserDevicesResponse>(res, {
|
||||||
|
data: {
|
||||||
|
devices: olmsWithUpdates,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Clients retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,7 +148,6 @@ export async function createOrgDomain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let numOrgDomains: OrgDomains[] | undefined;
|
|
||||||
let aRecords: CreateDomainResponse["aRecords"];
|
let aRecords: CreateDomainResponse["aRecords"];
|
||||||
let cnameRecords: CreateDomainResponse["cnameRecords"];
|
let cnameRecords: CreateDomainResponse["cnameRecords"];
|
||||||
let txtRecords: CreateDomainResponse["txtRecords"];
|
let txtRecords: CreateDomainResponse["txtRecords"];
|
||||||
@@ -347,20 +346,9 @@ export async function createOrgDomain(
|
|||||||
await trx.insert(dnsRecords).values(recordsToInsert);
|
await trx.insert(dnsRecords).values(recordsToInsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
numOrgDomains = await trx
|
await usageService.add(orgId, FeatureId.DOMAINS, 1, trx);
|
||||||
.select()
|
|
||||||
.from(orgDomains)
|
|
||||||
.where(eq(orgDomains.orgId, orgId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (numOrgDomains) {
|
|
||||||
await usageService.updateCount(
|
|
||||||
orgId,
|
|
||||||
FeatureId.DOMAINS,
|
|
||||||
numOrgDomains.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!returned) {
|
if (!returned) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ export async function deleteAccountDomain(
|
|||||||
}
|
}
|
||||||
const { domainId, orgId } = parsed.data;
|
const { domainId, orgId } = parsed.data;
|
||||||
|
|
||||||
let numOrgDomains: OrgDomains[] | undefined;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const [existing] = await trx
|
const [existing] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -79,20 +77,9 @@ export async function deleteAccountDomain(
|
|||||||
|
|
||||||
await trx.delete(domains).where(eq(domains.domainId, domainId));
|
await trx.delete(domains).where(eq(domains.domainId, domainId));
|
||||||
|
|
||||||
numOrgDomains = await trx
|
await usageService.add(orgId, FeatureId.DOMAINS, -1, trx);
|
||||||
.select()
|
|
||||||
.from(orgDomains)
|
|
||||||
.where(eq(orgDomains.orgId, orgId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (numOrgDomains) {
|
|
||||||
await usageService.updateCount(
|
|
||||||
orgId,
|
|
||||||
FeatureId.DOMAINS,
|
|
||||||
numOrgDomains.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<DeleteAccountDomainResponse>(res, {
|
return response<DeleteAccountDomainResponse>(res, {
|
||||||
data: { success: true },
|
data: { success: true },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import createHttpError from "http-errors";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { createStore } from "#dynamic/lib/rateLimitStore";
|
import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||||
import { logActionAudit } from "#dynamic/middlewares";
|
import { logActionAudit } from "#dynamic/middlewares";
|
||||||
|
import { checkRoundTripMessage } from "./ws";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
@@ -64,9 +65,8 @@ authenticated.use(verifySessionUserMiddleware);
|
|||||||
|
|
||||||
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
|
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
|
||||||
authenticated.get("/org/checkId", org.checkId);
|
authenticated.get("/org/checkId", org.checkId);
|
||||||
if (build === "oss" || build === "enterprise") {
|
|
||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||||
}
|
|
||||||
|
|
||||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||||
@@ -86,16 +86,14 @@ authenticated.post(
|
|||||||
org.updateOrg
|
org.updateOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
if (build !== "saas") {
|
authenticated.delete(
|
||||||
authenticated.delete(
|
"/org/:orgId",
|
||||||
"/org/:orgId",
|
verifyOrgAccess,
|
||||||
verifyOrgAccess,
|
verifyUserIsOrgOwner,
|
||||||
verifyUserIsOrgOwner,
|
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
logActionAudit(ActionsEnum.deleteOrg),
|
||||||
logActionAudit(ActionsEnum.deleteOrg),
|
org.deleteOrg
|
||||||
org.deleteOrg
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site",
|
"/org/:orgId/site",
|
||||||
@@ -145,6 +143,13 @@ authenticated.get(
|
|||||||
client.listClients
|
client.listClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-devices",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listClients),
|
||||||
|
client.listUserDevices
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
@@ -1116,6 +1121,8 @@ authenticated.get(
|
|||||||
blueprints.getBlueprint
|
blueprints.getBlueprint
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
@@ -1164,6 +1171,7 @@ authRouter.post(
|
|||||||
auth.login
|
auth.login
|
||||||
);
|
);
|
||||||
authRouter.post("/logout", auth.logout);
|
authRouter.post("/logout", auth.logout);
|
||||||
|
authRouter.post("/delete-my-account", auth.deleteMyAccount);
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/lookup-user",
|
"/lookup-user",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export async function createIdpOrgPolicy(
|
|||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
const { roleMapping, orgMapping } = parsedBody.data;
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ export async function createOidcIdp(
|
|||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.IDENTITY_PROVIDER_MODE === "org"
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|
||||||
const encryptedSecret = encrypt(clientSecret, key);
|
const encryptedSecret = encrypt(clientSecret, key);
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy(
|
|||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
const { roleMapping, orgMapping } = parsedBody.data;
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if IDP and policy exist
|
// Check if IDP and policy exist
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export async function updateOidcIdp(
|
|||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
const [existingIdp] = await db
|
const [existingIdp] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import { build } from "@server/build";
|
|||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import {
|
||||||
|
assignUserToOrg,
|
||||||
|
removeUserFromOrg
|
||||||
|
} from "@server/lib/userOrg";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
return url;
|
return url;
|
||||||
@@ -436,6 +440,7 @@ export async function validateOidcCallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These are the orgs that the user should be provisioned into based on the IdP mappings and the token claims
|
||||||
logger.debug("User org info", { userOrgInfo });
|
logger.debug("User org info", { userOrgInfo });
|
||||||
|
|
||||||
let existingUserId = existingUser?.userId;
|
let existingUserId = existingUser?.userId;
|
||||||
@@ -454,15 +459,32 @@ export async function validateOidcCallback(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!existingUserOrgs.length) {
|
if (!existingUserOrgs.length) {
|
||||||
// delete all auto -provisioned user orgs
|
// delete all auto-provisioned user orgs
|
||||||
await db
|
const autoProvisionedUserOrgs = await db
|
||||||
.delete(userOrgs)
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, existingUser.userId),
|
eq(userOrgs.userId, existingUser.userId),
|
||||||
eq(userOrgs.autoProvisioned, true)
|
eq(userOrgs.autoProvisioned, true)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const orgIdsToRemove = autoProvisionedUserOrgs.map(
|
||||||
|
(uo) => uo.orgId
|
||||||
|
);
|
||||||
|
if (orgIdsToRemove.length > 0) {
|
||||||
|
const orgsToRemove = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(inArray(orgs.orgId, orgIdsToRemove));
|
||||||
|
for (const org of orgsToRemove) {
|
||||||
|
await removeUserFromOrg(
|
||||||
|
org,
|
||||||
|
existingUser.userId,
|
||||||
|
db
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(existingUser.userId);
|
await calculateUserClientsForOrgs(existingUser.userId);
|
||||||
|
|
||||||
@@ -484,7 +506,7 @@ export async function validateOidcCallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||||
|
|
||||||
// sync the user with the orgs and roles
|
// sync the user with the orgs and roles
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -538,15 +560,14 @@ export async function validateOidcCallback(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (orgsToDelete.length > 0) {
|
if (orgsToDelete.length > 0) {
|
||||||
await trx.delete(userOrgs).where(
|
const orgIdsToRemove = orgsToDelete.map((org) => org.orgId);
|
||||||
and(
|
const fullOrgsToRemove = await trx
|
||||||
eq(userOrgs.userId, userId!),
|
.select()
|
||||||
inArray(
|
.from(orgs)
|
||||||
userOrgs.orgId,
|
.where(inArray(orgs.orgId, orgIdsToRemove));
|
||||||
orgsToDelete.map((org) => org.orgId)
|
for (const org of fullOrgsToRemove) {
|
||||||
)
|
await removeUserFromOrg(org, userId!, trx);
|
||||||
)
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update roles for existing auto-provisioned orgs where the role has changed
|
// Update roles for existing auto-provisioned orgs where the role has changed
|
||||||
@@ -587,15 +608,24 @@ export async function validateOidcCallback(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (orgsToAdd.length > 0) {
|
if (orgsToAdd.length > 0) {
|
||||||
await trx.insert(userOrgs).values(
|
for (const org of orgsToAdd) {
|
||||||
orgsToAdd.map((org) => ({
|
const [fullOrg] = await trx
|
||||||
userId: userId!,
|
.select()
|
||||||
orgId: org.orgId,
|
.from(orgs)
|
||||||
roleId: org.roleId,
|
.where(eq(orgs.orgId, org.orgId));
|
||||||
autoProvisioned: true,
|
if (fullOrg) {
|
||||||
dateCreated: new Date().toISOString()
|
await assignUserToOrg(
|
||||||
}))
|
fullOrg,
|
||||||
);
|
{
|
||||||
|
orgId: org.orgId,
|
||||||
|
userId: userId!,
|
||||||
|
roleId: org.roleId,
|
||||||
|
autoProvisioned: true,
|
||||||
|
},
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop through all the orgs and get the total number of users from the userOrgs table
|
// Loop through all the orgs and get the total number of users from the userOrgs table
|
||||||
|
|||||||
@@ -866,6 +866,13 @@ authenticated.get(
|
|||||||
client.listClients
|
client.listClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-devices",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listClients),
|
||||||
|
client.listUserDevices
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, count, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
Org,
|
Org,
|
||||||
@@ -24,13 +24,24 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { isValidCIDR } from "@server/lib/validators";
|
import { isValidCIDR } from "@server/lib/validators";
|
||||||
import { createCustomer } from "#dynamic/lib/billing";
|
import { createCustomer } from "#dynamic/lib/billing";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
import { doCidrsOverlap } from "@server/lib/ip";
|
import { doCidrsOverlap } from "@server/lib/ip";
|
||||||
|
import { generateCA } from "@server/private/lib/sshCA";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
|
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
||||||
|
|
||||||
const createOrgSchema = z.strictObject({
|
const createOrgSchema = z.strictObject({
|
||||||
orgId: z.string(),
|
orgId: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Organization ID is required")
|
||||||
|
.max(32, "Organization ID must be at most 32 characters")
|
||||||
|
.refine((val) => validOrgIdRegex.test(val), {
|
||||||
|
message:
|
||||||
|
"Organization ID must contain only lowercase letters, numbers, underscores, and single hyphens (no leading, trailing, or consecutive hyphens)"
|
||||||
|
}),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
subnet: z
|
subnet: z
|
||||||
// .union([z.cidrv4(), z.cidrv6()])
|
// .union([z.cidrv4(), z.cidrv6()])
|
||||||
@@ -108,6 +119,7 @@ export async function createOrg(
|
|||||||
// )
|
// )
|
||||||
// );
|
// );
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
|
|
||||||
// make sure the orgId is unique
|
// make sure the orgId is unique
|
||||||
const orgExists = await db
|
const orgExists = await db
|
||||||
@@ -134,8 +146,71 @@ export async function createOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isFirstOrg: boolean | null = null;
|
||||||
|
let billingOrgIdForNewOrg: string | null = null;
|
||||||
|
if (build === "saas" && req.user) {
|
||||||
|
const ownedOrgs = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, req.user.userId),
|
||||||
|
eq(userOrgs.isOwner, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (ownedOrgs.length === 0) {
|
||||||
|
isFirstOrg = true;
|
||||||
|
} else {
|
||||||
|
isFirstOrg = false;
|
||||||
|
const [billingOrg] = await db
|
||||||
|
.select({ orgId: orgs.orgId })
|
||||||
|
.from(orgs)
|
||||||
|
.innerJoin(userOrgs, eq(orgs.orgId, userOrgs.orgId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, req.user.userId),
|
||||||
|
eq(userOrgs.isOwner, true),
|
||||||
|
eq(orgs.isBillingOrg, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (billingOrg) {
|
||||||
|
billingOrgIdForNewOrg = billingOrg.orgId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build == "saas" && billingOrgIdForNewOrg) {
|
||||||
|
const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
|
||||||
|
if (!usage) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"No usage data found for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rejectOrgs = await usageService.checkLimitSet(
|
||||||
|
billingOrgIdForNewOrg,
|
||||||
|
FeatureId.ORGINIZATIONS,
|
||||||
|
{
|
||||||
|
...usage,
|
||||||
|
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
||||||
|
} // We need to add one to know if we are violating the limit
|
||||||
|
);
|
||||||
|
if (rejectOrgs) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Organization limit exceeded. Please upgrade your plan."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let error = "";
|
let error = "";
|
||||||
let org: Org | null = null;
|
let org: Org | null = null;
|
||||||
|
let numOrgs: number | null = null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const allDomains = await trx
|
const allDomains = await trx
|
||||||
@@ -143,6 +218,21 @@ export async function createOrg(
|
|||||||
.from(domains)
|
.from(domains)
|
||||||
.where(eq(domains.configManaged, true));
|
.where(eq(domains.configManaged, true));
|
||||||
|
|
||||||
|
// Generate SSH CA keys for the org
|
||||||
|
// const ca = generateCA(`${orgId}-ca`);
|
||||||
|
// const encryptionKey = config.getRawConfig().server.secret!;
|
||||||
|
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
|
||||||
|
|
||||||
|
const saasBillingFields =
|
||||||
|
build === "saas" && req.user && isFirstOrg !== null
|
||||||
|
? isFirstOrg
|
||||||
|
? { isBillingOrg: true as const, billingOrgId: orgId } // if this is the first org, it becomes the billing org for itself
|
||||||
|
: {
|
||||||
|
isBillingOrg: false as const,
|
||||||
|
billingOrgId: billingOrgIdForNewOrg
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
.insert(orgs)
|
.insert(orgs)
|
||||||
.values({
|
.values({
|
||||||
@@ -150,7 +240,10 @@ export async function createOrg(
|
|||||||
name,
|
name,
|
||||||
subnet,
|
subnet,
|
||||||
utilitySubnet,
|
utilitySubnet,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString(),
|
||||||
|
// sshCaPrivateKey: encryptedCaPrivateKey,
|
||||||
|
// sshCaPublicKey: ca.publicKeyOpenSSH,
|
||||||
|
...saasBillingFields
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -252,6 +345,17 @@ export async function createOrg(
|
|||||||
);
|
);
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(ownerUserId, trx);
|
await calculateUserClientsForOrgs(ownerUserId, trx);
|
||||||
|
|
||||||
|
if (billingOrgIdForNewOrg) {
|
||||||
|
const [numOrgsResult] = await trx
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.billingOrgId, billingOrgIdForNewOrg)); // all the billable orgs including the primary org that is the billing org itself
|
||||||
|
|
||||||
|
numOrgs = numOrgsResult.count;
|
||||||
|
} else {
|
||||||
|
numOrgs = 1; // we only have one org if there is no billing org found out
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
@@ -267,8 +371,8 @@ export async function createOrg(
|
|||||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
|
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build == "saas") {
|
if (build === "saas" && isFirstOrg === true) {
|
||||||
// make sure we have the stripe customer
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
const customerId = await createCustomer(orgId, req.user?.email);
|
const customerId = await createCustomer(orgId, req.user?.email);
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
await usageService.updateCount(
|
await usageService.updateCount(
|
||||||
@@ -280,6 +384,14 @@ export async function createOrg(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (numOrgs) {
|
||||||
|
usageService.updateCount(
|
||||||
|
billingOrgIdForNewOrg || orgId,
|
||||||
|
FeatureId.ORGINIZATIONS,
|
||||||
|
numOrgs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: org,
|
data: org,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,28 +1,14 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
domains,
|
|
||||||
olms,
|
|
||||||
orgDomains,
|
|
||||||
resources
|
|
||||||
} from "@server/db";
|
|
||||||
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
|
|
||||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
|
||||||
import { deletePeer } from "../gerbil/peers";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { OlmErrorCodes } from "../olm/error";
|
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
import { db, userOrgs, orgs } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
const deleteOrgSchema = z.strictObject({
|
const deleteOrgSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -56,16 +42,23 @@ export async function deleteOrg(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
const [org] = await db
|
const [data] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(userOrgs)
|
||||||
.where(eq(orgs.orgId, orgId))
|
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
|
||||||
.limit(1);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.userId, req.user!.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (!org) {
|
const org = data?.orgs;
|
||||||
|
const userOrg = data?.userOrgs;
|
||||||
|
|
||||||
|
if (!org || !userOrg) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -73,153 +66,27 @@ export async function deleteOrg(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// we need to handle deleting each site
|
|
||||||
const orgSites = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.orgId, orgId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const orgClients = await db
|
if (!userOrg.isOwner) {
|
||||||
.select()
|
return next(
|
||||||
.from(clients)
|
createHttpError(
|
||||||
.where(eq(clients.orgId, orgId));
|
HttpCode.FORBIDDEN,
|
||||||
|
"Only organization owners can delete the organization"
|
||||||
const deletedNewtIds: string[] = [];
|
)
|
||||||
const olmsToTerminate: string[] = [];
|
);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
for (const site of orgSites) {
|
|
||||||
if (site.pubKey) {
|
|
||||||
if (site.type == "wireguard") {
|
|
||||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
|
||||||
} else if (site.type == "newt") {
|
|
||||||
// get the newt on the site by querying the newt table for siteId
|
|
||||||
const [deletedNewt] = await trx
|
|
||||||
.delete(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.returning();
|
|
||||||
if (deletedNewt) {
|
|
||||||
deletedNewtIds.push(deletedNewt.newtId);
|
|
||||||
|
|
||||||
// delete all of the sessions for the newt
|
|
||||||
await trx
|
|
||||||
.delete(newtSessions)
|
|
||||||
.where(
|
|
||||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deleting site ${site.siteId}`);
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
|
||||||
}
|
|
||||||
for (const client of orgClients) {
|
|
||||||
const [olm] = await trx
|
|
||||||
.select()
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.clientId, client.clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (olm) {
|
|
||||||
olmsToTerminate.push(olm.olmId);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deleting client ${client.clientId}`);
|
|
||||||
await trx
|
|
||||||
.delete(clients)
|
|
||||||
.where(eq(clients.clientId, client.clientId));
|
|
||||||
|
|
||||||
// also delete the associations
|
|
||||||
await trx
|
|
||||||
.delete(clientSiteResourcesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(clientSitesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
clientSitesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allOrgDomains = await trx
|
|
||||||
.select()
|
|
||||||
.from(orgDomains)
|
|
||||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(orgDomains.orgId, orgId),
|
|
||||||
eq(domains.configManaged, false)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// For each domain, check if it belongs to multiple organizations
|
|
||||||
const domainIdsToDelete: string[] = [];
|
|
||||||
for (const orgDomain of allOrgDomains) {
|
|
||||||
const domainId = orgDomain.domains.domainId;
|
|
||||||
|
|
||||||
// Count how many organizations this domain belongs to
|
|
||||||
const orgCount = await trx
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(orgDomains)
|
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
|
||||||
|
|
||||||
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
|
|
||||||
if (orgCount[0].count === 1) {
|
|
||||||
domainIdsToDelete.push(domainId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete domains that belong exclusively to this organization
|
|
||||||
if (domainIdsToDelete.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(domains)
|
|
||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete resources
|
|
||||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
|
||||||
|
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send termination messages outside of transaction to prevent blocking
|
|
||||||
for (const newtId of deletedNewtIds) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/wg/terminate`,
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
// Don't await this to prevent blocking the response
|
|
||||||
sendToClient(newtId, payload).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
"Failed to send termination message to newt:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const olmId of olmsToTerminate) {
|
if (org.isBillingOrg) {
|
||||||
sendTerminateClient(
|
return next(
|
||||||
0, // clientId not needed since we're passing olmId
|
createHttpError(
|
||||||
OlmErrorCodes.TERMINATED_REKEYED,
|
HttpCode.BAD_REQUEST,
|
||||||
olmId
|
"Cannot delete a primary organization"
|
||||||
).catch((error) => {
|
)
|
||||||
logger.error(
|
);
|
||||||
"Failed to send termination message to olm:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await deleteOrgById(orgId);
|
||||||
|
sendTerminationMessages(result);
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -228,6 +95,9 @@ export async function deleteOrg(
|
|||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ const listOrgsSchema = z.object({
|
|||||||
// responses: {}
|
// responses: {}
|
||||||
// });
|
// });
|
||||||
|
|
||||||
type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean };
|
type ResponseOrg = Org & {
|
||||||
|
isOwner?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isPrimaryOrg?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ListUserOrgsResponse = {
|
export type ListUserOrgsResponse = {
|
||||||
orgs: ResponseOrg[];
|
orgs: ResponseOrg[];
|
||||||
@@ -132,6 +136,9 @@ export async function listUserOrgs(
|
|||||||
if (val.roles && val.roles.isAdmin) {
|
if (val.roles && val.roles.isAdmin) {
|
||||||
res.isAdmin = val.roles.isAdmin;
|
res.isAdmin = val.roles.isAdmin;
|
||||||
}
|
}
|
||||||
|
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
|
||||||
|
res.isPrimaryOrg = val.orgs.isBillingOrg;
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
||||||
|
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z.strictObject({
|
const authWithAccessTokenBodySchema = z.strictObject({
|
||||||
accessToken: z.string(),
|
accessToken: z.string(),
|
||||||
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
|
|||||||
requestIp: req.ip
|
requestIp: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||||
|
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
|
||||||
|
if (postAuthPath) {
|
||||||
|
redirectUrl = redirectUrl + postAuthPath;
|
||||||
|
}
|
||||||
|
|
||||||
return response<AuthWithAccessTokenResponse>(res, {
|
return response<AuthWithAccessTokenResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
session: token,
|
session: token,
|
||||||
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
redirectUrl
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
|
|||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
protocol: z.enum(["tcp", "udp"]),
|
||||||
domainId: z.string(),
|
domainId: z.string(),
|
||||||
stickySession: z.boolean().optional()
|
stickySession: z.boolean().optional(),
|
||||||
|
postAuthPath: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -188,7 +189,7 @@ async function createHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, domainId } = parsedBody.data;
|
const { name, domainId, postAuthPath } = parsedBody.data;
|
||||||
const subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
@@ -255,7 +256,8 @@ async function createHttpResource(
|
|||||||
http: true,
|
http: true,
|
||||||
protocol: "tcp",
|
protocol: "tcp",
|
||||||
ssl: true,
|
ssl: true,
|
||||||
stickySession: stickySession
|
stickySession: stickySession,
|
||||||
|
postAuthPath: postAuthPath
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
|
|||||||
whitelist: boolean;
|
whitelist: boolean;
|
||||||
skipToIdpId: number | null;
|
skipToIdpId: number | null;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
postAuthPath: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getResourceAuthInfo(
|
export async function getResourceAuthInfo(
|
||||||
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
|
|||||||
url,
|
url,
|
||||||
whitelist: resource.emailWhitelistEnabled,
|
whitelist: resource.emailWhitelistEnabled,
|
||||||
skipToIdpId: resource.skipToIdpId,
|
skipToIdpId: resource.skipToIdpId,
|
||||||
orgId: resource.orgId
|
orgId: resource.orgId,
|
||||||
|
postAuthPath: resource.postAuthPath ?? null
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
userOrgs,
|
userOrgs,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resourceWhitelist
|
resourceWhitelist,
|
||||||
|
siteResources,
|
||||||
|
userSiteResources,
|
||||||
|
roleSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -57,9 +60,21 @@ export async function getUserResources(
|
|||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.where(eq(roleResources.roleId, userRoleId));
|
.where(eq(roleResources.roleId, userRoleId));
|
||||||
|
|
||||||
const [directResources, roleResourceResults] = await Promise.all([
|
const directSiteResourcesQuery = db
|
||||||
|
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||||
|
.from(userSiteResources)
|
||||||
|
.where(eq(userSiteResources.userId, userId));
|
||||||
|
|
||||||
|
const roleSiteResourcesQuery = db
|
||||||
|
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(eq(roleSiteResources.roleId, userRoleId));
|
||||||
|
|
||||||
|
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
|
||||||
directResourcesQuery,
|
directResourcesQuery,
|
||||||
roleResourcesQuery
|
roleResourcesQuery,
|
||||||
|
directSiteResourcesQuery,
|
||||||
|
roleSiteResourcesQuery
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Combine all accessible resource IDs
|
// Combine all accessible resource IDs
|
||||||
@@ -68,18 +83,25 @@ export async function getUserResources(
|
|||||||
...roleResourceResults.map((r) => r.resourceId)
|
...roleResourceResults.map((r) => r.resourceId)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (accessibleResourceIds.length === 0) {
|
// Combine all accessible site resource IDs
|
||||||
return response(res, {
|
const accessibleSiteResourceIds = [
|
||||||
data: { resources: [] },
|
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||||
success: true,
|
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||||
error: false,
|
];
|
||||||
message: "No resources found",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get resource details for accessible resources
|
// Get resource details for accessible resources
|
||||||
const resourcesData = await db
|
let resourcesData: Array<{
|
||||||
|
resourceId: number;
|
||||||
|
name: string;
|
||||||
|
fullDomain: string | null;
|
||||||
|
ssl: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
sso: boolean;
|
||||||
|
protocol: string;
|
||||||
|
emailWhitelistEnabled: boolean;
|
||||||
|
}> = [];
|
||||||
|
if (accessibleResourceIds.length > 0) {
|
||||||
|
resourcesData = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
name: resources.name,
|
name: resources.name,
|
||||||
@@ -98,6 +120,40 @@ export async function getUserResources(
|
|||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get site resource details for accessible site resources
|
||||||
|
let siteResourcesData: Array<{
|
||||||
|
siteResourceId: number;
|
||||||
|
name: string;
|
||||||
|
destination: string;
|
||||||
|
mode: string;
|
||||||
|
protocol: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
alias: string | null;
|
||||||
|
aliasAddress: string | null;
|
||||||
|
}> = [];
|
||||||
|
if (accessibleSiteResourceIds.length > 0) {
|
||||||
|
siteResourcesData = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
destination: siteResources.destination,
|
||||||
|
mode: siteResources.mode,
|
||||||
|
protocol: siteResources.protocol,
|
||||||
|
enabled: siteResources.enabled,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
aliasAddress: siteResources.aliasAddress
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds),
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for password, pincode, and whitelist protection for each resource
|
// Check for password, pincode, and whitelist protection for each resource
|
||||||
const resourcesWithAuth = await Promise.all(
|
const resourcesWithAuth = await Promise.all(
|
||||||
@@ -161,8 +217,26 @@ export async function getUserResources(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Format site resources
|
||||||
|
const siteResourcesFormatted = siteResourcesData.map((siteResource) => {
|
||||||
|
return {
|
||||||
|
siteResourceId: siteResource.siteResourceId,
|
||||||
|
name: siteResource.name,
|
||||||
|
destination: siteResource.destination,
|
||||||
|
mode: siteResource.mode,
|
||||||
|
protocol: siteResource.protocol,
|
||||||
|
enabled: siteResource.enabled,
|
||||||
|
alias: siteResource.alias,
|
||||||
|
aliasAddress: siteResource.aliasAddress,
|
||||||
|
type: 'site' as const
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { resources: resourcesWithAuth },
|
data: {
|
||||||
|
resources: resourcesWithAuth,
|
||||||
|
siteResources: siteResourcesFormatted
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "User resources retrieved successfully",
|
message: "User resources retrieved successfully",
|
||||||
@@ -190,5 +264,16 @@ export type GetUserResourcesResponse = {
|
|||||||
protected: boolean;
|
protected: boolean;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
}>;
|
}>;
|
||||||
|
siteResources: Array<{
|
||||||
|
siteResourceId: number;
|
||||||
|
name: string;
|
||||||
|
destination: string;
|
||||||
|
mode: string;
|
||||||
|
protocol: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
alias: string | null;
|
||||||
|
aliasAddress: string | null;
|
||||||
|
type: 'site';
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,74 +1,99 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
} from "@server/db";
|
|
||||||
import {
|
|
||||||
resources,
|
|
||||||
userResources,
|
|
||||||
roleResources,
|
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
|
resources,
|
||||||
|
roleResources,
|
||||||
|
targetHealthCheck,
|
||||||
targets,
|
targets,
|
||||||
targetHealthCheck
|
userResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
like,
|
||||||
|
not,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
type SQL
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
const listResourcesParamsSchema = z.strictObject({
|
const listResourcesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listResourcesSchema = z.object({
|
const listResourcesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().nonnegative()),
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
offset: z
|
default: 20,
|
||||||
.string()
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative())
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
enabled: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter resources based on enabled status"
|
||||||
|
}),
|
||||||
|
authState: z
|
||||||
|
.enum(["protected", "not_protected", "none"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["protected", "not_protected", "none"],
|
||||||
|
description:
|
||||||
|
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
|
||||||
|
}),
|
||||||
|
healthStatus: z
|
||||||
|
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
|
||||||
|
description:
|
||||||
|
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// (resource fields + a single joined target)
|
|
||||||
type JoinedRow = {
|
|
||||||
resourceId: number;
|
|
||||||
niceId: string;
|
|
||||||
name: string;
|
|
||||||
ssl: boolean;
|
|
||||||
fullDomain: string | null;
|
|
||||||
passwordId: number | null;
|
|
||||||
sso: boolean;
|
|
||||||
pincodeId: number | null;
|
|
||||||
whitelist: boolean;
|
|
||||||
http: boolean;
|
|
||||||
protocol: string;
|
|
||||||
proxyPort: number | null;
|
|
||||||
enabled: boolean;
|
|
||||||
domainId: string | null;
|
|
||||||
headerAuthId: number | null;
|
|
||||||
|
|
||||||
targetId: number | null;
|
|
||||||
targetIp: string | null;
|
|
||||||
targetPort: number | null;
|
|
||||||
targetEnabled: boolean | null;
|
|
||||||
|
|
||||||
hcHealth: string | null;
|
|
||||||
hcEnabled: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// grouped by resource with targets[])
|
// grouped by resource with targets[])
|
||||||
export type ResourceWithTargets = {
|
export type ResourceWithTargets = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
@@ -91,11 +116,32 @@ export type ResourceWithTargets = {
|
|||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
// Aggregate filters
|
||||||
|
const total_targets = count(targets.targetId);
|
||||||
|
const healthy_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
const unknown_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
const unhealthy_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
|
||||||
|
function queryResourcesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -114,14 +160,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthExtendedCompatibilityId:
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||||
targetId: targets.targetId,
|
|
||||||
targetIp: targets.ip,
|
|
||||||
targetPort: targets.port,
|
|
||||||
targetEnabled: targets.enabled,
|
|
||||||
|
|
||||||
hcHealth: targetHealthCheck.hcHealth,
|
|
||||||
hcEnabled: targetHealthCheck.hcEnabled
|
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -148,18 +187,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
)
|
)
|
||||||
.where(
|
.groupBy(
|
||||||
and(
|
resources.resourceId,
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
resourcePassword.passwordId,
|
||||||
eq(resources.orgId, orgId)
|
resourcePincode.pincodeId,
|
||||||
)
|
resourceHeaderAuth.headerAuthId,
|
||||||
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListResourcesResponse = {
|
export type ListResourcesResponse = PaginatedResponse<{
|
||||||
resources: ResourceWithTargets[];
|
resources: ResourceWithTargets[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -190,7 +229,8 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, authState, enabled, query, healthStatus } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -252,14 +292,133 @@ export async function listResources(
|
|||||||
(resource) => resource.resourceId
|
(resource) => resource.resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
const countQuery: any = db
|
const conditions = [
|
||||||
.select({ count: count() })
|
and(
|
||||||
.from(resources)
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.fullDomain})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof enabled !== "undefined") {
|
||||||
|
conditions.push(eq(resources.enabled, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
|
if (typeof authState !== "undefined") {
|
||||||
|
switch (authState) {
|
||||||
|
case "none":
|
||||||
|
conditions.push(eq(resources.http, false));
|
||||||
|
break;
|
||||||
|
case "protected":
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(resources.sso, true),
|
||||||
|
eq(resources.emailWhitelistEnabled, true),
|
||||||
|
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||||
|
not(isNull(resourcePincode.pincodeId)),
|
||||||
|
not(isNull(resourcePassword.passwordId))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "not_protected":
|
||||||
|
conditions.push(
|
||||||
|
not(eq(resources.sso, true)),
|
||||||
|
not(eq(resources.emailWhitelistEnabled, true)),
|
||||||
|
isNull(resourceHeaderAuth.headerAuthId),
|
||||||
|
isNull(resourcePincode.pincodeId),
|
||||||
|
isNull(resourcePassword.passwordId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
|
||||||
|
|
||||||
|
if (typeof healthStatus !== "undefined") {
|
||||||
|
switch (healthStatus) {
|
||||||
|
case "healthy":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${healthy_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "degraded":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${unhealthy_targets} > 0`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "no_targets":
|
||||||
|
aggregateFilters = sql`${total_targets} = 0`;
|
||||||
|
break;
|
||||||
|
case "offline":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${healthy_targets} = 0`,
|
||||||
|
sql`${unhealthy_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "unknown":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${unknown_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryResourcesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.having(aggregateFilters);
|
||||||
|
|
||||||
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_resources"));
|
||||||
|
|
||||||
|
const [rows, totalCount] = await Promise.all([
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(resources.resourceId)),
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resourceIdList = rows.map((row) => row.resourceId);
|
||||||
|
const allResourceTargets =
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
targetId: targets.targetId,
|
||||||
|
resourceId: targets.resourceId,
|
||||||
|
ip: targets.ip,
|
||||||
|
port: targets.port,
|
||||||
|
enabled: targets.enabled,
|
||||||
|
healthStatus: targetHealthCheck.hcHealth,
|
||||||
|
hcEnabled: targetHealthCheck.hcEnabled
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(inArray(targets.resourceId, resourceIdList))
|
||||||
|
.leftJoin(
|
||||||
|
targetHealthCheck,
|
||||||
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
|
);
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
@@ -288,44 +447,20 @@ export async function listResources(
|
|||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
entry.targets = allResourceTargets.filter(
|
||||||
row.targetId != null &&
|
(t) => t.resourceId === entry.resourceId
|
||||||
row.targetIp &&
|
);
|
||||||
row.targetPort != null &&
|
|
||||||
row.targetEnabled != null
|
|
||||||
) {
|
|
||||||
let healthStatus: "healthy" | "unhealthy" | "unknown" =
|
|
||||||
"unknown";
|
|
||||||
|
|
||||||
if (row.hcEnabled && row.hcHealth) {
|
|
||||||
healthStatus = row.hcHealth as
|
|
||||||
| "healthy"
|
|
||||||
| "unhealthy"
|
|
||||||
| "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.targets.push({
|
|
||||||
targetId: row.targetId,
|
|
||||||
ip: row.targetIp,
|
|
||||||
port: row.targetPort,
|
|
||||||
enabled: row.targetEnabled,
|
|
||||||
healthStatus: healthStatus
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
||||||
|
|
||||||
const totalCountResult = await countQuery;
|
|
||||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
|
||||||
|
|
||||||
return response<ListResourcesResponse>(res, {
|
return response<ListResourcesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
resources: resourcesList,
|
resources: resourcesList,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const updateResourceParamsSchema = z.strictObject({
|
|||||||
const updateHttpResourceBodySchema = z
|
const updateHttpResourceBodySchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
niceId: z.string().min(1).max(255).optional(),
|
niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
subdomain: subdomainSchema.nullable().optional(),
|
subdomain: subdomainSchema.nullable().optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
@@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z
|
|||||||
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
|
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
|
||||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional()
|
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||||
|
postAuthPath: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import response from "@server/lib/response";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, count } from "drizzle-orm";
|
||||||
import { getUniqueSiteName } from "../../db/names";
|
import { getUniqueSiteName } from "../../db/names";
|
||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
@@ -288,7 +288,6 @@ export async function createSite(
|
|||||||
const niceId = await getUniqueSiteName(orgId);
|
const niceId = await getUniqueSiteName(orgId);
|
||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
let numSites: Site[] | undefined;
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (type == "newt") {
|
if (type == "newt") {
|
||||||
[newSite] = await trx
|
[newSite] = await trx
|
||||||
@@ -443,20 +442,9 @@ export async function createSite(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
numSites = await trx
|
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.orgId, orgId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (numSites) {
|
|
||||||
await usageService.updateCount(
|
|
||||||
orgId,
|
|
||||||
FeatureId.SITES,
|
|
||||||
numSites.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newSite) {
|
if (!newSite) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export async function deleteSite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let deletedNewtId: string | null = null;
|
let deletedNewtId: string | null = null;
|
||||||
let numSites: Site[] | undefined;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
@@ -103,19 +102,9 @@ export async function deleteSite(
|
|||||||
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
|
|
||||||
numSites = await trx
|
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.orgId, site.orgId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (numSites) {
|
|
||||||
await usageService.updateCount(
|
|
||||||
site.orgId,
|
|
||||||
FeatureId.SITES,
|
|
||||||
numSites.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Send termination message outside of transaction to prevent blocking
|
// Send termination message outside of transaction to prevent blocking
|
||||||
if (deletedNewtId) {
|
if (deletedNewtId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { db, exitNodes, newts } from "@server/db";
|
import {
|
||||||
import { orgs, roleSites, sites, userSites } from "@server/db";
|
db,
|
||||||
import { remoteExitNodes } from "@server/db";
|
exitNodes,
|
||||||
import logger from "@server/logger";
|
newts,
|
||||||
import HttpCode from "@server/types/HttpCode";
|
orgs,
|
||||||
|
remoteExitNodes,
|
||||||
|
roleSites,
|
||||||
|
sites,
|
||||||
|
userSites
|
||||||
|
} from "@server/db";
|
||||||
|
import cache from "@server/lib/cache";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, count, eq, inArray, or, sql } from "drizzle-orm";
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import semver from "semver";
|
|
||||||
import cache from "@server/lib/cache";
|
|
||||||
|
|
||||||
async function getLatestNewtVersion(): Promise<string | null> {
|
async function getLatestNewtVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listSitesSchema = z.object({
|
const listSitesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().positive()),
|
.openapi({
|
||||||
offset: z
|
type: "integer",
|
||||||
.string()
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative())
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["megabytesIn", "megabytesOut"],
|
||||||
|
description: "Field to sort by"
|
||||||
|
}),
|
||||||
|
order: z
|
||||||
|
.enum(["asc", "desc"])
|
||||||
|
.optional()
|
||||||
|
.default("asc")
|
||||||
|
.catch("asc")
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
default: "asc",
|
||||||
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter by online status"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function querySites(orgId: string, accessibleSiteIds: number[]) {
|
function querySitesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
@@ -115,23 +165,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
|||||||
.leftJoin(
|
.leftJoin(
|
||||||
remoteExitNodes,
|
remoteExitNodes,
|
||||||
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
|
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListSitesResponse = {
|
export type ListSitesResponse = PaginatedResponse<{
|
||||||
sites: SiteWithUpdateAvailable[];
|
sites: SiteWithUpdateAvailable[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -160,7 +203,6 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -203,34 +245,67 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const { pageSize, page, query, sort_by, order, online } =
|
||||||
const baseQuery = querySites(orgId, accessibleSiteIds);
|
parsedQuery.data;
|
||||||
|
|
||||||
const countQuery = db
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
const conditions = [
|
||||||
.where(
|
and(
|
||||||
and(
|
inArray(sites.siteId, accessibleSiteIds),
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
eq(sites.orgId, orgId)
|
||||||
eq(sites.orgId, orgId)
|
)
|
||||||
|
];
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (typeof online !== "undefined") {
|
||||||
|
conditions.push(eq(sites.online, online));
|
||||||
|
}
|
||||||
|
|
||||||
const sitesList = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
const totalCountResult = await countQuery;
|
|
||||||
const totalCount = totalCountResult[0].count;
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(
|
||||||
|
querySitesBase().where(and(...conditions))
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteListQuery = baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(sites[sort_by])
|
||||||
|
: desc(sites[sort_by])
|
||||||
|
: asc(sites.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalCount, rows] = await Promise.all([
|
||||||
|
countQuery,
|
||||||
|
siteListQuery
|
||||||
|
]);
|
||||||
|
|
||||||
// Get latest version asynchronously without blocking the response
|
// Get latest version asynchronously without blocking the response
|
||||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map(
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
(site) => {
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
// Initially set to false, will be updated if version check succeeds
|
||||||
// Initially set to false, will be updated if version check succeeds
|
siteWithUpdate.newtUpdateAvailable = false;
|
||||||
siteWithUpdate.newtUpdateAvailable = false;
|
return siteWithUpdate;
|
||||||
return siteWithUpdate;
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try to get the latest version, but don't block if it fails
|
// Try to get the latest version, but don't block if it fails
|
||||||
try {
|
try {
|
||||||
@@ -267,8 +342,8 @@ export async function listSites(
|
|||||||
sites: sitesWithUpdates,
|
sites: sitesWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export async function createSiteResource(
|
|||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
mode,
|
mode: mode as "host" | "cidr",
|
||||||
// protocol: mode === "port" ? protocol : null,
|
// protocol: mode === "port" ? protocol : null,
|
||||||
// proxyPort: mode === "port" ? proxyPort : null,
|
// proxyPort: mode === "port" ? proxyPort : null,
|
||||||
// destinationPort: mode === "port" ? destinationPort : null,
|
// destinationPort: mode === "port" ? destinationPort : null,
|
||||||
|
|||||||
@@ -1,41 +1,90 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { siteResources, sites, SiteResource } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().positive()),
|
.openapi({
|
||||||
offset: z
|
type: "integer",
|
||||||
.string()
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative())
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
mode: z
|
||||||
|
.enum(["host", "cidr"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["host", "cidr"],
|
||||||
|
description: "Filter site resources by mode"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListAllSiteResourcesByOrgResponse = {
|
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||||
siteResources: (SiteResource & {
|
siteResources: (SiteResource & {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
siteAddress: string | null;
|
siteAddress: string | null;
|
||||||
})[];
|
})[];
|
||||||
};
|
}>;
|
||||||
|
|
||||||
|
function querySiteResourcesBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
siteId: siteResources.siteId,
|
||||||
|
orgId: siteResources.orgId,
|
||||||
|
niceId: siteResources.niceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
mode: siteResources.mode,
|
||||||
|
protocol: siteResources.protocol,
|
||||||
|
proxyPort: siteResources.proxyPort,
|
||||||
|
destinationPort: siteResources.destinationPort,
|
||||||
|
destination: siteResources.destination,
|
||||||
|
enabled: siteResources.enabled,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
aliasAddress: siteResources.aliasAddress,
|
||||||
|
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||||
|
udpPortRangeString: siteResources.udpPortRangeString,
|
||||||
|
disableIcmp: siteResources.disableIcmp,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteNiceId: sites.niceId,
|
||||||
|
siteAddress: sites.address
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||||
|
}
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -80,39 +129,67 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, query, mode } = parsedQuery.data;
|
||||||
|
|
||||||
// Get all site resources for the org with site names
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
const siteResourcesList = await db
|
if (query) {
|
||||||
.select({
|
conditions.push(
|
||||||
siteResourceId: siteResources.siteResourceId,
|
or(
|
||||||
siteId: siteResources.siteId,
|
like(
|
||||||
orgId: siteResources.orgId,
|
sql`LOWER(${siteResources.name})`,
|
||||||
niceId: siteResources.niceId,
|
"%" + query.toLowerCase() + "%"
|
||||||
name: siteResources.name,
|
),
|
||||||
mode: siteResources.mode,
|
like(
|
||||||
protocol: siteResources.protocol,
|
sql`LOWER(${siteResources.niceId})`,
|
||||||
proxyPort: siteResources.proxyPort,
|
"%" + query.toLowerCase() + "%"
|
||||||
destinationPort: siteResources.destinationPort,
|
),
|
||||||
destination: siteResources.destination,
|
like(
|
||||||
enabled: siteResources.enabled,
|
sql`LOWER(${siteResources.destination})`,
|
||||||
alias: siteResources.alias,
|
"%" + query.toLowerCase() + "%"
|
||||||
aliasAddress: siteResources.aliasAddress,
|
),
|
||||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
like(
|
||||||
udpPortRangeString: siteResources.udpPortRangeString,
|
sql`LOWER(${siteResources.alias})`,
|
||||||
disableIcmp: siteResources.disableIcmp,
|
"%" + query.toLowerCase() + "%"
|
||||||
siteName: sites.name,
|
),
|
||||||
siteNiceId: sites.niceId,
|
like(
|
||||||
siteAddress: sites.address
|
sql`LOWER(${siteResources.aliasAddress})`,
|
||||||
})
|
"%" + query.toLowerCase() + "%"
|
||||||
.from(siteResources)
|
),
|
||||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))
|
like(
|
||||||
.where(eq(siteResources.orgId, orgId))
|
sql`LOWER(${sites.name})`,
|
||||||
.limit(limit)
|
"%" + query.toLowerCase() + "%"
|
||||||
.offset(offset);
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
if (mode) {
|
||||||
data: { siteResources: siteResourcesList },
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(
|
||||||
|
querySiteResourcesBase().where(and(...conditions))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [siteResourcesList, totalCount] = await Promise.all([
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(siteResources.siteResourceId)),
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
|
data: {
|
||||||
|
siteResources: siteResourcesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Site resources retrieved successfully",
|
message: "Site resources retrieved successfully",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const updateSiteResourceSchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
siteId: z.int(),
|
siteId: z.int(),
|
||||||
|
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||||
mode: z.enum(["host", "cidr"]).optional(),
|
mode: z.enum(["host", "cidr"]).optional(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user