Compare commits
326 Commits
miloschwar
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f95896aa | ||
|
|
5b8994d143 | ||
|
|
c46ef2fe9c | ||
|
|
4cd025dd91 | ||
|
|
ce04ea9720 | ||
|
|
a3ce382725 | ||
|
|
4eb49e3e60 | ||
|
|
2a9481023a | ||
|
|
8ed01372b8 | ||
|
|
6a7d4fd385 | ||
|
|
7bc08c0425 | ||
|
|
36a47c4cfb | ||
|
|
7dce4500ec | ||
|
|
72e48a56df | ||
|
|
293d9865b4 | ||
|
|
45a2a07747 | ||
|
|
181bcffe7d | ||
|
|
ed35d25598 | ||
|
|
05e738e0f4 | ||
|
|
c95e66d531 | ||
|
|
cc2a416a92 | ||
|
|
70bb42f1fc | ||
|
|
10d2bc1e9e | ||
|
|
385f57ec93 | ||
|
|
9c8ffdb661 | ||
|
|
5a5feccc76 | ||
|
|
36e7054386 | ||
|
|
19de12b12e | ||
|
|
d96e930679 | ||
|
|
5e51b8ad74 | ||
|
|
885b9e638d | ||
|
|
56ef3a934a | ||
|
|
98bc199c8e | ||
|
|
0444d3490b | ||
|
|
54820d1db0 | ||
|
|
961cbfcacc | ||
|
|
a784cd307e | ||
|
|
b46c948522 | ||
|
|
7eab2cc0bb | ||
|
|
5ff2569ece | ||
|
|
c59505be8d | ||
|
|
2b0e6649fa | ||
|
|
428e9b546e | ||
|
|
5089660381 | ||
|
|
998364b09d | ||
|
|
ac0d88d9b7 | ||
|
|
401f04b53e | ||
|
|
b046ab7513 | ||
|
|
65ee9b9544 | ||
|
|
49c7319342 | ||
|
|
ce7df5ddaa | ||
|
|
af1739fbcb | ||
|
|
f01c9ee41c | ||
|
|
19f8956218 | ||
|
|
a8c50b8618 | ||
|
|
e86a381ed5 | ||
|
|
dd18375f23 | ||
|
|
46b72b9e8c | ||
|
|
7bb2a5a0a5 | ||
|
|
4b777b1488 | ||
|
|
428f91b5fa | ||
|
|
caaae77f74 | ||
|
|
4df27b316c | ||
|
|
8f52a48937 | ||
|
|
a53da85fb4 | ||
|
|
08a5785cc5 | ||
|
|
ff928b846d | ||
|
|
47b3d26d0e | ||
|
|
6270dce86a | ||
|
|
864d1d5cc4 | ||
|
|
b63eda64f4 | ||
|
|
b8e942478d | ||
|
|
6d9bfbf08f | ||
|
|
35ce947e19 | ||
|
|
b17ba96235 | ||
|
|
f1bdb25497 | ||
|
|
e11527b430 | ||
|
|
31d3b314e9 | ||
|
|
3bce57c65c | ||
|
|
d649a83535 | ||
|
|
3c6b1781bc | ||
|
|
7dd50f65fc | ||
|
|
342b4aeddf | ||
|
|
65908fa00f | ||
|
|
223e0d0706 | ||
|
|
5426031cd4 | ||
|
|
adf4a1ffda | ||
|
|
780feba19c | ||
|
|
3ac315b52e | ||
|
|
1b183d32c0 | ||
|
|
0c643e91a6 | ||
|
|
fab53ba26a | ||
|
|
62e19a2f4e | ||
|
|
7d67fb9984 | ||
|
|
7436aebca7 | ||
|
|
66fda553e4 | ||
|
|
432dc81875 | ||
|
|
2ecf076c0f | ||
|
|
9b71c426c7 | ||
|
|
e06dda27cb | ||
|
|
18f6e0f75d | ||
|
|
3b232bcc58 | ||
|
|
c575bb76e7 | ||
|
|
87e6c7ba36 | ||
|
|
c8e7e0ee1e | ||
|
|
0e7aafd364 | ||
|
|
91f1bae3e9 | ||
|
|
53c138ce3e | ||
|
|
969db14a3c | ||
|
|
1ca1059673 | ||
|
|
9410a18404 | ||
|
|
c1c387bdd8 | ||
|
|
6e83d77a87 | ||
|
|
ba9a1efa4c | ||
|
|
9e046b9608 | ||
|
|
37794eb299 | ||
|
|
4e66b0e74b | ||
|
|
44fa873977 | ||
|
|
505461a533 | ||
|
|
a88c5b1428 | ||
|
|
97ef1d605c | ||
|
|
3fc1c9d948 | ||
|
|
68bd37ab6c | ||
|
|
5c317c535b | ||
|
|
37c6b11899 | ||
|
|
45c567ffa0 | ||
|
|
49d22498fc | ||
|
|
23f4302186 | ||
|
|
775ea64b55 | ||
|
|
64ad7641af | ||
|
|
d724f5bb5d | ||
|
|
30e627cca8 | ||
|
|
53c1e2e742 | ||
|
|
fb4bda077b | ||
|
|
d4f7c4a9c4 | ||
|
|
1cc0e9b689 | ||
|
|
584be4dbd2 | ||
|
|
c33e295ce7 | ||
|
|
1a926a7127 | ||
|
|
eb515a8f7f | ||
|
|
81b8a8a9e3 | ||
|
|
bcd164219f | ||
|
|
c90e405105 | ||
|
|
b2c8311b26 | ||
|
|
2154811ffb | ||
|
|
1772ac220f | ||
|
|
9bd33072f4 | ||
|
|
cf596d980f | ||
|
|
70f619b726 | ||
|
|
7743e3890b | ||
|
|
d8df250555 | ||
|
|
45c9f217c6 | ||
|
|
8371692cc5 | ||
|
|
5377dc7a1c | ||
|
|
02649468e0 | ||
|
|
c5ef00fb0e | ||
|
|
6f4325e9a0 | ||
|
|
a2a031dfe7 | ||
|
|
e34a4c82eb | ||
|
|
52fd7df727 | ||
|
|
d5f08437d7 | ||
|
|
9ee07ba343 | ||
|
|
4baaa5fc14 | ||
|
|
61de100630 | ||
|
|
3694f43ae8 | ||
|
|
279211142d | ||
|
|
b8822b4d25 | ||
|
|
0655ba9423 | ||
|
|
e1afbc226c | ||
|
|
96c450fd08 | ||
|
|
587e4d104b | ||
|
|
368c5c374f | ||
|
|
7675b6409c | ||
|
|
d31da1a41e | ||
|
|
49e259e259 | ||
|
|
f4684c1858 | ||
|
|
6e223bb363 | ||
|
|
22e7038b2c | ||
|
|
76ba4c1fdf | ||
|
|
7f25d94a83 | ||
|
|
769ba27e3a | ||
|
|
a188552ba0 | ||
|
|
208132082e | ||
|
|
fcd5789221 | ||
|
|
2c85bcd06b | ||
|
|
c6a8b09cff | ||
|
|
380ff381fc | ||
|
|
5eb3951f00 | ||
|
|
c30e94da98 | ||
|
|
6ca24d51a1 | ||
|
|
13f512aed6 | ||
|
|
2bdbc9d688 | ||
|
|
8e2f30d8de | ||
|
|
a84e1cc9e0 | ||
|
|
6b28f0c81e | ||
|
|
d28d3ba6ea | ||
|
|
6efaf9f40d | ||
|
|
5379b32959 | ||
|
|
9bb936a40d | ||
|
|
960fe760f1 | ||
|
|
2f2105a085 | ||
|
|
de92a28435 | ||
|
|
d8c3484ed5 | ||
|
|
726e000154 | ||
|
|
d6abe83fdc | ||
|
|
9df46f7014 | ||
|
|
908f0d54e2 | ||
|
|
f0010ea12a | ||
|
|
cab8be1a9a | ||
|
|
0a9dab7cca | ||
|
|
889ab1f8a8 | ||
|
|
a9019cfb23 | ||
|
|
441d4bce6e | ||
|
|
dd1e681a9c | ||
|
|
a882619eaf | ||
|
|
f43baaaf1f | ||
|
|
c3dc0bd015 | ||
|
|
1fd2a0fae2 | ||
|
|
8ba5b43569 | ||
|
|
6deefcd003 | ||
|
|
4d6cea5fcd | ||
|
|
f175ac774f | ||
|
|
0fe2b24f6b | ||
|
|
6ad06e6faf | ||
|
|
d47faeced1 | ||
|
|
498f586eeb | ||
|
|
e94fc6bc65 | ||
|
|
0a1fe1b725 | ||
|
|
eb40b04b43 | ||
|
|
6685afdcf9 | ||
|
|
49232e32bf | ||
|
|
aec0aed211 | ||
|
|
d43b3176f5 | ||
|
|
190074ea0c | ||
|
|
c5a7719239 | ||
|
|
5eac131d2e | ||
|
|
0bc3276ee2 | ||
|
|
5073507b90 | ||
|
|
805e6f856a | ||
|
|
412a9b5294 | ||
|
|
fbf95c5363 | ||
|
|
b907850344 | ||
|
|
22116373e3 | ||
|
|
9757c3d8b6 | ||
|
|
f8b85d4b4e | ||
|
|
4651f19c53 | ||
|
|
4524bdc094 | ||
|
|
741850880e | ||
|
|
53e096f7cb | ||
|
|
3dfd7e8a43 | ||
|
|
db6e60d0a3 | ||
|
|
54d2d689c1 | ||
|
|
bb5853827b | ||
|
|
68f5512732 | ||
|
|
657072dd17 | ||
|
|
443a19165f | ||
|
|
b4906ec9ba | ||
|
|
416e124c02 | ||
|
|
d3e4d8cda8 | ||
|
|
81972dbb73 | ||
|
|
39bf64bc35 | ||
|
|
b715786a1e | ||
|
|
ae24eb2d2c | ||
|
|
20fc59dcda | ||
|
|
93b09de425 | ||
|
|
bacc130453 | ||
|
|
79541ec7b8 | ||
|
|
81197f8a86 | ||
|
|
dcfc7822f4 | ||
|
|
269bd9aa0f | ||
|
|
0a0817b860 | ||
|
|
b7a903ab32 | ||
|
|
ab60438aa7 | ||
|
|
b9f3f90de6 | ||
|
|
b53cc397be | ||
|
|
994fb456c2 | ||
|
|
b36927c7a0 | ||
|
|
1c57473b6d | ||
|
|
c02c3eaa4a | ||
|
|
3c265ee577 | ||
|
|
98dfd05f06 | ||
|
|
faa2e97530 | ||
|
|
175f10a51d | ||
|
|
6284930fce | ||
|
|
6c93aca444 | ||
|
|
d83318cbfc | ||
|
|
143f362a48 | ||
|
|
698cd868a8 | ||
|
|
a55842ffff | ||
|
|
2ffe254879 | ||
|
|
e173f59d89 | ||
|
|
d3870f4920 | ||
|
|
227501d8f8 | ||
|
|
a16f805709 | ||
|
|
a029b107ae | ||
|
|
f03389a9a0 | ||
|
|
78fff6bfde | ||
|
|
bc585c24fc | ||
|
|
0f6c66dc67 | ||
|
|
6be150bafe | ||
|
|
1eac7741a5 | ||
|
|
b8ca0499af | ||
|
|
b39a2bcfb1 | ||
|
|
d45b727dca | ||
|
|
5c31d35e28 | ||
|
|
8c645315f3 | ||
|
|
ab6377e086 | ||
|
|
a3f30eff02 | ||
|
|
081940dff8 | ||
|
|
c4cf4cdec4 | ||
|
|
85f2165a1e | ||
|
|
1bc7175dd4 | ||
|
|
8ed9adbfae | ||
|
|
ddaa9c32a7 | ||
|
|
27b2ec309d | ||
|
|
91ce8bea4b | ||
|
|
2ea9d27237 | ||
|
|
95cbaaae21 | ||
|
|
955aa41f53 | ||
|
|
cb3fa028c3 | ||
|
|
c746e1bc8d | ||
|
|
da4dd88fdd | ||
|
|
b9bee2836b | ||
|
|
53c48e6f04 | ||
|
|
9db5ff9ff7 | ||
|
|
8e1905a695 |
92
.github/workflows/cicd.yml
vendored
@@ -414,28 +414,18 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
# cosign is used to sign and verify container images (key and keyless)
|
# cosign is used to sign container images using keyless (OIDC) signing
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||||
|
|
||||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
- name: Sign (GHCR, keyless)
|
||||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
||||||
# then verify both the public key signature and the keyless OIDC signature.
|
# Signatures are stored in the registry alongside the image.
|
||||||
env:
|
env:
|
||||||
TAG: ${{ env.TAG }}
|
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"
|
COSIGN_YES: "true"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
issuer="https://token.actions.githubusercontent.com"
|
|
||||||
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
|
||||||
@@ -463,95 +453,47 @@ jobs:
|
|||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Sign each image variant for both registries
|
FAILED_TAGS=()
|
||||||
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
SUCCESSFUL_TAGS=()
|
||||||
|
|
||||||
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
||||||
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
|
||||||
TAG_FAILED=false
|
TAG_FAILED=false
|
||||||
|
|
||||||
# Wrap the entire tag processing in error handling
|
|
||||||
(
|
(
|
||||||
set -e
|
set -e
|
||||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
||||||
REF="${BASE_IMAGE}@${DIGEST}"
|
REF="${GHCR_IMAGE}@${DIGEST}"
|
||||||
echo "Resolved digest: ${REF}"
|
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}"
|
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
|
||||||
|
|
||||||
# Retry wrapper for verification to handle registry propagation delays
|
|
||||||
retry_verify() {
|
|
||||||
local cmd="$1"
|
|
||||||
local attempts=6
|
|
||||||
local delay=5
|
|
||||||
local i=1
|
|
||||||
until eval "$cmd"; do
|
|
||||||
if [ $i -ge $attempts ]; then
|
|
||||||
echo "Verification failed after $attempts attempts"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
|
||||||
sleep $delay
|
|
||||||
i=$((i+1))
|
|
||||||
delay=$((delay*2))
|
|
||||||
# Cap the delay to avoid very long waits
|
|
||||||
if [ $delay -gt 60 ]; then delay=60; fi
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "==> cosign verify (public key) ${REF}"
|
|
||||||
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
|
|
||||||
|
|
||||||
# Check if verification succeeded
|
|
||||||
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
|
||||||
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
|
|
||||||
echo "This may be due to registry propagation delays. Continuing anyway."
|
|
||||||
fi
|
|
||||||
) || TAG_FAILED=true
|
) || TAG_FAILED=true
|
||||||
|
|
||||||
if [ "$TAG_FAILED" = "true" ]; then
|
if [ "$TAG_FAILED" = "true" ]; then
|
||||||
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
|
||||||
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
|
||||||
else
|
else
|
||||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
|
||||||
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done
|
|
||||||
|
|
||||||
# Report summary
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Sign and Verify Summary"
|
echo "Sign Summary"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
||||||
echo "Failed: ${#FAILED_TAGS[@]}"
|
echo "Failed: ${#FAILED_TAGS[@]}"
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
||||||
echo "Failed tags:"
|
echo "Failed tags:"
|
||||||
for tag in "${FAILED_TAGS[@]}"; do
|
for tag in "${FAILED_TAGS[@]}"; do
|
||||||
echo " - $tag"
|
echo " - $tag"
|
||||||
done
|
done
|
||||||
echo ""
|
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
|
||||||
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
|
|
||||||
else
|
else
|
||||||
echo "✓ All images signed and verified successfully!"
|
echo "✓ All images signed successfully!"
|
||||||
fi
|
fi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
|||||||
28
cli/commands/clearCertificates.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { CommandModule } from "yargs";
|
||||||
|
import { db, certificates } from "@server/db";
|
||||||
|
|
||||||
|
type ClearCertificatesArgs = {};
|
||||||
|
|
||||||
|
export const clearCertificates: CommandModule<{}, ClearCertificatesArgs> = {
|
||||||
|
command: "clear-certificates",
|
||||||
|
describe: "Delete all entries from the certificates table",
|
||||||
|
builder: (yargs) => {
|
||||||
|
return yargs;
|
||||||
|
},
|
||||||
|
handler: async (argv: {}) => {
|
||||||
|
try {
|
||||||
|
console.log("Clearing all certificates from the database...");
|
||||||
|
|
||||||
|
const deleted = await db.delete(certificates).returning();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Deleted ${deleted.length} certificate(s) from the database`
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommandModule } from "yargs";
|
import { CommandModule } from "yargs";
|
||||||
import { db, idpOidcConfig, licenseKey } from "@server/db";
|
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -129,9 +129,15 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log("\nReading encrypted data from database...");
|
console.log("\nReading encrypted data from database...");
|
||||||
const idpConfigs = await db.select().from(idpOidcConfig);
|
const idpConfigs = await db.select().from(idpOidcConfig);
|
||||||
const licenseKeys = await db.select().from(licenseKey);
|
const licenseKeys = await db.select().from(licenseKey);
|
||||||
|
const certs = await db.select().from(certificates);
|
||||||
|
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||||
|
const webhookActions = await db.select().from(alertWebhookActions);
|
||||||
|
|
||||||
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
|
console.log(`Found ${certs.length} certificate(s)`);
|
||||||
|
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||||
|
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
||||||
|
|
||||||
// Prepare all decrypted and re-encrypted values
|
// Prepare all decrypted and re-encrypted values
|
||||||
console.log("\nDecrypting and re-encrypting values...");
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
@@ -149,8 +155,27 @@ export const rotateServerSecret: CommandModule<
|
|||||||
encryptedInstanceId: string;
|
encryptedInstanceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CertUpdate = {
|
||||||
|
certId: number;
|
||||||
|
encryptedCertFile: string | null;
|
||||||
|
encryptedKeyFile: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StreamingDestinationUpdate = {
|
||||||
|
destinationId: number;
|
||||||
|
encryptedConfig: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebhookActionUpdate = {
|
||||||
|
webhookActionId: number;
|
||||||
|
encryptedConfig: string;
|
||||||
|
};
|
||||||
|
|
||||||
const idpUpdates: IdpUpdate[] = [];
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
|
const certUpdates: CertUpdate[] = [];
|
||||||
|
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||||
|
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||||
|
|
||||||
// Process idpOidcConfig entries
|
// Process idpOidcConfig entries
|
||||||
for (const idpConfig of idpConfigs) {
|
for (const idpConfig of idpConfigs) {
|
||||||
@@ -217,6 +242,70 @@ export const rotateServerSecret: CommandModule<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process certificate entries
|
||||||
|
for (const cert of certs) {
|
||||||
|
try {
|
||||||
|
const encryptedCertFile = cert.certFile
|
||||||
|
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
|
||||||
|
: null;
|
||||||
|
const encryptedKeyFile = cert.keyFile
|
||||||
|
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
certUpdates.push({
|
||||||
|
certId: cert.certId,
|
||||||
|
encryptedCertFile,
|
||||||
|
encryptedKeyFile
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing certificate ${cert.certId} (${cert.domain}):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process eventStreamingDestinations entries
|
||||||
|
for (const dest of streamingDestinations) {
|
||||||
|
try {
|
||||||
|
const decryptedConfig = decrypt(dest.config, oldSecret);
|
||||||
|
const encryptedConfig = encrypt(decryptedConfig, newSecret);
|
||||||
|
|
||||||
|
streamingDestinationUpdates.push({
|
||||||
|
destinationId: dest.destinationId,
|
||||||
|
encryptedConfig
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing event streaming destination ${dest.destinationId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process alertWebhookActions entries
|
||||||
|
for (const webhook of webhookActions) {
|
||||||
|
try {
|
||||||
|
if (webhook.config == null) continue;
|
||||||
|
|
||||||
|
const decryptedConfig = decrypt(webhook.config, oldSecret);
|
||||||
|
const encryptedConfig = encrypt(decryptedConfig, newSecret);
|
||||||
|
|
||||||
|
webhookActionUpdates.push({
|
||||||
|
webhookActionId: webhook.webhookActionId,
|
||||||
|
encryptedConfig
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing alert webhook action ${webhook.webhookActionId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Perform all database updates in a single transaction
|
// Perform all database updates in a single transaction
|
||||||
console.log("\nUpdating database in transaction...");
|
console.log("\nUpdating database in transaction...");
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -250,10 +339,50 @@ export const rotateServerSecret: CommandModule<
|
|||||||
instanceId: update.encryptedInstanceId
|
instanceId: update.encryptedInstanceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update certificate entries
|
||||||
|
for (const update of certUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: update.encryptedCertFile,
|
||||||
|
keyFile: update.encryptedKeyFile
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, update.certId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update event streaming destination entries
|
||||||
|
for (const update of streamingDestinationUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(eventStreamingDestinations)
|
||||||
|
.set({ config: update.encryptedConfig })
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
eventStreamingDestinations.destinationId,
|
||||||
|
update.destinationId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update alert webhook action entries
|
||||||
|
for (const update of webhookActionUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(alertWebhookActions)
|
||||||
|
.set({ config: update.encryptedConfig })
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
alertWebhookActions.webhookActionId,
|
||||||
|
update.webhookActionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
|
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
|
||||||
|
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
||||||
|
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||||
|
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
||||||
|
|
||||||
// Update config file with new secret
|
// Update config file with new secret
|
||||||
console.log("\nUpdating config file...");
|
console.log("\nUpdating config file...");
|
||||||
@@ -270,6 +399,9 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(`\nSummary:`);
|
console.log(`\nSummary:`);
|
||||||
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
|
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
|
||||||
console.log(` - License keys: ${licenseKeyUpdates.length}`);
|
console.log(` - License keys: ${licenseKeyUpdates.length}`);
|
||||||
|
console.log(` - Certificates: ${certUpdates.length}`);
|
||||||
|
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||||
|
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
|
|||||||
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||||
import { deleteClient } from "./commands/deleteClient";
|
import { deleteClient } from "./commands/deleteClient";
|
||||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||||
|
import { clearCertificates } from "./commands/clearCertificates";
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
@@ -19,5 +20,6 @@ yargs(hideBin(process.argv))
|
|||||||
.command(clearLicenseKeys)
|
.command(clearLicenseKeys)
|
||||||
.command(deleteClient)
|
.command(deleteClient)
|
||||||
.command(generateOrgCaKeys)
|
.command(generateOrgCaKeys)
|
||||||
|
.command(clearCertificates)
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
12
docker-compose.mailpit.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
mailer:
|
||||||
|
image: axllent/mailpit
|
||||||
|
ports:
|
||||||
|
- 8025:8025
|
||||||
|
- 1025:1025
|
||||||
|
volumes:
|
||||||
|
- mailpit-storage:/data
|
||||||
|
environment:
|
||||||
|
- MP_DATABASE=/data/mailpit.db
|
||||||
|
volumes:
|
||||||
|
mailpit-storage:
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.",
|
"subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.",
|
||||||
"trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.",
|
"trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.",
|
||||||
"trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.",
|
"trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.",
|
||||||
|
"billingTrialBannerTitle": "Пробният период е активен",
|
||||||
|
"billingTrialBannerDescription": "В момента сте в пробен период на бизнес ниво. След края на пробния период, вашият акаунт автоматично ще бъде върнат към функциите и ограниченията на основното ниво. Надградете по всяко време, за да запазите достъпа до текущите функции на плана.",
|
||||||
|
"billingTrialBannerUpgrade": "Надградете сега",
|
||||||
|
"billingTrialBadge": "Пробен период",
|
||||||
"trialActive": "Активен пробен период",
|
"trialActive": "Активен пробен период",
|
||||||
"trialExpired": "Пробният период е изтекъл",
|
"trialExpired": "Пробният период е изтекъл",
|
||||||
"trialHasEnded": "Пробният Ви период е приключил.",
|
"trialHasEnded": "Пробният Ви период е приключил.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Крайна точка",
|
"newtEndpoint": "Крайна точка",
|
||||||
"newtId": "Идентификационен номер",
|
"newtId": "Идентификационен номер",
|
||||||
"newtSecretKey": "Секретен ключ",
|
"newtSecretKey": "Секретен ключ",
|
||||||
|
"newtVersion": "Версия",
|
||||||
"architecture": "Архитектура",
|
"architecture": "Архитектура",
|
||||||
"sites": "Сайтове",
|
"sites": "Сайтове",
|
||||||
"siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.",
|
"siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Създаване на админ акаунт",
|
"createAdminAccount": "Създаване на админ акаунт",
|
||||||
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
|
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
|
||||||
"loading": "Зареждане",
|
"loading": "Зареждане",
|
||||||
"loadingAnalytics": "Зареждане на анализи",
|
"loadingAnalytics": "Зареждане на анализи",
|
||||||
"restart": "Рестарт",
|
"restart": "Рестарт",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието",
|
"pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието",
|
||||||
"newtUpdateAvailable": "Ново обновление",
|
"newtUpdateAvailable": "Ново обновление",
|
||||||
"newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.",
|
"newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.",
|
||||||
"domainPickerEnterDomain": "Домейн",
|
"domainPickerEnterDomain": "Домейн",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.",
|
"domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите",
|
"orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите",
|
||||||
"orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.",
|
"orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.",
|
||||||
"orgAuthSignInWithPangolin": "Впишете се с Pangolin",
|
"orgAuthSignInWithPangolin": "Впишете се с Pangolin",
|
||||||
"orgAuthSignInToOrg": "Влезте в организация",
|
"orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Вход в организация.",
|
"orgAuthSelectOrgTitle": "Вход в организация.",
|
||||||
"orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.",
|
"orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.",
|
||||||
"orgAuthOrgIdPlaceholder": "вашата-организация",
|
"orgAuthOrgIdPlaceholder": "вашата-организация",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "Няма валидни методи за удостоверение",
|
"noMoreAuthMethods": "Няма валидни методи за удостоверение",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Причина",
|
"reason": "Причина",
|
||||||
"requestLogs": "Заявка за логове",
|
"requestLogs": "Логове за HTTP заявки",
|
||||||
"requestAnalytics": "Анализи На Заявки",
|
"requestAnalytics": "Анализи На Заявки",
|
||||||
"host": "Хост",
|
"host": "Хост",
|
||||||
"location": "Местоположение",
|
"location": "Местоположение",
|
||||||
"actionLogs": "Дневници на действията",
|
"actionLogs": "Дневници на действията",
|
||||||
"sidebarLogsRequest": "Заявка за логове",
|
"sidebarLogsRequest": "Логове за HTTP заявки",
|
||||||
"sidebarLogsAccess": "Достъп до логове",
|
"sidebarLogsAccess": "Достъп до логове",
|
||||||
"sidebarLogsAction": "Дневници на действията",
|
"sidebarLogsAction": "Дневници на действията",
|
||||||
"logRetention": "Задържане на логове",
|
"logRetention": "Задържане на логове",
|
||||||
"logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте",
|
"logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте",
|
||||||
"requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация",
|
"requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация",
|
||||||
"requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация",
|
"requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация",
|
||||||
"logRetentionRequestLabel": "Задържане на логове на заявки",
|
"logRetentionRequestLabel": "Задържане на логове за HTTP заявки",
|
||||||
"logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките",
|
"logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките",
|
||||||
"logRetentionAccessLabel": "Задържане на логове за достъп",
|
"logRetentionAccessLabel": "Задържане на логове за достъп",
|
||||||
"logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп",
|
"logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.",
|
"httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.",
|
||||||
"httpDestConnectionLogsTitle": "Логове на връзката",
|
"httpDestConnectionLogsTitle": "Логове на връзката",
|
||||||
"httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.",
|
"httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.",
|
||||||
"httpDestRequestLogsTitle": "Заявки за логове",
|
"httpDestRequestLogsTitle": "Логове за HTTP заявки",
|
||||||
"httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.",
|
"httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.",
|
||||||
"httpDestSaveChanges": "Запази промените",
|
"httpDestSaveChanges": "Запази промените",
|
||||||
"httpDestCreateDestination": "Създаване на дестинация",
|
"httpDestCreateDestination": "Създаване на дестинация",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.",
|
"domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.",
|
||||||
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
|
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
|
||||||
"domainPickerWildcardCertWarningLink": "Научете повече",
|
"domainPickerWildcardCertWarningLink": "Научете повече",
|
||||||
"health": "Здраве"
|
"health": "Здраве",
|
||||||
|
"domainPendingErrorTitle": "Проблем при проверка",
|
||||||
|
"memberPortalTitle": "Ресурси",
|
||||||
|
"memberPortalDescription": "Ресурси, до които имате достъп в тази организация",
|
||||||
|
"memberPortalSortBy": "Сортиране по...",
|
||||||
|
"memberPortalSortNameAsc": "Име А-Я",
|
||||||
|
"memberPortalSortNameDesc": "Име Я-А",
|
||||||
|
"memberPortalSortDomainAsc": "Домен А-Я",
|
||||||
|
"memberPortalSortDomainDesc": "Домен Я-А",
|
||||||
|
"memberPortalSortEnabledFirst": "Активирани Първи",
|
||||||
|
"memberPortalSortDisabledFirst": "Деактивирани Първи",
|
||||||
|
"memberPortalRefresh": "Обнови",
|
||||||
|
"memberPortalRefreshResources": "Обнови ресурсите",
|
||||||
|
"memberPortalFailedToLoad": "Грешка при зареждане на ресурсите",
|
||||||
|
"memberPortalFailedToLoadDescription": "Грешка при зареждане на ресурсите. Моля, проверете връзката си и опитайте отново.",
|
||||||
|
"memberPortalUnableToLoad": "Неуспешно зареждане на ресурси",
|
||||||
|
"memberPortalTryAgain": "Опитай отново",
|
||||||
|
"memberPortalNoResourcesFound": "Няма намерени ресурси",
|
||||||
|
"memberPortalNoResourcesAvailable": "Няма налични ресурси",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Няма ресурси, съвпадащи с \"{query}\". Опитайте да промените търсените условия или нулирайте търсенето, за да видите всички ресурси.",
|
||||||
|
"memberPortalNoResourcesAccess": "Още нямате достъп до ресурси. Свържете се с вашия администратор, за да получите достъп до нужните ресурси.",
|
||||||
|
"memberPortalClearSearch": "Изчисти търсенето",
|
||||||
|
"memberPortalPublicResources": "Публични ресурси",
|
||||||
|
"memberPortalPublicResourcesDescription": "Уеб приложения и услуги, достъпни през браузър",
|
||||||
|
"memberPortalCopiedToClipboard": "Копирано в клипборда",
|
||||||
|
"memberPortalCopiedUrlDescription": "URL адресът на ресурса е копиран в клипборда.",
|
||||||
|
"memberPortalOpenResource": "Отвори ресурса",
|
||||||
|
"memberPortalPrivateResources": "Частни ресурси",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Ресурси на вътрешната мрежа, достъпни чрез клиент",
|
||||||
|
"memberPortalResourceDetails": "Детайли за ресурса",
|
||||||
|
"memberPortalMode": "Режим",
|
||||||
|
"memberPortalDestination": "Дестинация",
|
||||||
|
"memberPortalAlias": "Алиас",
|
||||||
|
"memberPortalCopiedAliasDescription": "Алиасът на ресурса е копиран в клипборда.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Дестинацията на ресурса е копирана в клипборда.",
|
||||||
|
"memberPortalRequiresClientConnection": "Изисква връзка с клиента",
|
||||||
|
"memberPortalAuthMethods": "Методи на удостоверяване",
|
||||||
|
"memberPortalSso": "Единно вход (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Защитено с парола",
|
||||||
|
"memberPortalPinCode": "ПИН код",
|
||||||
|
"memberPortalEmailWhitelist": "Бял списък на имейли",
|
||||||
|
"memberPortalResourceDisabled": "Ресурсът е деактивиран",
|
||||||
|
"memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси",
|
||||||
|
"memberPortalPrevious": "Предишен",
|
||||||
|
"memberPortalNext": "Следващ"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.",
|
"subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.",
|
||||||
"trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.",
|
"trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.",
|
||||||
"trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.",
|
"trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.",
|
||||||
|
"billingTrialBannerTitle": "Aktivní zkušební verze",
|
||||||
|
"billingTrialBannerDescription": "Právě používáte zkušební verzi na úrovni business. Po skončení zkušební verze se váš účet automaticky vrátí k funkcím a limitům úrovně Basic. Upgradujte kdykoli pro zachování přístupu k funkcím vašeho aktuálního plánu.",
|
||||||
|
"billingTrialBannerUpgrade": "Upgradovat nyní",
|
||||||
|
"billingTrialBadge": "Zkušební verze",
|
||||||
"trialActive": "Zkušební verze je aktivní",
|
"trialActive": "Zkušební verze je aktivní",
|
||||||
"trialExpired": "Zkušební verze vypršela",
|
"trialExpired": "Zkušební verze vypršela",
|
||||||
"trialHasEnded": "Vaše zkušební verze skončila.",
|
"trialHasEnded": "Vaše zkušební verze skončila.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Tajný klíč",
|
"newtSecretKey": "Tajný klíč",
|
||||||
|
"newtVersion": "Verze",
|
||||||
"architecture": "Architektura",
|
"architecture": "Architektura",
|
||||||
"sites": "Stránky",
|
"sites": "Stránky",
|
||||||
"siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.",
|
"siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Vytvořit účet správce",
|
"createAdminAccount": "Vytvořit účet správce",
|
||||||
"setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.",
|
"setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.",
|
||||||
"certificateStatus": "Certifikát",
|
"certificateStatus": "Certifikát",
|
||||||
|
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
|
||||||
"loading": "Načítání",
|
"loading": "Načítání",
|
||||||
"loadingAnalytics": "Načítání analytiky",
|
"loadingAnalytics": "Načítání analytiky",
|
||||||
"restart": "Restartovat",
|
"restart": "Restartovat",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání",
|
"pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání",
|
||||||
"newtUpdateAvailable": "Dostupná aktualizace",
|
"newtUpdateAvailable": "Dostupná aktualizace",
|
||||||
"newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
|
"newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Je k dispozici nová verze uzlu Pangolin. Pro nejlepší zážitek aktualizujte na nejnovější verzi.",
|
||||||
"domainPickerEnterDomain": "Doména",
|
"domainPickerEnterDomain": "Doména",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.",
|
"domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity",
|
"orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity",
|
||||||
"orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.",
|
"orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.",
|
||||||
"orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu",
|
"orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu",
|
||||||
"orgAuthSignInToOrg": "Přihlásit se do organizace",
|
"orgAuthSignInToOrg": "Poskytovatel identity organizace (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Přihlášení do organizace",
|
"orgAuthSelectOrgTitle": "Přihlášení do organizace",
|
||||||
"orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování",
|
"orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování",
|
||||||
"orgAuthOrgIdPlaceholder": "vaše-organizace",
|
"orgAuthOrgIdPlaceholder": "vaše-organizace",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP adresa",
|
"ip": "IP adresa",
|
||||||
"reason": "Důvod",
|
"reason": "Důvod",
|
||||||
"requestLogs": "Záznamy požadavků",
|
"requestLogs": "Záznamy HTTP požadavků",
|
||||||
"requestAnalytics": "Vyžádat analýzu",
|
"requestAnalytics": "Vyžádat analýzu",
|
||||||
"host": "Hostitel",
|
"host": "Hostitel",
|
||||||
"location": "Poloha",
|
"location": "Poloha",
|
||||||
"actionLogs": "Záznamy akcí",
|
"actionLogs": "Záznamy akcí",
|
||||||
"sidebarLogsRequest": "Záznamy požadavků",
|
"sidebarLogsRequest": "Záznamy HTTP požadavků",
|
||||||
"sidebarLogsAccess": "Protokoly přístupu",
|
"sidebarLogsAccess": "Protokoly přístupu",
|
||||||
"sidebarLogsAction": "Záznamy akcí",
|
"sidebarLogsAction": "Záznamy akcí",
|
||||||
"logRetention": "Zaznamenávání záznamu",
|
"logRetention": "Zaznamenávání záznamu",
|
||||||
"logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat",
|
"logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat",
|
||||||
"requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci",
|
"requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci",
|
||||||
"requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci",
|
"requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci",
|
||||||
"logRetentionRequestLabel": "Zachování logu žádosti",
|
"logRetentionRequestLabel": "Zachování logu HTTP požadavků",
|
||||||
"logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků",
|
"logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků",
|
||||||
"logRetentionAccessLabel": "Zachování záznamu přístupu",
|
"logRetentionAccessLabel": "Zachování záznamu přístupu",
|
||||||
"logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy",
|
"logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.",
|
"httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.",
|
||||||
"httpDestConnectionLogsTitle": "Protokoly připojení",
|
"httpDestConnectionLogsTitle": "Protokoly připojení",
|
||||||
"httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.",
|
"httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.",
|
||||||
"httpDestRequestLogsTitle": "Záznamy požadavků",
|
"httpDestRequestLogsTitle": "Záznamy HTTP požadavků",
|
||||||
"httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.",
|
"httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.",
|
||||||
"httpDestSaveChanges": "Uložit změny",
|
"httpDestSaveChanges": "Uložit změny",
|
||||||
"httpDestCreateDestination": "Vytvořit cíl",
|
"httpDestCreateDestination": "Vytvořit cíl",
|
||||||
@@ -3167,7 +3174,7 @@
|
|||||||
"publicIpEndpoint": "Koncový bod",
|
"publicIpEndpoint": "Koncový bod",
|
||||||
"lastTriggeredAt": "Poslední spouštěč",
|
"lastTriggeredAt": "Poslední spouštěč",
|
||||||
"reject": "Odmítnout",
|
"reject": "Odmítnout",
|
||||||
"uptimeDaysAgo": "{count} days ago",
|
"uptimeDaysAgo": "Před {count} dny",
|
||||||
"uptimeToday": "Dnes",
|
"uptimeToday": "Dnes",
|
||||||
"uptimeNoDataAvailable": "Dostupná žádná data",
|
"uptimeNoDataAvailable": "Dostupná žádná data",
|
||||||
"uptimeSuffix": "doba dostupnosti",
|
"uptimeSuffix": "doba dostupnosti",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Zástupné poddomény nejsou povoleny.",
|
"domainPickerWildcardSubdomainNotAllowed": "Zástupné poddomény nejsou povoleny.",
|
||||||
"domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.",
|
"domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.",
|
||||||
"domainPickerWildcardCertWarningLink": "Zjistit více",
|
"domainPickerWildcardCertWarningLink": "Zjistit více",
|
||||||
"health": "Zdraví"
|
"health": "Zdraví",
|
||||||
|
"domainPendingErrorTitle": "Problém s ověřením",
|
||||||
|
"memberPortalTitle": "Zdroje",
|
||||||
|
"memberPortalDescription": "Zdroje, ke kterým máte v této organizaci přístup",
|
||||||
|
"memberPortalSortBy": "Řadit podle...",
|
||||||
|
"memberPortalSortNameAsc": "Názvu A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Názvu Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domény A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domény Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Nejprve povoleno",
|
||||||
|
"memberPortalSortDisabledFirst": "Nejprve zakázáno",
|
||||||
|
"memberPortalRefresh": "Aktualizovat",
|
||||||
|
"memberPortalRefreshResources": "Aktualizovat zdroje",
|
||||||
|
"memberPortalFailedToLoad": "Nepodařilo se načíst zdroje",
|
||||||
|
"memberPortalFailedToLoadDescription": "Nepodařilo se načíst zdroje. Zkontrolujte prosím své připojení a zkuste to znovu.",
|
||||||
|
"memberPortalUnableToLoad": "Nelze načíst zdroje",
|
||||||
|
"memberPortalTryAgain": "Zkusit znovu",
|
||||||
|
"memberPortalNoResourcesFound": "Žádné zdroje nebyly nalezeny",
|
||||||
|
"memberPortalNoResourcesAvailable": "Žádné zdroje nejsou k dispozici",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Žádné zdroje neodpovídají \"{query}\". Zkuste přizpůsobit své vyhledávací termíny nebo vyčistit hledání, abyste viděli všechny zdroje.",
|
||||||
|
"memberPortalNoResourcesAccess": "Zatím nemáte přístup k žádným zdrojům. Kontaktujte svého správce, aby vám poskytl přístup k potřebným zdrojům.",
|
||||||
|
"memberPortalClearSearch": "Vymazat hledání",
|
||||||
|
"memberPortalPublicResources": "Veřejné zdroje",
|
||||||
|
"memberPortalPublicResourcesDescription": "Webové aplikace a služby přístupné přes prohlížeč",
|
||||||
|
"memberPortalCopiedToClipboard": "Zkopírováno do schránky",
|
||||||
|
"memberPortalCopiedUrlDescription": "URL zdroje byla zkopírována do vaší schránky.",
|
||||||
|
"memberPortalOpenResource": "Otevřít zdroj",
|
||||||
|
"memberPortalPrivateResources": "Soukromé zdroje",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Interní síťové zdroje přístupné přes klienta",
|
||||||
|
"memberPortalResourceDetails": "Podrobnosti o zdroji",
|
||||||
|
"memberPortalMode": "Režim",
|
||||||
|
"memberPortalDestination": "Cíl",
|
||||||
|
"memberPortalAlias": "Přezdívka",
|
||||||
|
"memberPortalCopiedAliasDescription": "Alias zdroje byl zkopírován do vaší schránky.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Cíl zdroje byl zkopírován do vaší schránky.",
|
||||||
|
"memberPortalRequiresClientConnection": "Vyžaduje klientské připojení",
|
||||||
|
"memberPortalAuthMethods": "Metody ověřování",
|
||||||
|
"memberPortalSso": "Jedno přihlášení (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Heslo chráněno",
|
||||||
|
"memberPortalPinCode": "PIN kód",
|
||||||
|
"memberPortalEmailWhitelist": "Seznam povolených emailů",
|
||||||
|
"memberPortalResourceDisabled": "Zdroj je zakázán",
|
||||||
|
"memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů",
|
||||||
|
"memberPortalPrevious": "Předchozí",
|
||||||
|
"memberPortalNext": "Následující"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
|
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
|
||||||
"trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.",
|
"trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.",
|
||||||
"trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.",
|
"trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.",
|
||||||
|
"billingTrialBannerTitle": "Kostenlose Testversion aktiv",
|
||||||
|
"billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Geschäftsstufe. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basisstufe zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.",
|
||||||
|
"billingTrialBannerUpgrade": "Jetzt upgraden",
|
||||||
|
"billingTrialBadge": "Kostenlose Testversion",
|
||||||
"trialActive": "Kostenlose Testversion aktiv",
|
"trialActive": "Kostenlose Testversion aktiv",
|
||||||
"trialExpired": "Testversion abgelaufen",
|
"trialExpired": "Testversion abgelaufen",
|
||||||
"trialHasEnded": "Ihre Testversion ist beendet.",
|
"trialHasEnded": "Ihre Testversion ist beendet.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpunkt",
|
"newtEndpoint": "Endpunkt",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Geheimnis",
|
"newtSecretKey": "Geheimnis",
|
||||||
|
"newtVersion": "Version",
|
||||||
"architecture": "Architektur",
|
"architecture": "Architektur",
|
||||||
"sites": "Standorte",
|
"sites": "Standorte",
|
||||||
"siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.",
|
"siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.",
|
||||||
@@ -1432,7 +1437,7 @@
|
|||||||
"alertingTriggerHcToggle": "Gesundheits-Check-Status ändern",
|
"alertingTriggerHcToggle": "Gesundheits-Check-Status ändern",
|
||||||
"alertingTriggerResourceHealthy": "Ressource gesund",
|
"alertingTriggerResourceHealthy": "Ressource gesund",
|
||||||
"alertingTriggerResourceUnhealthy": "Ressource ungesund",
|
"alertingTriggerResourceUnhealthy": "Ressource ungesund",
|
||||||
"alertingTriggerResourceDegraded": "Resource degraded",
|
"alertingTriggerResourceDegraded": "Ressource verschlechtert",
|
||||||
"alertingSearchHealthChecks": "Gesundheits-Checks suchen…",
|
"alertingSearchHealthChecks": "Gesundheits-Checks suchen…",
|
||||||
"alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.",
|
"alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.",
|
||||||
"alertingTriggerResourceToggle": "Ressourcenstatus ändern",
|
"alertingTriggerResourceToggle": "Ressourcenstatus ändern",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Admin-Konto erstellen",
|
"createAdminAccount": "Admin-Konto erstellen",
|
||||||
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
|
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
|
||||||
"certificateStatus": "Zertifikat",
|
"certificateStatus": "Zertifikat",
|
||||||
|
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
|
||||||
"loading": "Laden",
|
"loading": "Laden",
|
||||||
"loadingAnalytics": "Analytik wird geladen",
|
"loadingAnalytics": "Analytik wird geladen",
|
||||||
"restart": "Neustart",
|
"restart": "Neustart",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen",
|
"pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen",
|
||||||
"newtUpdateAvailable": "Update verfügbar",
|
"newtUpdateAvailable": "Update verfügbar",
|
||||||
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Eine neue Version von Pangolin Node ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
||||||
"domainPickerEnterDomain": "Domäne",
|
"domainPickerEnterDomain": "Domäne",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.",
|
"domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren",
|
"orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren",
|
||||||
"orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.",
|
"orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.",
|
||||||
"orgAuthSignInWithPangolin": "Mit Pangolin anmelden",
|
"orgAuthSignInWithPangolin": "Mit Pangolin anmelden",
|
||||||
"orgAuthSignInToOrg": "Bei einer Organisation anmelden",
|
"orgAuthSignInToOrg": "Organisations-Identitätsanbieter (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Organisations-Anmeldung",
|
"orgAuthSelectOrgTitle": "Organisations-Anmeldung",
|
||||||
"orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren",
|
"orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren",
|
||||||
"orgAuthOrgIdPlaceholder": "Ihre Organisation",
|
"orgAuthOrgIdPlaceholder": "Ihre Organisation",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar",
|
"noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Grund",
|
"reason": "Grund",
|
||||||
"requestLogs": "Logs anfordern",
|
"requestLogs": "HTTP Anforderungsprotokolle",
|
||||||
"requestAnalytics": "Anfrage-Analyse anzeigen",
|
"requestAnalytics": "Anfrage-Analyse anzeigen",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"location": "Standort",
|
"location": "Standort",
|
||||||
"actionLogs": "Aktionsprotokolle",
|
"actionLogs": "Aktionsprotokolle",
|
||||||
"sidebarLogsRequest": "Logs anfordern",
|
"sidebarLogsRequest": "HTTP Anforderungsprotokolle",
|
||||||
"sidebarLogsAccess": "Zugriffsprotokolle",
|
"sidebarLogsAccess": "Zugriffsprotokolle",
|
||||||
"sidebarLogsAction": "Aktionsprotokolle",
|
"sidebarLogsAction": "Aktionsprotokolle",
|
||||||
"logRetention": "Log-Speicherung",
|
"logRetention": "Log-Speicherung",
|
||||||
"logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren",
|
"logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren",
|
||||||
"requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen",
|
"requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen",
|
||||||
"requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen",
|
"requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen",
|
||||||
"logRetentionRequestLabel": "Log-Speicherung anfordern",
|
"logRetentionRequestLabel": "HTTP Anforderungsprotokoll Aufbewahrung",
|
||||||
"logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden",
|
"logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden",
|
||||||
"logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung",
|
"logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung",
|
||||||
"logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen",
|
"logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.",
|
"httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.",
|
||||||
"httpDestConnectionLogsTitle": "Verbindungsprotokolle",
|
"httpDestConnectionLogsTitle": "Verbindungsprotokolle",
|
||||||
"httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.",
|
"httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.",
|
||||||
"httpDestRequestLogsTitle": "Logs anfordern",
|
"httpDestRequestLogsTitle": "HTTP Anforderungsprotokolle",
|
||||||
"httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.",
|
"httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.",
|
||||||
"httpDestSaveChanges": "Änderungen speichern",
|
"httpDestSaveChanges": "Änderungen speichern",
|
||||||
"httpDestCreateDestination": "Ziel erstellen",
|
"httpDestCreateDestination": "Ziel erstellen",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Wildcard-Subdomains sind nicht erlaubt.",
|
"domainPickerWildcardSubdomainNotAllowed": "Wildcard-Subdomains sind nicht erlaubt.",
|
||||||
"domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.",
|
"domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.",
|
||||||
"domainPickerWildcardCertWarningLink": "Mehr erfahren",
|
"domainPickerWildcardCertWarningLink": "Mehr erfahren",
|
||||||
"health": "Gesundheit"
|
"health": "Gesundheit",
|
||||||
|
"domainPendingErrorTitle": "Verifizierungsproblem",
|
||||||
|
"memberPortalTitle": "Ressourcen",
|
||||||
|
"memberPortalDescription": "Ressourcen, auf die Sie in dieser Organisation Zugriff haben",
|
||||||
|
"memberPortalSortBy": "Sortieren nach...",
|
||||||
|
"memberPortalSortNameAsc": "Name A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Name Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domain A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domain Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Zuerst aktiviert",
|
||||||
|
"memberPortalSortDisabledFirst": "Zuerst deaktiviert",
|
||||||
|
"memberPortalRefresh": "Aktualisieren",
|
||||||
|
"memberPortalRefreshResources": "Ressourcen aktualisieren",
|
||||||
|
"memberPortalFailedToLoad": "Fehler beim Laden der Ressourcen",
|
||||||
|
"memberPortalFailedToLoadDescription": "Fehler beim Laden der Ressourcen. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||||
|
"memberPortalUnableToLoad": "Ressourcen konnten nicht geladen werden",
|
||||||
|
"memberPortalTryAgain": "Nochmal versuchen",
|
||||||
|
"memberPortalNoResourcesFound": "Keine Ressourcen gefunden",
|
||||||
|
"memberPortalNoResourcesAvailable": "Keine Ressourcen verfügbar",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Keine Ressourcen passen zu \"{query}\". Versuchen Sie, Ihre Suchbegriffe anzupassen oder die Suche zu löschen, um alle Ressourcen anzuzeigen.",
|
||||||
|
"memberPortalNoResourcesAccess": "Sie haben noch keinen Zugriff auf Ressourcen. Wenden Sie sich an Ihren Administrator, um Zugriff auf die benötigten Ressourcen zu erhalten.",
|
||||||
|
"memberPortalClearSearch": "Suchverlauf löschen",
|
||||||
|
"memberPortalPublicResources": "Öffentliche Ressourcen",
|
||||||
|
"memberPortalPublicResourcesDescription": "Webanwendungen und Dienste, die über den Browser zugänglich sind",
|
||||||
|
"memberPortalCopiedToClipboard": "In die Zwischenablage kopiert",
|
||||||
|
"memberPortalCopiedUrlDescription": "Ressourcen-URL wurde in Ihre Zwischenablage kopiert.",
|
||||||
|
"memberPortalOpenResource": "Ressource öffnen",
|
||||||
|
"memberPortalPrivateResources": "Private Ressourcen",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Interne Netzwerkressourcen, die über den Client zugänglich sind",
|
||||||
|
"memberPortalResourceDetails": "Ressourcendetails",
|
||||||
|
"memberPortalMode": "Modus",
|
||||||
|
"memberPortalDestination": "Ziel",
|
||||||
|
"memberPortalAlias": "Alias",
|
||||||
|
"memberPortalCopiedAliasDescription": "Ressourcenalias wurde in Ihre Zwischenablage kopiert.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Ressourcenziel wurde in Ihre Zwischenablage kopiert.",
|
||||||
|
"memberPortalRequiresClientConnection": "Erfordert Client-Verbindung",
|
||||||
|
"memberPortalAuthMethods": "Authentifizierungsmethoden",
|
||||||
|
"memberPortalSso": "Single Sign-On (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Passwortgeschützt",
|
||||||
|
"memberPortalPinCode": "PIN-Code",
|
||||||
|
"memberPortalEmailWhitelist": "E-Mail-Whitelist",
|
||||||
|
"memberPortalResourceDisabled": "Ressource deaktiviert",
|
||||||
|
"memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen",
|
||||||
|
"memberPortalPrevious": "Vorherige",
|
||||||
|
"memberPortalNext": "Nächste"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
|
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
|
||||||
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
|
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
|
||||||
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
|
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
|
||||||
|
"billingTrialBannerTitle": "Free Trial Active",
|
||||||
|
"billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.",
|
||||||
|
"billingTrialBannerUpgrade": "Upgrade Now",
|
||||||
|
"billingTrialBadge": "Free Trial",
|
||||||
"trialActive": "Free Trial Active",
|
"trialActive": "Free Trial Active",
|
||||||
"trialExpired": "Trial Expired",
|
"trialExpired": "Trial Expired",
|
||||||
"trialHasEnded": "Your trial has ended.",
|
"trialHasEnded": "Your trial has ended.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Secret",
|
"newtSecretKey": "Secret",
|
||||||
|
"newtVersion": "Version",
|
||||||
"architecture": "Architecture",
|
"architecture": "Architecture",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.",
|
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Create Admin Account",
|
"createAdminAccount": "Create Admin Account",
|
||||||
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
|
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
|
||||||
"certificateStatus": "Certificate",
|
"certificateStatus": "Certificate",
|
||||||
|
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"loadingAnalytics": "Loading Analytics",
|
"loadingAnalytics": "Loading Analytics",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "View Release Notes",
|
"pangolinUpdateAvailableReleaseNotes": "View Release Notes",
|
||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "A new version of Pangolin Node is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
|
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
|
||||||
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
|
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
|
||||||
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
|
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
|
||||||
"orgAuthSignInToOrg": "Sign in to an organization",
|
"orgAuthSignInToOrg": "Organization Identity Provider (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Organization Sign In",
|
"orgAuthSelectOrgTitle": "Organization Sign In",
|
||||||
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
|
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
|
||||||
"orgAuthOrgIdPlaceholder": "your-organization",
|
"orgAuthOrgIdPlaceholder": "your-organization",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"requestLogs": "HTTPS Request Logs",
|
"requestLogs": "HTTP Request Logs",
|
||||||
"requestAnalytics": "Request Analytics",
|
"requestAnalytics": "Request Analytics",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
"actionLogs": "Admin Action Logs",
|
"actionLogs": "Admin Action Logs",
|
||||||
"sidebarLogsRequest": "HTTPS Request Logs",
|
"sidebarLogsRequest": "HTTP Request Logs",
|
||||||
"sidebarLogsAccess": "Authentication Logs",
|
"sidebarLogsAccess": "Authentication Logs",
|
||||||
"sidebarLogsAction": "Admin Action Logs",
|
"sidebarLogsAction": "Admin Action Logs",
|
||||||
"logRetention": "Log Retention",
|
"logRetention": "Log Retention",
|
||||||
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
||||||
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
|
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
|
||||||
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
|
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
|
||||||
"logRetentionRequestLabel": "HTTPS Request Log Retention",
|
"logRetentionRequestLabel": "HTTP Request Log Retention",
|
||||||
"logRetentionRequestDescription": "How long to retain request logs",
|
"logRetentionRequestDescription": "How long to retain request logs",
|
||||||
"logRetentionAccessLabel": "Authentication Log Retention",
|
"logRetentionAccessLabel": "Authentication Log Retention",
|
||||||
"logRetentionAccessDescription": "How long to retain access logs",
|
"logRetentionAccessDescription": "How long to retain access logs",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Administrative actions performed by users within the organization.",
|
"httpDestActionLogsDescription": "Administrative actions performed by users within the organization.",
|
||||||
"httpDestConnectionLogsTitle": "Network Logs",
|
"httpDestConnectionLogsTitle": "Network Logs",
|
||||||
"httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.",
|
"httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.",
|
||||||
"httpDestRequestLogsTitle": "HTTPS Request Logs",
|
"httpDestRequestLogsTitle": "HTTP Request Logs",
|
||||||
"httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.",
|
"httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.",
|
||||||
"httpDestSaveChanges": "Save Changes",
|
"httpDestSaveChanges": "Save Changes",
|
||||||
"httpDestCreateDestination": "Create Destination",
|
"httpDestCreateDestination": "Create Destination",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.",
|
"domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.",
|
||||||
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
|
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
|
||||||
"domainPickerWildcardCertWarningLink": "Learn more",
|
"domainPickerWildcardCertWarningLink": "Learn more",
|
||||||
"health": "Health"
|
"health": "Health",
|
||||||
|
"domainPendingErrorTitle": "Verification Issue",
|
||||||
|
"memberPortalTitle": "Resources",
|
||||||
|
"memberPortalDescription": "Resources you have access to in this organization",
|
||||||
|
"memberPortalSortBy": "Sort by...",
|
||||||
|
"memberPortalSortNameAsc": "Name A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Name Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domain A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domain Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Enabled First",
|
||||||
|
"memberPortalSortDisabledFirst": "Disabled First",
|
||||||
|
"memberPortalRefresh": "Refresh",
|
||||||
|
"memberPortalRefreshResources": "Refresh Resources",
|
||||||
|
"memberPortalFailedToLoad": "Failed to load resources",
|
||||||
|
"memberPortalFailedToLoadDescription": "Failed to load resources. Please check your connection and try again.",
|
||||||
|
"memberPortalUnableToLoad": "Unable to Load Resources",
|
||||||
|
"memberPortalTryAgain": "Try Again",
|
||||||
|
"memberPortalNoResourcesFound": "No Resources Found",
|
||||||
|
"memberPortalNoResourcesAvailable": "No Resources Available",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "No resources match \"{query}\". Try adjusting your search terms or clearing the search to see all resources.",
|
||||||
|
"memberPortalNoResourcesAccess": "You don't have access to any resources yet. Contact your administrator to get access to resources you need.",
|
||||||
|
"memberPortalClearSearch": "Clear Search",
|
||||||
|
"memberPortalPublicResources": "Public Resources",
|
||||||
|
"memberPortalPublicResourcesDescription": "Web applications and services accessible via browser",
|
||||||
|
"memberPortalCopiedToClipboard": "Copied to clipboard",
|
||||||
|
"memberPortalCopiedUrlDescription": "Resource URL has been copied to your clipboard.",
|
||||||
|
"memberPortalOpenResource": "Open Resource",
|
||||||
|
"memberPortalPrivateResources": "Private Resources",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Internal network resources accessible via client",
|
||||||
|
"memberPortalResourceDetails": "Resource Details",
|
||||||
|
"memberPortalMode": "Mode",
|
||||||
|
"memberPortalDestination": "Destination",
|
||||||
|
"memberPortalAlias": "Alias",
|
||||||
|
"memberPortalCopiedAliasDescription": "Resource alias has been copied to your clipboard.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Resource destination has been copied to your clipboard.",
|
||||||
|
"memberPortalRequiresClientConnection": "Requires Client Connection",
|
||||||
|
"memberPortalAuthMethods": "Authentication Methods",
|
||||||
|
"memberPortalSso": "Single Sign-On (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Password Protected",
|
||||||
|
"memberPortalPinCode": "PIN Code",
|
||||||
|
"memberPortalEmailWhitelist": "Email Whitelist",
|
||||||
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
|
"memberPortalPrevious": "Previous",
|
||||||
|
"memberPortalNext": "Next"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.",
|
"subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.",
|
||||||
"trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.",
|
"trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.",
|
||||||
"trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.",
|
"trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.",
|
||||||
|
"billingTrialBannerTitle": "Prueba gratuita activada",
|
||||||
|
"billingTrialBannerDescription": "Actualmente estás en una prueba gratuita en el nivel empresarial. Cuando finalice la prueba, tu cuenta volverá automáticamente a las características y límites del nivel Básico. Mejora en cualquier momento para mantener el acceso a las características de tu plan actual.",
|
||||||
|
"billingTrialBannerUpgrade": "Actualizar ahora",
|
||||||
|
"billingTrialBadge": "Prueba Gratuita",
|
||||||
"trialActive": "Prueba gratuita activa",
|
"trialActive": "Prueba gratuita activa",
|
||||||
"trialExpired": "Prueba expirada",
|
"trialExpired": "Prueba expirada",
|
||||||
"trialHasEnded": "Su prueba ha terminado.",
|
"trialHasEnded": "Su prueba ha terminado.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Secreto",
|
"newtSecretKey": "Secreto",
|
||||||
|
"newtVersion": "Versión",
|
||||||
"architecture": "Arquitectura",
|
"architecture": "Arquitectura",
|
||||||
"sites": "Sitios",
|
"sites": "Sitios",
|
||||||
"siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.",
|
"siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Crear cuenta de administrador",
|
"createAdminAccount": "Crear cuenta de administrador",
|
||||||
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
|
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
|
||||||
"certificateStatus": "Certificado",
|
"certificateStatus": "Certificado",
|
||||||
|
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
|
||||||
"loading": "Cargando",
|
"loading": "Cargando",
|
||||||
"loadingAnalytics": "Cargando analíticas",
|
"loadingAnalytics": "Cargando analíticas",
|
||||||
"restart": "Reiniciar",
|
"restart": "Reiniciar",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento",
|
"pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento",
|
||||||
"newtUpdateAvailable": "Nueva actualización disponible",
|
"newtUpdateAvailable": "Nueva actualización disponible",
|
||||||
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Hay una nueva versión de Pangolin Node disponible. Actualice a la última versión para la mejor experiencia.",
|
||||||
"domainPickerEnterDomain": "Dominio",
|
"domainPickerEnterDomain": "Dominio",
|
||||||
"domainPickerPlaceholder": "miapp.ejemplo.com",
|
"domainPickerPlaceholder": "miapp.ejemplo.com",
|
||||||
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
|
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar",
|
"orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar",
|
||||||
"orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.",
|
"orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.",
|
||||||
"orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin",
|
"orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin",
|
||||||
"orgAuthSignInToOrg": "Iniciar sesión en una organización",
|
"orgAuthSignInToOrg": "Proveedor de identidad de la organización (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Inicio de sesión de organización",
|
"orgAuthSelectOrgTitle": "Inicio de sesión de organización",
|
||||||
"orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar",
|
"orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar",
|
||||||
"orgAuthOrgIdPlaceholder": "tu-organización",
|
"orgAuthOrgIdPlaceholder": "tu-organización",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Razón",
|
"reason": "Razón",
|
||||||
"requestLogs": "Registros de Solicitud",
|
"requestLogs": "Registros de Solicitud HTTP",
|
||||||
"requestAnalytics": "Analítica de Solicitud",
|
"requestAnalytics": "Analítica de Solicitud",
|
||||||
"host": "Anfitrión",
|
"host": "Anfitrión",
|
||||||
"location": "Ubicación",
|
"location": "Ubicación",
|
||||||
"actionLogs": "Registros de acción",
|
"actionLogs": "Registros de acción",
|
||||||
"sidebarLogsRequest": "Registros de Solicitud",
|
"sidebarLogsRequest": "Registros de Solicitud HTTP",
|
||||||
"sidebarLogsAccess": "Registros de acceso",
|
"sidebarLogsAccess": "Registros de acceso",
|
||||||
"sidebarLogsAction": "Registros de acción",
|
"sidebarLogsAction": "Registros de acción",
|
||||||
"logRetention": "Retención de Log",
|
"logRetention": "Retención de Log",
|
||||||
"logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos",
|
"logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos",
|
||||||
"requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización",
|
"requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización",
|
||||||
"requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización",
|
"requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización",
|
||||||
"logRetentionRequestLabel": "Retención de Registro de Solicitud",
|
"logRetentionRequestLabel": "Retención de Registro de Solicitud HTTP",
|
||||||
"logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes",
|
"logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes",
|
||||||
"logRetentionAccessLabel": "Retención de Log de Acceso",
|
"logRetentionAccessLabel": "Retención de Log de Acceso",
|
||||||
"logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso",
|
"logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.",
|
"httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.",
|
||||||
"httpDestConnectionLogsTitle": "Registros de conexión",
|
"httpDestConnectionLogsTitle": "Registros de conexión",
|
||||||
"httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.",
|
"httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.",
|
||||||
"httpDestRequestLogsTitle": "Registros de Solicitud",
|
"httpDestRequestLogsTitle": "Registros de Solicitud HTTP",
|
||||||
"httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.",
|
"httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.",
|
||||||
"httpDestSaveChanges": "Guardar Cambios",
|
"httpDestSaveChanges": "Guardar Cambios",
|
||||||
"httpDestCreateDestination": "Crear destino",
|
"httpDestCreateDestination": "Crear destino",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "No se permiten subdominios comodín.",
|
"domainPickerWildcardSubdomainNotAllowed": "No se permiten subdominios comodín.",
|
||||||
"domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.",
|
"domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.",
|
||||||
"domainPickerWildcardCertWarningLink": "Más información",
|
"domainPickerWildcardCertWarningLink": "Más información",
|
||||||
"health": "Salud"
|
"health": "Salud",
|
||||||
|
"domainPendingErrorTitle": "Problema de verificación",
|
||||||
|
"memberPortalTitle": "Recursos",
|
||||||
|
"memberPortalDescription": "Recursos a los que tiene acceso en esta organización",
|
||||||
|
"memberPortalSortBy": "Ordenar por...",
|
||||||
|
"memberPortalSortNameAsc": "Nombre A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Nombre Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Dominio A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Dominio Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Habilitado Primero",
|
||||||
|
"memberPortalSortDisabledFirst": "Deshabilitado Primero",
|
||||||
|
"memberPortalRefresh": "Actualizar",
|
||||||
|
"memberPortalRefreshResources": "Actualizar Recursos",
|
||||||
|
"memberPortalFailedToLoad": "No se pudieron cargar los recursos",
|
||||||
|
"memberPortalFailedToLoadDescription": "No se pudieron cargar los recursos. Por favor, revise su conexión e intente de nuevo.",
|
||||||
|
"memberPortalUnableToLoad": "No se pudieron cargar los recursos",
|
||||||
|
"memberPortalTryAgain": "Intentar de Nuevo",
|
||||||
|
"memberPortalNoResourcesFound": "No se encontraron Recursos",
|
||||||
|
"memberPortalNoResourcesAvailable": "No Hay Recursos Disponibles",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tus términos de búsqueda o limpiar la búsqueda para ver todos los recursos.",
|
||||||
|
"memberPortalNoResourcesAccess": "Aún no tiene acceso a ningún recurso. Comuníquese con su administrador para obtener acceso a los recursos que necesita.",
|
||||||
|
"memberPortalClearSearch": "Limpiar Búsqueda",
|
||||||
|
"memberPortalPublicResources": "Recursos Públicos",
|
||||||
|
"memberPortalPublicResourcesDescription": "Aplicaciones web y servicios accesibles vía navegador",
|
||||||
|
"memberPortalCopiedToClipboard": "Copiado al portapapeles",
|
||||||
|
"memberPortalCopiedUrlDescription": "La URL del recurso ha sido copiada a su portapapeles.",
|
||||||
|
"memberPortalOpenResource": "Abrir Recurso",
|
||||||
|
"memberPortalPrivateResources": "Recursos Privados",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Recursos de red interna accesibles vía cliente",
|
||||||
|
"memberPortalResourceDetails": "Detalles del Recurso",
|
||||||
|
"memberPortalMode": "Modo",
|
||||||
|
"memberPortalDestination": "Destino",
|
||||||
|
"memberPortalAlias": "Alias",
|
||||||
|
"memberPortalCopiedAliasDescription": "El alias del recurso ha sido copiado a su portapapeles.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "El destino del recurso ha sido copiado a su portapapeles.",
|
||||||
|
"memberPortalRequiresClientConnection": "Requiere Conexión de Cliente",
|
||||||
|
"memberPortalAuthMethods": "Métodos de Autenticación",
|
||||||
|
"memberPortalSso": "Inicio de Sesión Único (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Protegido por Contraseña",
|
||||||
|
"memberPortalPinCode": "Código PIN",
|
||||||
|
"memberPortalEmailWhitelist": "Lista Blanca de Correo",
|
||||||
|
"memberPortalResourceDisabled": "Recurso Deshabilitado",
|
||||||
|
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
|
||||||
|
"memberPortalPrevious": "Anterior",
|
||||||
|
"memberPortalNext": "Siguiente"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.",
|
"subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.",
|
||||||
"trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.",
|
"trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.",
|
||||||
"trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.",
|
"trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.",
|
||||||
|
"billingTrialBannerTitle": "Essai gratuit actif",
|
||||||
|
"billingTrialBannerDescription": "Vous êtes actuellement en essai gratuit sur le niveau business. À la fin de l'essai, votre compte basculera automatiquement aux fonctionnalités et limites du niveau Basique. Mettez à jour à tout moment pour conserver l'accès aux fonctionnalités de votre plan actuel.",
|
||||||
|
"billingTrialBannerUpgrade": "Passer à la version supérieure maintenant",
|
||||||
|
"billingTrialBadge": "Essai gratuit",
|
||||||
"trialActive": "Essai gratuit actif",
|
"trialActive": "Essai gratuit actif",
|
||||||
"trialExpired": "Essai expiré",
|
"trialExpired": "Essai expiré",
|
||||||
"trialHasEnded": "Votre essai est terminé.",
|
"trialHasEnded": "Votre essai est terminé.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Secrète",
|
"newtSecretKey": "Secrète",
|
||||||
|
"newtVersion": "Version",
|
||||||
"architecture": "Architecture",
|
"architecture": "Architecture",
|
||||||
"sites": "Nœuds",
|
"sites": "Nœuds",
|
||||||
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.",
|
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.",
|
||||||
@@ -1351,7 +1356,7 @@
|
|||||||
"sidebarSites": "Nœuds",
|
"sidebarSites": "Nœuds",
|
||||||
"sidebarApprovals": "Demandes d'approbation",
|
"sidebarApprovals": "Demandes d'approbation",
|
||||||
"sidebarResources": "Ressource",
|
"sidebarResources": "Ressource",
|
||||||
"sidebarProxyResources": "Publique",
|
"sidebarProxyResources": "Publiques",
|
||||||
"sidebarClientResources": "Privé",
|
"sidebarClientResources": "Privé",
|
||||||
"sidebarAccessControl": "Contrôle d'accès",
|
"sidebarAccessControl": "Contrôle d'accès",
|
||||||
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Créer un compte administrateur",
|
"createAdminAccount": "Créer un compte administrateur",
|
||||||
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
|
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
|
||||||
"certificateStatus": "Certificat",
|
"certificateStatus": "Certificat",
|
||||||
|
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
|
||||||
"loading": "Chargement",
|
"loading": "Chargement",
|
||||||
"loadingAnalytics": "Chargement de l'analyse",
|
"loadingAnalytics": "Chargement de l'analyse",
|
||||||
"restart": "Redémarrer",
|
"restart": "Redémarrer",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication",
|
"pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication",
|
||||||
"newtUpdateAvailable": "Mise à jour disponible",
|
"newtUpdateAvailable": "Mise à jour disponible",
|
||||||
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Une nouvelle version de Pangolin Node est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
||||||
"domainPickerEnterDomain": "Domaine",
|
"domainPickerEnterDomain": "Domaine",
|
||||||
"domainPickerPlaceholder": "monapp.exemple.com",
|
"domainPickerPlaceholder": "monapp.exemple.com",
|
||||||
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
|
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer",
|
"orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer",
|
||||||
"orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.",
|
"orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.",
|
||||||
"orgAuthSignInWithPangolin": "Se connecter avec Pangolin",
|
"orgAuthSignInWithPangolin": "Se connecter avec Pangolin",
|
||||||
"orgAuthSignInToOrg": "Se connecter à une organisation",
|
"orgAuthSignInToOrg": "Fournisseur d'identité d'organisation (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Connexion à l'organisation",
|
"orgAuthSelectOrgTitle": "Connexion à l'organisation",
|
||||||
"orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer",
|
"orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer",
|
||||||
"orgAuthOrgIdPlaceholder": "votre-organisation",
|
"orgAuthOrgIdPlaceholder": "votre-organisation",
|
||||||
@@ -2451,8 +2458,8 @@
|
|||||||
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
||||||
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
||||||
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
||||||
"manageMachineClients": "Gérer les clients de la machine",
|
"manageMachineClients": "Gérer les machines",
|
||||||
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources",
|
"manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
|
||||||
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
||||||
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
||||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Raison",
|
"reason": "Raison",
|
||||||
"requestLogs": "Journal des requêtes",
|
"requestLogs": "Journal des Requêtes HTTP",
|
||||||
"requestAnalytics": "Demander des analyses",
|
"requestAnalytics": "Demander des analyses",
|
||||||
"host": "Hôte",
|
"host": "Hôte",
|
||||||
"location": "Localisation",
|
"location": "Localisation",
|
||||||
"actionLogs": "Journaux des actions",
|
"actionLogs": "Journaux des actions",
|
||||||
"sidebarLogsRequest": "Journal des requêtes",
|
"sidebarLogsRequest": "Journal des Requêtes HTTP",
|
||||||
"sidebarLogsAccess": "Journaux d'accès",
|
"sidebarLogsAccess": "Journaux d'accès",
|
||||||
"sidebarLogsAction": "Journaux des actions",
|
"sidebarLogsAction": "Journaux des actions",
|
||||||
"logRetention": "Journaliser la rétention",
|
"logRetention": "Journaliser la rétention",
|
||||||
"logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver",
|
"logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver",
|
||||||
"requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation",
|
"requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation",
|
||||||
"requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation",
|
"requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation",
|
||||||
"logRetentionRequestLabel": "Demander la rétention des journaux",
|
"logRetentionRequestLabel": "Rétention des Journaux de Requêtes HTTP",
|
||||||
"logRetentionRequestDescription": "Durée de conservation des journaux de requêtes",
|
"logRetentionRequestDescription": "Durée de conservation des journaux de requêtes",
|
||||||
"logRetentionAccessLabel": "Rétention du journal d'accès",
|
"logRetentionAccessLabel": "Rétention du journal d'accès",
|
||||||
"logRetentionAccessDescription": "Durée de conservation des journaux d'accès",
|
"logRetentionAccessDescription": "Durée de conservation des journaux d'accès",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.",
|
"httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.",
|
||||||
"httpDestConnectionLogsTitle": "Journaux de connexion",
|
"httpDestConnectionLogsTitle": "Journaux de connexion",
|
||||||
"httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.",
|
"httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.",
|
||||||
"httpDestRequestLogsTitle": "Journal des requêtes",
|
"httpDestRequestLogsTitle": "Journal des Requêtes HTTP",
|
||||||
"httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.",
|
"httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.",
|
||||||
"httpDestSaveChanges": "Enregistrer les modifications",
|
"httpDestSaveChanges": "Enregistrer les modifications",
|
||||||
"httpDestCreateDestination": "Créer une destination",
|
"httpDestCreateDestination": "Créer une destination",
|
||||||
@@ -3147,6 +3154,7 @@
|
|||||||
"healthCheckTabAdvanced": "Avancé",
|
"healthCheckTabAdvanced": "Avancé",
|
||||||
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
||||||
"uptime30d": "Disponibilité (30j)",
|
"uptime30d": "Disponibilité (30j)",
|
||||||
|
"uptimeNoData": "Aucune donnée",
|
||||||
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
||||||
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
||||||
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
||||||
@@ -3200,5 +3208,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Les sous-domaines Joker ne sont pas autorisés.",
|
"domainPickerWildcardSubdomainNotAllowed": "Les sous-domaines Joker ne sont pas autorisés.",
|
||||||
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
|
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
|
||||||
"domainPickerWildcardCertWarningLink": "En savoir plus",
|
"domainPickerWildcardCertWarningLink": "En savoir plus",
|
||||||
"health": "Santé"
|
"health": "Santé",
|
||||||
|
"domainPendingErrorTitle": "Problème de vérification",
|
||||||
|
"memberPortalTitle": "Ressources",
|
||||||
|
"memberPortalDescription": "Ressources auxquelles vous avez accès dans cette organisation",
|
||||||
|
"memberPortalSortBy": "Trier par...",
|
||||||
|
"memberPortalSortNameAsc": "Nom A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Nom Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domaine A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domaine Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Activé en premier",
|
||||||
|
"memberPortalSortDisabledFirst": "Désactivé en premier",
|
||||||
|
"memberPortalRefresh": "Actualiser",
|
||||||
|
"memberPortalRefreshResources": "Actualiser les ressources",
|
||||||
|
"memberPortalFailedToLoad": "Échec du chargement des ressources",
|
||||||
|
"memberPortalFailedToLoadDescription": "Échec du chargement des ressources. Veuillez vérifier votre connexion et réessayer.",
|
||||||
|
"memberPortalUnableToLoad": "Impossible de charger les ressources",
|
||||||
|
"memberPortalTryAgain": "Réessayer",
|
||||||
|
"memberPortalNoResourcesFound": "Aucune ressource trouvée",
|
||||||
|
"memberPortalNoResourcesAvailable": "Aucune ressource disponible",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster vos termes de recherche ou de vider la recherche pour voir toutes les ressources.",
|
||||||
|
"memberPortalNoResourcesAccess": "Vous n'avez encore accès à aucune ressource. Contactez votre administrateur pour obtenir l'accès aux ressources dont vous avez besoin.",
|
||||||
|
"memberPortalClearSearch": "Effacer la recherche",
|
||||||
|
"memberPortalPublicResources": "Ressources publiques",
|
||||||
|
"memberPortalPublicResourcesDescription": "Applications et services web accessibles via un navigateur",
|
||||||
|
"memberPortalCopiedToClipboard": "Copié dans le presse-papiers",
|
||||||
|
"memberPortalCopiedUrlDescription": "L'URL de la ressource a été copiée dans votre presse-papiers.",
|
||||||
|
"memberPortalOpenResource": "Ouvrir la ressource",
|
||||||
|
"memberPortalPrivateResources": "Ressources privées",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Ressources réseau internes accessibles via un client",
|
||||||
|
"memberPortalResourceDetails": "Détails de la ressource",
|
||||||
|
"memberPortalMode": "Mode",
|
||||||
|
"memberPortalDestination": "Destination",
|
||||||
|
"memberPortalAlias": "Alias",
|
||||||
|
"memberPortalCopiedAliasDescription": "L'alias de la ressource a été copié dans votre presse-papiers.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "La destination de la ressource a été copiée dans votre presse-papiers.",
|
||||||
|
"memberPortalRequiresClientConnection": "Nécessite une connexion client",
|
||||||
|
"memberPortalAuthMethods": "Méthodes d'authentification",
|
||||||
|
"memberPortalSso": "Authentification unique (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Protégé par un mot de passe",
|
||||||
|
"memberPortalPinCode": "Code PIN",
|
||||||
|
"memberPortalEmailWhitelist": "Liste blanche des e-mails",
|
||||||
|
"memberPortalResourceDisabled": "Ressource désactivée",
|
||||||
|
"memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources",
|
||||||
|
"memberPortalPrevious": "Précédent",
|
||||||
|
"memberPortalNext": "Suivant"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.",
|
"subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.",
|
||||||
"trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.",
|
"trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.",
|
||||||
"trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.",
|
"trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.",
|
||||||
|
"billingTrialBannerTitle": "Prova Gratuita Attiva",
|
||||||
|
"billingTrialBannerDescription": "Attualmente sei in una prova gratuita sul livello business. Quando la prova terminerà, il tuo account tornerà automaticamente alle funzionalità e ai limiti del piano Basic. Effettua l'upgrade in qualsiasi momento per mantenere l'accesso alle funzionalità del tuo piano attuale.",
|
||||||
|
"billingTrialBannerUpgrade": "Effettua l'Upgrade Ora",
|
||||||
|
"billingTrialBadge": "Prova Gratuita",
|
||||||
"trialActive": "Prova Gratuita Attiva",
|
"trialActive": "Prova Gratuita Attiva",
|
||||||
"trialExpired": "Prova scaduta",
|
"trialExpired": "Prova scaduta",
|
||||||
"trialHasEnded": "La tua prova è terminata.",
|
"trialHasEnded": "La tua prova è terminata.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Segreto",
|
"newtSecretKey": "Segreto",
|
||||||
|
"newtVersion": "Versione",
|
||||||
"architecture": "Architettura",
|
"architecture": "Architettura",
|
||||||
"sites": "Siti",
|
"sites": "Siti",
|
||||||
"siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.",
|
"siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Crea Account Admin",
|
"createAdminAccount": "Crea Account Admin",
|
||||||
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
|
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
|
||||||
"certificateStatus": "Certificato",
|
"certificateStatus": "Certificato",
|
||||||
|
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
|
||||||
"loading": "Caricamento",
|
"loading": "Caricamento",
|
||||||
"loadingAnalytics": "Caricamento Delle Analisi",
|
"loadingAnalytics": "Caricamento Delle Analisi",
|
||||||
"restart": "Riavvia",
|
"restart": "Riavvia",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio",
|
"pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio",
|
||||||
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
||||||
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "È disponibile una nuova versione di Pangolin Node. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
||||||
"domainPickerEnterDomain": "Dominio",
|
"domainPickerEnterDomain": "Dominio",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
|
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare",
|
"orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare",
|
||||||
"orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.",
|
"orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.",
|
||||||
"orgAuthSignInWithPangolin": "Accedi con Pangolino",
|
"orgAuthSignInWithPangolin": "Accedi con Pangolino",
|
||||||
"orgAuthSignInToOrg": "Accedi a un'organizzazione",
|
"orgAuthSignInToOrg": "Provider di identità dell'organizzazione (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Accesso Organizzazione",
|
"orgAuthSelectOrgTitle": "Accesso Organizzazione",
|
||||||
"orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare",
|
"orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare",
|
||||||
"orgAuthOrgIdPlaceholder": "la-tua-organizzazione",
|
"orgAuthOrgIdPlaceholder": "la-tua-organizzazione",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Motivo",
|
"reason": "Motivo",
|
||||||
"requestLogs": "Log Richiesta",
|
"requestLogs": "Log Richieste HTTP",
|
||||||
"requestAnalytics": "Richiedi Analisi",
|
"requestAnalytics": "Richiedi Analisi",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"location": "Posizione",
|
"location": "Posizione",
|
||||||
"actionLogs": "Log Azioni",
|
"actionLogs": "Log Azioni",
|
||||||
"sidebarLogsRequest": "Log Richiesta",
|
"sidebarLogsRequest": "Log Richieste HTTP",
|
||||||
"sidebarLogsAccess": "Log Accesso",
|
"sidebarLogsAccess": "Log Accesso",
|
||||||
"sidebarLogsAction": "Log Azioni",
|
"sidebarLogsAction": "Log Azioni",
|
||||||
"logRetention": "Ritenzione Registro",
|
"logRetention": "Ritenzione Registro",
|
||||||
"logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali",
|
"logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali",
|
||||||
"requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione",
|
"requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione",
|
||||||
"requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione",
|
"requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione",
|
||||||
"logRetentionRequestLabel": "Richiedi Ritenzione Log",
|
"logRetentionRequestLabel": "Conservazione Log Richieste HTTP",
|
||||||
"logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste",
|
"logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste",
|
||||||
"logRetentionAccessLabel": "Ritenzione Registro Accesso",
|
"logRetentionAccessLabel": "Ritenzione Registro Accesso",
|
||||||
"logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso",
|
"logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.",
|
"httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.",
|
||||||
"httpDestConnectionLogsTitle": "Log Di Connessione",
|
"httpDestConnectionLogsTitle": "Log Di Connessione",
|
||||||
"httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.",
|
"httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.",
|
||||||
"httpDestRequestLogsTitle": "Log Richiesta",
|
"httpDestRequestLogsTitle": "Log Richieste HTTP",
|
||||||
"httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.",
|
"httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.",
|
||||||
"httpDestSaveChanges": "Salva Modifiche",
|
"httpDestSaveChanges": "Salva Modifiche",
|
||||||
"httpDestCreateDestination": "Crea Destinazione",
|
"httpDestCreateDestination": "Crea Destinazione",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "I sottodomini wildcard non sono permessi.",
|
"domainPickerWildcardSubdomainNotAllowed": "I sottodomini wildcard non sono permessi.",
|
||||||
"domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.",
|
"domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.",
|
||||||
"domainPickerWildcardCertWarningLink": "Scopri di più",
|
"domainPickerWildcardCertWarningLink": "Scopri di più",
|
||||||
"health": "Salute"
|
"health": "Salute",
|
||||||
|
"domainPendingErrorTitle": "Problema di Verifica",
|
||||||
|
"memberPortalTitle": "Risorse",
|
||||||
|
"memberPortalDescription": "Risorse a cui hai accesso in questa organizzazione",
|
||||||
|
"memberPortalSortBy": "Ordina per...",
|
||||||
|
"memberPortalSortNameAsc": "Nome A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Nome Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Dominio A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Dominio Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Abilitati per primi",
|
||||||
|
"memberPortalSortDisabledFirst": "Disabilitati per primi",
|
||||||
|
"memberPortalRefresh": "Aggiorna",
|
||||||
|
"memberPortalRefreshResources": "Aggiorna Risorse",
|
||||||
|
"memberPortalFailedToLoad": "Caricamento delle risorse non riuscito",
|
||||||
|
"memberPortalFailedToLoadDescription": "Caricamento delle risorse non riuscito. Controlla la tua connessione e riprova.",
|
||||||
|
"memberPortalUnableToLoad": "Impossibile caricare le risorse",
|
||||||
|
"memberPortalTryAgain": "Riprova",
|
||||||
|
"memberPortalNoResourcesFound": "Nessuna risorsa trovata",
|
||||||
|
"memberPortalNoResourcesAvailable": "Nessuna risorsa disponibile",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Nessuna risorsa corrisponde a \"{query}\". Prova ad aggiustare i termini di ricerca o a cancellare la ricerca per vedere tutte le risorse.",
|
||||||
|
"memberPortalNoResourcesAccess": "Non hai ancora accesso a nessuna risorsa. Contatta il tuo amministratore per ottenere l'accesso alle risorse di cui hai bisogno.",
|
||||||
|
"memberPortalClearSearch": "Cancella Ricerca",
|
||||||
|
"memberPortalPublicResources": "Risorse Pubbliche",
|
||||||
|
"memberPortalPublicResourcesDescription": "Applicazioni web e servizi accessibili tramite browser",
|
||||||
|
"memberPortalCopiedToClipboard": "Copiato negli appunti",
|
||||||
|
"memberPortalCopiedUrlDescription": "L'URL della risorsa è stato copiato negli appunti.",
|
||||||
|
"memberPortalOpenResource": "Apri Risorsa",
|
||||||
|
"memberPortalPrivateResources": "Risorse Private",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Risorse di rete interne accessibili tramite client",
|
||||||
|
"memberPortalResourceDetails": "Dettagli della Risorsa",
|
||||||
|
"memberPortalMode": "Modalità",
|
||||||
|
"memberPortalDestination": "Destinazione",
|
||||||
|
"memberPortalAlias": "Alias",
|
||||||
|
"memberPortalCopiedAliasDescription": "L'alias della risorsa è stato copiato negli appunti.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "La destinazione della risorsa è stata copiata negli appunti.",
|
||||||
|
"memberPortalRequiresClientConnection": "Richiede Connessione Client",
|
||||||
|
"memberPortalAuthMethods": "Metodi di Autenticazione",
|
||||||
|
"memberPortalSso": "Accesso unico (Single Sign-On, SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Protetto da password",
|
||||||
|
"memberPortalPinCode": "Codice PIN",
|
||||||
|
"memberPortalEmailWhitelist": "Lista Autorizzazioni Email",
|
||||||
|
"memberPortalResourceDisabled": "Risorsa Disabilitata",
|
||||||
|
"memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse",
|
||||||
|
"memberPortalPrevious": "Precedente",
|
||||||
|
"memberPortalNext": "Successivo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.",
|
"subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.",
|
||||||
"trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.",
|
"trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.",
|
||||||
"trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.",
|
"trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.",
|
||||||
|
"billingTrialBannerTitle": "무료 평가판 활성화",
|
||||||
|
"billingTrialBannerDescription": "현재 비즈니스 티어의 무료 평가판을 사용 중입니다. 평가판이 종료되면 계정은 자동으로 기본 티어 기능 및 제한으로 돌아갑니다. 현재 계획의 기능을 유지하려면 언제든지 업그레이드 하세요.",
|
||||||
|
"billingTrialBannerUpgrade": "지금 업그레이드",
|
||||||
|
"billingTrialBadge": "무료 평가판",
|
||||||
"trialActive": "무료 체험 활성화됨",
|
"trialActive": "무료 체험 활성화됨",
|
||||||
"trialExpired": "체험 만료됨",
|
"trialExpired": "체험 만료됨",
|
||||||
"trialHasEnded": "시험 사용 기간이 종료되었습니다.",
|
"trialHasEnded": "시험 사용 기간이 종료되었습니다.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "엔드포인트",
|
"newtEndpoint": "엔드포인트",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "비밀",
|
"newtSecretKey": "비밀",
|
||||||
|
"newtVersion": "버전",
|
||||||
"architecture": "아키텍처",
|
"architecture": "아키텍처",
|
||||||
"sites": "사이트",
|
"sites": "사이트",
|
||||||
"siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.",
|
"siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "관리자 계정 생성",
|
"createAdminAccount": "관리자 계정 생성",
|
||||||
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
|
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
|
||||||
"certificateStatus": "인증서",
|
"certificateStatus": "인증서",
|
||||||
|
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
|
||||||
"loading": "로딩 중",
|
"loading": "로딩 중",
|
||||||
"loadingAnalytics": "분석 로딩 중",
|
"loadingAnalytics": "분석 로딩 중",
|
||||||
"restart": "재시작",
|
"restart": "재시작",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기",
|
"pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기",
|
||||||
"newtUpdateAvailable": "업데이트 가능",
|
"newtUpdateAvailable": "업데이트 가능",
|
||||||
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||||
"domainPickerEnterDomain": "도메인",
|
"domainPickerEnterDomain": "도메인",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.",
|
"orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.",
|
||||||
"orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.",
|
"orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.",
|
||||||
"orgAuthSignInWithPangolin": "Pangolin으로 로그인",
|
"orgAuthSignInWithPangolin": "Pangolin으로 로그인",
|
||||||
"orgAuthSignInToOrg": "조직에 로그인",
|
"orgAuthSignInToOrg": "조직 아이덴티티 제공자 (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "조직 로그인",
|
"orgAuthSelectOrgTitle": "조직 로그인",
|
||||||
"orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.",
|
"orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.",
|
||||||
"orgAuthOrgIdPlaceholder": "your-organization",
|
"orgAuthOrgIdPlaceholder": "your-organization",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "유효한 인증 없음",
|
"noMoreAuthMethods": "유효한 인증 없음",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "이유",
|
"reason": "이유",
|
||||||
"requestLogs": "요청 로그",
|
"requestLogs": "HTTP 요청 로그",
|
||||||
"requestAnalytics": "요청 분석",
|
"requestAnalytics": "요청 분석",
|
||||||
"host": "호스트",
|
"host": "호스트",
|
||||||
"location": "위치",
|
"location": "위치",
|
||||||
"actionLogs": "작업 로그",
|
"actionLogs": "작업 로그",
|
||||||
"sidebarLogsRequest": "요청 로그",
|
"sidebarLogsRequest": "HTTP 요청 로그",
|
||||||
"sidebarLogsAccess": "접근 로그",
|
"sidebarLogsAccess": "접근 로그",
|
||||||
"sidebarLogsAction": "작업 로그",
|
"sidebarLogsAction": "작업 로그",
|
||||||
"logRetention": "로그 보관",
|
"logRetention": "로그 보관",
|
||||||
"logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다",
|
"logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다",
|
||||||
"requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다",
|
"requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다",
|
||||||
"requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기",
|
"requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기",
|
||||||
"logRetentionRequestLabel": "요청 로그 보관",
|
"logRetentionRequestLabel": "HTTP 요청 로그 보관",
|
||||||
"logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지",
|
"logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지",
|
||||||
"logRetentionAccessLabel": "접근 로그 보관",
|
"logRetentionAccessLabel": "접근 로그 보관",
|
||||||
"logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지",
|
"logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.",
|
"httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.",
|
||||||
"httpDestConnectionLogsTitle": "연결 로그",
|
"httpDestConnectionLogsTitle": "연결 로그",
|
||||||
"httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.",
|
"httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.",
|
||||||
"httpDestRequestLogsTitle": "요청 로그",
|
"httpDestRequestLogsTitle": "HTTP 요청 로그",
|
||||||
"httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.",
|
"httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.",
|
||||||
"httpDestSaveChanges": "변경 사항 저장",
|
"httpDestSaveChanges": "변경 사항 저장",
|
||||||
"httpDestCreateDestination": "대상지 생성",
|
"httpDestCreateDestination": "대상지 생성",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.",
|
"domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.",
|
||||||
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
|
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
|
||||||
"domainPickerWildcardCertWarningLink": "자세히 알아보기",
|
"domainPickerWildcardCertWarningLink": "자세히 알아보기",
|
||||||
"health": "건강"
|
"health": "건강",
|
||||||
|
"domainPendingErrorTitle": "확인 문제",
|
||||||
|
"memberPortalTitle": "리소스",
|
||||||
|
"memberPortalDescription": "이 조직에서 접근할 수 있는 리소스",
|
||||||
|
"memberPortalSortBy": "정렬 기준...",
|
||||||
|
"memberPortalSortNameAsc": "이름 A-Z",
|
||||||
|
"memberPortalSortNameDesc": "이름 Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "도메인 A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "도메인 Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "사용 활성화 우선",
|
||||||
|
"memberPortalSortDisabledFirst": "사용 비활성화 우선",
|
||||||
|
"memberPortalRefresh": "새로 고침",
|
||||||
|
"memberPortalRefreshResources": "리소스 새로 고침",
|
||||||
|
"memberPortalFailedToLoad": "리소스를 불러오는 데 실패했습니다",
|
||||||
|
"memberPortalFailedToLoadDescription": "리소스를 불러오는 데 실패했습니다. 연결을 확인하고 다시 시도해 주십시오.",
|
||||||
|
"memberPortalUnableToLoad": "리소스를 가져오는 데 실패했습니다",
|
||||||
|
"memberPortalTryAgain": "다시 시도",
|
||||||
|
"memberPortalNoResourcesFound": "리소스를 발견하지 못했습니다",
|
||||||
|
"memberPortalNoResourcesAvailable": "사용 가능한 리소스가 없습니다",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "\"{query}\"와 일치하는 리소스가 없습니다. 검색어를 수정하거나 검색을 초기화하여 모든 리소스를 확인하십시오.",
|
||||||
|
"memberPortalNoResourcesAccess": "아직 접근할 수 있는 리소스가 없습니다. 필요한 리소스 접근을 위해 관리자에게 문의하세요.",
|
||||||
|
"memberPortalClearSearch": "검색 초기화",
|
||||||
|
"memberPortalPublicResources": "공공 리소스",
|
||||||
|
"memberPortalPublicResourcesDescription": "브라우저를 통해 접근 가능한 웹 애플리케이션 및 서비스",
|
||||||
|
"memberPortalCopiedToClipboard": "클립보드에 복사됨",
|
||||||
|
"memberPortalCopiedUrlDescription": "리소스 URL이 클립보드에 복사되었습니다.",
|
||||||
|
"memberPortalOpenResource": "리소스 열기",
|
||||||
|
"memberPortalPrivateResources": "비공개 리소스",
|
||||||
|
"memberPortalPrivateResourcesDescription": "클라이언트를 통해 접근 가능한 내부 네트워크 리소스",
|
||||||
|
"memberPortalResourceDetails": "리소스 세부 정보",
|
||||||
|
"memberPortalMode": "모드",
|
||||||
|
"memberPortalDestination": "대상지",
|
||||||
|
"memberPortalAlias": "별칭",
|
||||||
|
"memberPortalCopiedAliasDescription": "리소스 별칭이 클립보드에 복사되었습니다.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "리소스 대상지가 클립보드에 복사되었습니다.",
|
||||||
|
"memberPortalRequiresClientConnection": "클라이언트 연결 필요",
|
||||||
|
"memberPortalAuthMethods": "인증 방법",
|
||||||
|
"memberPortalSso": "싱글 사인온 (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "비밀번호 보호",
|
||||||
|
"memberPortalPinCode": "PIN 코드",
|
||||||
|
"memberPortalEmailWhitelist": "이메일 화이트리스트",
|
||||||
|
"memberPortalResourceDisabled": "리소스 비활성화됨",
|
||||||
|
"memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중",
|
||||||
|
"memberPortalPrevious": "이전",
|
||||||
|
"memberPortalNext": "다음"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.",
|
"subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.",
|
||||||
"trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.",
|
"trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.",
|
||||||
"trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.",
|
"trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.",
|
||||||
|
"billingTrialBannerTitle": "Prøveversjon Aktiv",
|
||||||
|
"billingTrialBannerDescription": "Du har for øyeblikket en gratis prøveversjon på forretningsnivået. Når prøven avsluttes, vil kontoen din automatisk gå tilbake til funksjoner og begrensninger på Basis-nivået. Oppgrader når som helst for å beholde tilgang til de nåværende planens funksjoner.",
|
||||||
|
"billingTrialBannerUpgrade": "Oppgrader nå",
|
||||||
|
"billingTrialBadge": "Prøveversjon",
|
||||||
"trialActive": "Gratis prøveversjon aktiv",
|
"trialActive": "Gratis prøveversjon aktiv",
|
||||||
"trialExpired": "Prøveperioden er utløpt",
|
"trialExpired": "Prøveperioden er utløpt",
|
||||||
"trialHasEnded": "Din prøveperiode har avsluttet.",
|
"trialHasEnded": "Din prøveperiode har avsluttet.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Sikkerhetsnøkkel",
|
"newtSecretKey": "Sikkerhetsnøkkel",
|
||||||
|
"newtVersion": "Versjon",
|
||||||
"architecture": "Arkitektur",
|
"architecture": "Arkitektur",
|
||||||
"sites": "Områder",
|
"sites": "Områder",
|
||||||
"siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.",
|
"siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Opprett administratorkonto",
|
"createAdminAccount": "Opprett administratorkonto",
|
||||||
"setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.",
|
"setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.",
|
||||||
"certificateStatus": "Sertifikat",
|
"certificateStatus": "Sertifikat",
|
||||||
|
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
|
||||||
"loading": "Laster inn",
|
"loading": "Laster inn",
|
||||||
"loadingAnalytics": "Laster inn analyser",
|
"loadingAnalytics": "Laster inn analyser",
|
||||||
"restart": "Start på nytt",
|
"restart": "Start på nytt",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater",
|
"pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater",
|
||||||
"newtUpdateAvailable": "Oppdatering tilgjengelig",
|
"newtUpdateAvailable": "Oppdatering tilgjengelig",
|
||||||
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "En ny versjon av Pangolin Node er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
||||||
"domainPickerEnterDomain": "Domene",
|
"domainPickerEnterDomain": "Domene",
|
||||||
"domainPickerPlaceholder": "minapp.eksempel.no",
|
"domainPickerPlaceholder": "minapp.eksempel.no",
|
||||||
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
|
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette",
|
"orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette",
|
||||||
"orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.",
|
"orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.",
|
||||||
"orgAuthSignInWithPangolin": "Logg inn med Pangolin",
|
"orgAuthSignInWithPangolin": "Logg inn med Pangolin",
|
||||||
"orgAuthSignInToOrg": "Logg inn på en organisasjon",
|
"orgAuthSignInToOrg": "Organisasjonens identitetsleverandør (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Organisasjonsinnlogging",
|
"orgAuthSelectOrgTitle": "Organisasjonsinnlogging",
|
||||||
"orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette",
|
"orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette",
|
||||||
"orgAuthOrgIdPlaceholder": "din-organisasjon",
|
"orgAuthOrgIdPlaceholder": "din-organisasjon",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Grunn",
|
"reason": "Grunn",
|
||||||
"requestLogs": "Forespørselslogger (Automatic Translation)",
|
"requestLogs": "HTTP-forespørselslogger",
|
||||||
"requestAnalytics": "Be om analyser",
|
"requestAnalytics": "Be om analyser",
|
||||||
"host": "Vert",
|
"host": "Vert",
|
||||||
"location": "Sted",
|
"location": "Sted",
|
||||||
"actionLogs": "Handlingslogger",
|
"actionLogs": "Handlingslogger",
|
||||||
"sidebarLogsRequest": "Forespørselslogger (Automatic Translation)",
|
"sidebarLogsRequest": "HTTP-forespørselslogger",
|
||||||
"sidebarLogsAccess": "Tilgangslogger (Automatic Translation)",
|
"sidebarLogsAccess": "Tilgangslogger (Automatic Translation)",
|
||||||
"sidebarLogsAction": "Handlingslogger",
|
"sidebarLogsAction": "Handlingslogger",
|
||||||
"logRetention": "Logg tilbaketrekning",
|
"logRetention": "Logg tilbaketrekning",
|
||||||
"logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem",
|
"logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem",
|
||||||
"requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen",
|
"requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen",
|
||||||
"requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen",
|
"requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen",
|
||||||
"logRetentionRequestLabel": "Be om loggoverføring",
|
"logRetentionRequestLabel": "Be om loggbevaring",
|
||||||
"logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger",
|
"logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger",
|
||||||
"logRetentionAccessLabel": "Få tilgang til loggoverføring",
|
"logRetentionAccessLabel": "Få tilgang til loggoverføring",
|
||||||
"logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger",
|
"logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.",
|
"httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.",
|
||||||
"httpDestConnectionLogsTitle": "Loggfiler for tilkobling",
|
"httpDestConnectionLogsTitle": "Loggfiler for tilkobling",
|
||||||
"httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.",
|
"httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.",
|
||||||
"httpDestRequestLogsTitle": "Forespørselslogger (Automatic Translation)",
|
"httpDestRequestLogsTitle": "HTTP-forespørselslogger",
|
||||||
"httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.",
|
"httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.",
|
||||||
"httpDestSaveChanges": "Lagre endringer",
|
"httpDestSaveChanges": "Lagre endringer",
|
||||||
"httpDestCreateDestination": "Opprett mål",
|
"httpDestCreateDestination": "Opprett mål",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Jokertegnsubdomener er ikke tillatt.",
|
"domainPickerWildcardSubdomainNotAllowed": "Jokertegnsubdomener er ikke tillatt.",
|
||||||
"domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.",
|
"domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.",
|
||||||
"domainPickerWildcardCertWarningLink": "Lær mer",
|
"domainPickerWildcardCertWarningLink": "Lær mer",
|
||||||
"health": "Helse"
|
"health": "Helse",
|
||||||
|
"domainPendingErrorTitle": "Verifiseringsproblem",
|
||||||
|
"memberPortalTitle": "Ressurser",
|
||||||
|
"memberPortalDescription": "Ressurser du har tilgang til i denne organisasjonen",
|
||||||
|
"memberPortalSortBy": "Sorter etter...",
|
||||||
|
"memberPortalSortNameAsc": "Navn A-Å",
|
||||||
|
"memberPortalSortNameDesc": "Navn Å-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domene A-Å",
|
||||||
|
"memberPortalSortDomainDesc": "Domene Å-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Aktivert først",
|
||||||
|
"memberPortalSortDisabledFirst": "Deaktivert først",
|
||||||
|
"memberPortalRefresh": "Oppdater",
|
||||||
|
"memberPortalRefreshResources": "Oppdater ressurser",
|
||||||
|
"memberPortalFailedToLoad": "Kunne ikke laste inn ressurser",
|
||||||
|
"memberPortalFailedToLoadDescription": "Kunne ikke laste inn ressurser. Vennligst sjekk tilkoblingen din og prøv igjen.",
|
||||||
|
"memberPortalUnableToLoad": "Kan ikke laste inn ressurser",
|
||||||
|
"memberPortalTryAgain": "Prøv igjen",
|
||||||
|
"memberPortalNoResourcesFound": "Ingen ressurser funnet",
|
||||||
|
"memberPortalNoResourcesAvailable": "Ingen ressurser tilgjengelig",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søkeordene dine eller fjern søket for å se alle ressurser.",
|
||||||
|
"memberPortalNoResourcesAccess": "Du har ennå ikke tilgang til noen ressurser. Kontakt administratoren din for å få tilgang til de ressursene du trenger.",
|
||||||
|
"memberPortalClearSearch": "Fjern søk",
|
||||||
|
"memberPortalPublicResources": "Offentlige ressurser",
|
||||||
|
"memberPortalPublicResourcesDescription": "Webapplikasjoner og -tjenester tilgjengelige via nettleser",
|
||||||
|
"memberPortalCopiedToClipboard": "Kopiert til utklippstavlen",
|
||||||
|
"memberPortalCopiedUrlDescription": "Ressurs-URL er kopiert til utklippstavlen din.",
|
||||||
|
"memberPortalOpenResource": "Åpne ressurs",
|
||||||
|
"memberPortalPrivateResources": "Private ressurser",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Interne nettverksressurser tilgjengelige via klient",
|
||||||
|
"memberPortalResourceDetails": "Ressursdetaljer",
|
||||||
|
"memberPortalMode": "Modus",
|
||||||
|
"memberPortalDestination": "Destinasjon",
|
||||||
|
"memberPortalAlias": "Navn",
|
||||||
|
"memberPortalCopiedAliasDescription": "Ressursalias er kopiert til utklippstavlen din.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Ressursdestinasjon er kopiert til utklippstavlen din.",
|
||||||
|
"memberPortalRequiresClientConnection": "Krever klienttilkobling",
|
||||||
|
"memberPortalAuthMethods": "Autentiseringsmetoder",
|
||||||
|
"memberPortalSso": "Enkeltpålogging (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Passordbeskyttet",
|
||||||
|
"memberPortalPinCode": "PIN-kode",
|
||||||
|
"memberPortalEmailWhitelist": "E-post-hviteliste",
|
||||||
|
"memberPortalResourceDisabled": "Ressurs deaktivert",
|
||||||
|
"memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser",
|
||||||
|
"memberPortalPrevious": "Forrige",
|
||||||
|
"memberPortalNext": "Neste"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.",
|
"subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.",
|
||||||
"trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.",
|
"trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.",
|
||||||
"trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.",
|
"trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.",
|
||||||
|
"billingTrialBannerTitle": "Proefperiode Actief",
|
||||||
|
"billingTrialBannerDescription": "Je bent momenteel bezig met een gratis proefperiode op het zakelijke niveau. Wanneer de proefperiode eindigt, wordt je account automatisch teruggezet naar de functies en limieten van het Basic-niveau. Upgrade op elk moment om toegang te houden tot de functies van je huidige plan.",
|
||||||
|
"billingTrialBannerUpgrade": "Nu Upgraden",
|
||||||
|
"billingTrialBadge": "Gratis Proefversie",
|
||||||
"trialActive": "Gratis proefversie actief",
|
"trialActive": "Gratis proefversie actief",
|
||||||
"trialExpired": "Proefversie verlopen",
|
"trialExpired": "Proefversie verlopen",
|
||||||
"trialHasEnded": "Uw proefperiode is geëindigd.",
|
"trialHasEnded": "Uw proefperiode is geëindigd.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Geheim",
|
"newtSecretKey": "Geheim",
|
||||||
|
"newtVersion": "Versie",
|
||||||
"architecture": "Architectuur",
|
"architecture": "Architectuur",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.",
|
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Maak een beheeraccount aan",
|
"createAdminAccount": "Maak een beheeraccount aan",
|
||||||
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
|
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
|
||||||
"certificateStatus": "Certificaat",
|
"certificateStatus": "Certificaat",
|
||||||
|
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
|
||||||
"loading": "Bezig met laden",
|
"loading": "Bezig met laden",
|
||||||
"loadingAnalytics": "Laden van Analytics",
|
"loadingAnalytics": "Laden van Analytics",
|
||||||
"restart": "Herstarten",
|
"restart": "Herstarten",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken",
|
"pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken",
|
||||||
"newtUpdateAvailable": "Update beschikbaar",
|
"newtUpdateAvailable": "Update beschikbaar",
|
||||||
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Er is een nieuwe versie van Pangolin Node beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
||||||
"domainPickerEnterDomain": "Domein",
|
"domainPickerEnterDomain": "Domein",
|
||||||
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
|
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
|
||||||
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
|
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan",
|
"orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan",
|
||||||
"orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.",
|
"orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.",
|
||||||
"orgAuthSignInWithPangolin": "Log in met Pangolin",
|
"orgAuthSignInWithPangolin": "Log in met Pangolin",
|
||||||
"orgAuthSignInToOrg": "Log in bij een organisatie",
|
"orgAuthSignInToOrg": "Organisatie Identiteitsprovider (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Organisatie Inloggen",
|
"orgAuthSelectOrgTitle": "Organisatie Inloggen",
|
||||||
"orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan",
|
"orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan",
|
||||||
"orgAuthOrgIdPlaceholder": "jouw-organisatie",
|
"orgAuthOrgIdPlaceholder": "jouw-organisatie",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP-adres",
|
"ip": "IP-adres",
|
||||||
"reason": "Reden",
|
"reason": "Reden",
|
||||||
"requestLogs": "Logboeken aanvragen",
|
"requestLogs": "HTTP-aanvraaglogboeken",
|
||||||
"requestAnalytics": "Analytics opvragen",
|
"requestAnalytics": "Analytics opvragen",
|
||||||
"host": "Hostnaam",
|
"host": "Hostnaam",
|
||||||
"location": "Locatie",
|
"location": "Locatie",
|
||||||
"actionLogs": "Actie logs",
|
"actionLogs": "Actie logs",
|
||||||
"sidebarLogsRequest": "Logboeken aanvragen",
|
"sidebarLogsRequest": "HTTP-aanvraaglogboeken",
|
||||||
"sidebarLogsAccess": "Toegang tot logboek",
|
"sidebarLogsAccess": "Toegang tot logboek",
|
||||||
"sidebarLogsAction": "Actie logs",
|
"sidebarLogsAction": "Actie logs",
|
||||||
"logRetention": "Log bewaring",
|
"logRetention": "Log bewaring",
|
||||||
"logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit",
|
"logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit",
|
||||||
"requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie",
|
"requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie",
|
||||||
"requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie",
|
"requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie",
|
||||||
"logRetentionRequestLabel": "Logboekbewaring aanvragen",
|
"logRetentionRequestLabel": "Bewaring van HTTP-aanvraaglogboeken",
|
||||||
"logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden",
|
"logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden",
|
||||||
"logRetentionAccessLabel": "Toegang logboek bewaring",
|
"logRetentionAccessLabel": "Toegang logboek bewaring",
|
||||||
"logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven",
|
"logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.",
|
"httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.",
|
||||||
"httpDestConnectionLogsTitle": "Connectie Logs",
|
"httpDestConnectionLogsTitle": "Connectie Logs",
|
||||||
"httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.",
|
"httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.",
|
||||||
"httpDestRequestLogsTitle": "Logboeken aanvragen",
|
"httpDestRequestLogsTitle": "HTTP-aanvraaglogboeken",
|
||||||
"httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.",
|
"httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.",
|
||||||
"httpDestSaveChanges": "Wijzigingen opslaan",
|
"httpDestSaveChanges": "Wijzigingen opslaan",
|
||||||
"httpDestCreateDestination": "Maak bestemming aan",
|
"httpDestCreateDestination": "Maak bestemming aan",
|
||||||
@@ -3167,7 +3174,7 @@
|
|||||||
"publicIpEndpoint": "Eindpunt",
|
"publicIpEndpoint": "Eindpunt",
|
||||||
"lastTriggeredAt": "Laatste Trigger",
|
"lastTriggeredAt": "Laatste Trigger",
|
||||||
"reject": "Afwijzen",
|
"reject": "Afwijzen",
|
||||||
"uptimeDaysAgo": "{count} days ago",
|
"uptimeDaysAgo": "{count} dagen geleden",
|
||||||
"uptimeToday": "Vandaag",
|
"uptimeToday": "Vandaag",
|
||||||
"uptimeNoDataAvailable": "Geen gegevens beschikbaar",
|
"uptimeNoDataAvailable": "Geen gegevens beschikbaar",
|
||||||
"uptimeSuffix": "werktijd",
|
"uptimeSuffix": "werktijd",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Wildcard-subdomeinen zijn niet toegestaan.",
|
"domainPickerWildcardSubdomainNotAllowed": "Wildcard-subdomeinen zijn niet toegestaan.",
|
||||||
"domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.",
|
"domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.",
|
||||||
"domainPickerWildcardCertWarningLink": "Meer informatie",
|
"domainPickerWildcardCertWarningLink": "Meer informatie",
|
||||||
"health": "Gezondheid"
|
"health": "Gezondheid",
|
||||||
|
"domainPendingErrorTitle": "Verificatieprobleem",
|
||||||
|
"memberPortalTitle": "Bronnen",
|
||||||
|
"memberPortalDescription": "Bronnen waartoe je toegang hebt binnen deze organisatie",
|
||||||
|
"memberPortalSortBy": "Sorteren op...",
|
||||||
|
"memberPortalSortNameAsc": "Naam A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Naam Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domein A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domein Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Ingeschakeld Eerst",
|
||||||
|
"memberPortalSortDisabledFirst": "Uitgeschakeld Eerst",
|
||||||
|
"memberPortalRefresh": "Vernieuwen",
|
||||||
|
"memberPortalRefreshResources": "Bronnen Vernieuwen",
|
||||||
|
"memberPortalFailedToLoad": "Fout bij het laden van bronnen",
|
||||||
|
"memberPortalFailedToLoadDescription": "Fout bij het laden van bronnen. Controleer uw verbinding en probeer het opnieuw.",
|
||||||
|
"memberPortalUnableToLoad": "Niet in staat om bronnen te laden",
|
||||||
|
"memberPortalTryAgain": "Probeer Opnieuw",
|
||||||
|
"memberPortalNoResourcesFound": "Geen Bronnen Gevonden",
|
||||||
|
"memberPortalNoResourcesAvailable": "Geen Bronnen Beschikbaar",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Geen bronnen komen overeen met \"{query}\". Probeer uw zoektermen aan te passen of wis de zoekopdracht om alle bronnen te zien.",
|
||||||
|
"memberPortalNoResourcesAccess": "Je hebt nog geen toegang tot bronnen. Neem contact op met je beheerder om toegang te krijgen tot de benodigde bronnen.",
|
||||||
|
"memberPortalClearSearch": "Zoekopdracht Wissen",
|
||||||
|
"memberPortalPublicResources": "Publieke Bronnen",
|
||||||
|
"memberPortalPublicResourcesDescription": "Webapplicaties en services toegankelijk via browser",
|
||||||
|
"memberPortalCopiedToClipboard": "Gekopieerd naar klembord",
|
||||||
|
"memberPortalCopiedUrlDescription": "Bron URL is naar uw klembord gekopieerd.",
|
||||||
|
"memberPortalOpenResource": "Bron Openen",
|
||||||
|
"memberPortalPrivateResources": "Privé Bronnen",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Interne netwerkbronnen toegankelijk via client",
|
||||||
|
"memberPortalResourceDetails": "Bron Details",
|
||||||
|
"memberPortalMode": "Modus",
|
||||||
|
"memberPortalDestination": "Bestemming",
|
||||||
|
"memberPortalAlias": "Alias",
|
||||||
|
"memberPortalCopiedAliasDescription": "Bron alias is naar uw klembord gekopieerd.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Bron bestemming is naar uw klembord gekopieerd.",
|
||||||
|
"memberPortalRequiresClientConnection": "Clientverbinding Vereist",
|
||||||
|
"memberPortalAuthMethods": "Authenticatiemethoden",
|
||||||
|
"memberPortalSso": "Single Sign-On (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Wachtwoord Beveiligd",
|
||||||
|
"memberPortalPinCode": "Pincode",
|
||||||
|
"memberPortalEmailWhitelist": "E-mail whitelist",
|
||||||
|
"memberPortalResourceDisabled": "Bron Uitgeschakeld",
|
||||||
|
"memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen",
|
||||||
|
"memberPortalPrevious": "Vorige",
|
||||||
|
"memberPortalNext": "Volgende"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.",
|
"subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.",
|
||||||
"trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.",
|
"trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.",
|
||||||
"trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.",
|
"trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.",
|
||||||
|
"billingTrialBannerTitle": "Bezpłatna wersja próbna aktywna",
|
||||||
|
"billingTrialBannerDescription": "Obecnie korzystasz z bezpłatnej wersji próbnej na poziomie biznesowym. Po zakończeniu wersji próbnej, Twoje konto automatycznie powróci do funkcji i limitów poziomu Podstawowego. Możesz dokonać uaktualnienia w każdej chwili, aby zachować dostęp do funkcji obecnego planu.",
|
||||||
|
"billingTrialBannerUpgrade": "Uaktualnij teraz",
|
||||||
|
"billingTrialBadge": "Bezpłatna wersja próbna",
|
||||||
"trialActive": "Okres próbny aktywny",
|
"trialActive": "Okres próbny aktywny",
|
||||||
"trialExpired": "Okres próbny wygasł",
|
"trialExpired": "Okres próbny wygasł",
|
||||||
"trialHasEnded": "Twój okres próbny dobiegł końca.",
|
"trialHasEnded": "Twój okres próbny dobiegł końca.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Sekret",
|
"newtSecretKey": "Sekret",
|
||||||
|
"newtVersion": "Wersja",
|
||||||
"architecture": "Architektura",
|
"architecture": "Architektura",
|
||||||
"sites": "Witryny",
|
"sites": "Witryny",
|
||||||
"siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.",
|
"siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Utwórz konto administratora",
|
"createAdminAccount": "Utwórz konto administratora",
|
||||||
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
|
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
|
||||||
"certificateStatus": "Certyfikat",
|
"certificateStatus": "Certyfikat",
|
||||||
|
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
|
||||||
"loading": "Ładowanie",
|
"loading": "Ładowanie",
|
||||||
"loadingAnalytics": "Ładowanie Analityki",
|
"loadingAnalytics": "Ładowanie Analityki",
|
||||||
"restart": "Uruchom ponownie",
|
"restart": "Uruchom ponownie",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu",
|
"pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu",
|
||||||
"newtUpdateAvailable": "Dostępna aktualizacja",
|
"newtUpdateAvailable": "Dostępna aktualizacja",
|
||||||
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Nowa wersja Pangolin Node jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
||||||
"domainPickerEnterDomain": "Domena",
|
"domainPickerEnterDomain": "Domena",
|
||||||
"domainPickerPlaceholder": "mojapp.example.com",
|
"domainPickerPlaceholder": "mojapp.example.com",
|
||||||
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
|
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować",
|
"orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować",
|
||||||
"orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.",
|
"orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.",
|
||||||
"orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin",
|
"orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin",
|
||||||
"orgAuthSignInToOrg": "Zaloguj się do organizacji",
|
"orgAuthSignInToOrg": "Dostawca tożsamości organizacji (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Logowanie do organizacji",
|
"orgAuthSelectOrgTitle": "Logowanie do organizacji",
|
||||||
"orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować",
|
"orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować",
|
||||||
"orgAuthOrgIdPlaceholder": "twoja-organizacja",
|
"orgAuthOrgIdPlaceholder": "twoja-organizacja",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Powód",
|
"reason": "Powód",
|
||||||
"requestLogs": "Dzienniki żądań",
|
"requestLogs": "Dzienniki żądań HTTP",
|
||||||
"requestAnalytics": "Żądanie Analityki",
|
"requestAnalytics": "Żądanie Analityki",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"location": "Lokalizacja",
|
"location": "Lokalizacja",
|
||||||
"actionLogs": "Dzienniki działań",
|
"actionLogs": "Dzienniki działań",
|
||||||
"sidebarLogsRequest": "Dzienniki żądań",
|
"sidebarLogsRequest": "Dzienniki żądań HTTP",
|
||||||
"sidebarLogsAccess": "Logi dostępu",
|
"sidebarLogsAccess": "Logi dostępu",
|
||||||
"sidebarLogsAction": "Dzienniki działań",
|
"sidebarLogsAction": "Dzienniki działań",
|
||||||
"logRetention": "Zachowanie dziennika",
|
"logRetention": "Zachowanie dziennika",
|
||||||
"logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je",
|
"logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je",
|
||||||
"requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji",
|
"requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji",
|
||||||
"requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji",
|
"requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji",
|
||||||
"logRetentionRequestLabel": "Zachowanie dziennika żądań",
|
"logRetentionRequestLabel": "Przechowywanie dzienników żądań HTTP",
|
||||||
"logRetentionRequestDescription": "Jak długo zachować dzienniki żądań",
|
"logRetentionRequestDescription": "Jak długo zachować dzienniki żądań",
|
||||||
"logRetentionAccessLabel": "Zachowanie dziennika dostępu",
|
"logRetentionAccessLabel": "Zachowanie dziennika dostępu",
|
||||||
"logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu",
|
"logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.",
|
"httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.",
|
||||||
"httpDestConnectionLogsTitle": "Dzienniki połączeń",
|
"httpDestConnectionLogsTitle": "Dzienniki połączeń",
|
||||||
"httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.",
|
"httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.",
|
||||||
"httpDestRequestLogsTitle": "Dzienniki żądań",
|
"httpDestRequestLogsTitle": "Dzienniki żądań HTTP",
|
||||||
"httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.",
|
"httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.",
|
||||||
"httpDestSaveChanges": "Zapisz zmiany",
|
"httpDestSaveChanges": "Zapisz zmiany",
|
||||||
"httpDestCreateDestination": "Utwórz cel",
|
"httpDestCreateDestination": "Utwórz cel",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Uniwersalne subdomeny nie są dozwolone.",
|
"domainPickerWildcardSubdomainNotAllowed": "Uniwersalne subdomeny nie są dozwolone.",
|
||||||
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
|
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
|
||||||
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
|
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
|
||||||
"health": "Zdrowie"
|
"health": "Zdrowie",
|
||||||
|
"domainPendingErrorTitle": "Problem z weryfikacją",
|
||||||
|
"memberPortalTitle": "Zasoby",
|
||||||
|
"memberPortalDescription": "Zasoby, do których masz dostęp w tej organizacji",
|
||||||
|
"memberPortalSortBy": "Sortuj według...",
|
||||||
|
"memberPortalSortNameAsc": "Nazwa A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Nazwa Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domena A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domena Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Włączone najpierw",
|
||||||
|
"memberPortalSortDisabledFirst": "Wyłączone najpierw",
|
||||||
|
"memberPortalRefresh": "Odśwież",
|
||||||
|
"memberPortalRefreshResources": "Odśwież zasoby",
|
||||||
|
"memberPortalFailedToLoad": "Nie udało się załadować zasobów",
|
||||||
|
"memberPortalFailedToLoadDescription": "Nie udało się załadować zasobów. Sprawdź połączenie i spróbuj ponownie.",
|
||||||
|
"memberPortalUnableToLoad": "Nie można załadować zasobów",
|
||||||
|
"memberPortalTryAgain": "Spróbuj ponownie",
|
||||||
|
"memberPortalNoResourcesFound": "Nie znaleziono zasobów",
|
||||||
|
"memberPortalNoResourcesAvailable": "Brak dostępnych zasobów",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Żadne zasoby nie pasują do „{query}”. Spróbuj dostosować swoje warunki wyszukiwania lub wyczyść wyszukiwanie, aby zobaczyć wszystkie zasoby.",
|
||||||
|
"memberPortalNoResourcesAccess": "Nie masz jeszcze dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby uzyskać dostęp do potrzebnych zasobów.",
|
||||||
|
"memberPortalClearSearch": "Wyczyść wyszukiwanie",
|
||||||
|
"memberPortalPublicResources": "Publiczne zasoby",
|
||||||
|
"memberPortalPublicResourcesDescription": "Aplikacje i usługi internetowe dostępne za pośrednictwem przeglądarki",
|
||||||
|
"memberPortalCopiedToClipboard": "Skopiowano do schowka",
|
||||||
|
"memberPortalCopiedUrlDescription": "URL zasobu został skopiowany do schowka.",
|
||||||
|
"memberPortalOpenResource": "Otwórz zasób",
|
||||||
|
"memberPortalPrivateResources": "Prywatne zasoby",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Zasoby sieci wewnętrznej dostępne za pośrednictwem klienta",
|
||||||
|
"memberPortalResourceDetails": "Szczegóły zasobu",
|
||||||
|
"memberPortalMode": "Tryb",
|
||||||
|
"memberPortalDestination": "Miejsce docelowe",
|
||||||
|
"memberPortalAlias": "Pseudonim",
|
||||||
|
"memberPortalCopiedAliasDescription": "Alias zasobu został skopiowany do schowka.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Miejsce docelowe zasobu zostało skopiowane do schowka.",
|
||||||
|
"memberPortalRequiresClientConnection": "Wymaga połączenia z klientem",
|
||||||
|
"memberPortalAuthMethods": "Metody uwierzytelniania",
|
||||||
|
"memberPortalSso": "Jednorazowe logowanie (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Chronione hasłem",
|
||||||
|
"memberPortalPinCode": "Kod PIN",
|
||||||
|
"memberPortalEmailWhitelist": "Biała lista e-mail",
|
||||||
|
"memberPortalResourceDisabled": "Zasób wyłączony",
|
||||||
|
"memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}",
|
||||||
|
"memberPortalPrevious": "Poprzedni",
|
||||||
|
"memberPortalNext": "Następny"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.",
|
"subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.",
|
||||||
"trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.",
|
"trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.",
|
||||||
"trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.",
|
"trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.",
|
||||||
|
"billingTrialBannerTitle": "Teste Gratuito Ativo",
|
||||||
|
"billingTrialBannerDescription": "Atualmente, você está em um teste gratuito no nível empresarial. Quando o teste terminar, sua conta reverterá automaticamente para os recursos e limites do nível Básico. Atualize a qualquer momento para manter o acesso aos recursos do seu plano atual.",
|
||||||
|
"billingTrialBannerUpgrade": "Atualize Agora",
|
||||||
|
"billingTrialBadge": "Teste Gratuito",
|
||||||
"trialActive": "Avaliação Gratuita Ativa",
|
"trialActive": "Avaliação Gratuita Ativa",
|
||||||
"trialExpired": "Avaliação Expirada",
|
"trialExpired": "Avaliação Expirada",
|
||||||
"trialHasEnded": "Sua avaliação terminou.",
|
"trialHasEnded": "Sua avaliação terminou.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Chave Secreta",
|
"newtSecretKey": "Chave Secreta",
|
||||||
|
"newtVersion": "Versão",
|
||||||
"architecture": "Arquitetura",
|
"architecture": "Arquitetura",
|
||||||
"sites": "sites",
|
"sites": "sites",
|
||||||
"siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.",
|
"siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Criar Conta de Administrador",
|
"createAdminAccount": "Criar Conta de Administrador",
|
||||||
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
|
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
|
||||||
"certificateStatus": "Certificado",
|
"certificateStatus": "Certificado",
|
||||||
|
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
|
||||||
"loading": "Carregando",
|
"loading": "Carregando",
|
||||||
"loadingAnalytics": "Carregando Analytics",
|
"loadingAnalytics": "Carregando Analytics",
|
||||||
"restart": "Reiniciar",
|
"restart": "Reiniciar",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Ver notas de versão",
|
"pangolinUpdateAvailableReleaseNotes": "Ver notas de versão",
|
||||||
"newtUpdateAvailable": "Nova Atualização Disponível",
|
"newtUpdateAvailable": "Nova Atualização Disponível",
|
||||||
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Uma nova versão do Pangolin Node está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
||||||
"domainPickerEnterDomain": "Domínio",
|
"domainPickerEnterDomain": "Domínio",
|
||||||
"domainPickerPlaceholder": "myapp.exemplo.com",
|
"domainPickerPlaceholder": "myapp.exemplo.com",
|
||||||
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
|
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar",
|
"orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar",
|
||||||
"orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.",
|
"orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.",
|
||||||
"orgAuthSignInWithPangolin": "Entrar com o Pangolin",
|
"orgAuthSignInWithPangolin": "Entrar com o Pangolin",
|
||||||
"orgAuthSignInToOrg": "Fazer login em uma organização",
|
"orgAuthSignInToOrg": "Provedor de Identidade da Organização (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Entrada da Organização",
|
"orgAuthSelectOrgTitle": "Entrada da Organização",
|
||||||
"orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar",
|
"orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar",
|
||||||
"orgAuthOrgIdPlaceholder": "sua-organização",
|
"orgAuthOrgIdPlaceholder": "sua-organização",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "PI",
|
"ip": "PI",
|
||||||
"reason": "Motivo",
|
"reason": "Motivo",
|
||||||
"requestLogs": "Registro de pedidos",
|
"requestLogs": "Registros de Pedidos HTTP",
|
||||||
"requestAnalytics": "Solicitar análise",
|
"requestAnalytics": "Solicitar análise",
|
||||||
"host": "Servidor",
|
"host": "Servidor",
|
||||||
"location": "Local:",
|
"location": "Local:",
|
||||||
"actionLogs": "Logs de Ações",
|
"actionLogs": "Logs de Ações",
|
||||||
"sidebarLogsRequest": "Registro de pedidos",
|
"sidebarLogsRequest": "Registros de Pedidos HTTP",
|
||||||
"sidebarLogsAccess": "Logs de Acesso",
|
"sidebarLogsAccess": "Logs de Acesso",
|
||||||
"sidebarLogsAction": "Logs de Ações",
|
"sidebarLogsAction": "Logs de Ações",
|
||||||
"logRetention": "Retenção de Log",
|
"logRetention": "Retenção de Log",
|
||||||
"logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los",
|
"logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los",
|
||||||
"requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização",
|
"requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização",
|
||||||
"requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização",
|
"requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização",
|
||||||
"logRetentionRequestLabel": "Solicitar retenção de registro",
|
"logRetentionRequestLabel": "Retenção de Registro de Pedido HTTP",
|
||||||
"logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos",
|
"logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos",
|
||||||
"logRetentionAccessLabel": "Retenção de Log de Acesso",
|
"logRetentionAccessLabel": "Retenção de Log de Acesso",
|
||||||
"logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso",
|
"logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.",
|
"httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.",
|
||||||
"httpDestConnectionLogsTitle": "Logs da conexão",
|
"httpDestConnectionLogsTitle": "Logs da conexão",
|
||||||
"httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.",
|
"httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.",
|
||||||
"httpDestRequestLogsTitle": "Registro de pedidos",
|
"httpDestRequestLogsTitle": "Registros de Pedidos HTTP",
|
||||||
"httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.",
|
"httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.",
|
||||||
"httpDestSaveChanges": "Salvar as alterações",
|
"httpDestSaveChanges": "Salvar as alterações",
|
||||||
"httpDestCreateDestination": "Criar destino",
|
"httpDestCreateDestination": "Criar destino",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Subdomínios curinga não são permitidos.",
|
"domainPickerWildcardSubdomainNotAllowed": "Subdomínios curinga não são permitidos.",
|
||||||
"domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.",
|
"domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.",
|
||||||
"domainPickerWildcardCertWarningLink": "Saiba mais",
|
"domainPickerWildcardCertWarningLink": "Saiba mais",
|
||||||
"health": "Saúde"
|
"health": "Saúde",
|
||||||
|
"domainPendingErrorTitle": "Problema de Verificação",
|
||||||
|
"memberPortalTitle": "Recursos",
|
||||||
|
"memberPortalDescription": "Recursos aos quais você tem acesso nesta organização",
|
||||||
|
"memberPortalSortBy": "Ordenar por...",
|
||||||
|
"memberPortalSortNameAsc": "Nome A-Z",
|
||||||
|
"memberPortalSortNameDesc": "Nome Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Domínio A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Domínio Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Habilitados Primeiro",
|
||||||
|
"memberPortalSortDisabledFirst": "Desabilitados Primeiro",
|
||||||
|
"memberPortalRefresh": "Atualizar",
|
||||||
|
"memberPortalRefreshResources": "Atualizar Recursos",
|
||||||
|
"memberPortalFailedToLoad": "Falha ao carregar recursos",
|
||||||
|
"memberPortalFailedToLoadDescription": "Falha ao carregar recursos. Por favor, verifique sua conexão e tente novamente.",
|
||||||
|
"memberPortalUnableToLoad": "Incapaz de Carregar Recursos",
|
||||||
|
"memberPortalTryAgain": "Tentar Novamente",
|
||||||
|
"memberPortalNoResourcesFound": "Nenhum Recurso Encontrado",
|
||||||
|
"memberPortalNoResourcesAvailable": "Nenhum Recurso Disponível",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Nenhum recurso corresponde a \"{query}\". Tente ajustar seus termos de pesquisa ou limpe a pesquisa para ver todos os recursos.",
|
||||||
|
"memberPortalNoResourcesAccess": "Você ainda não tem acesso a nenhum recurso. Entre em contato com seu administrador para obter acesso aos recursos que precisa.",
|
||||||
|
"memberPortalClearSearch": "Limpar Pesquisa",
|
||||||
|
"memberPortalPublicResources": "Recursos Públicos",
|
||||||
|
"memberPortalPublicResourcesDescription": "Aplicações e serviços web acessíveis via navegador",
|
||||||
|
"memberPortalCopiedToClipboard": "Copiado para a área de transferência",
|
||||||
|
"memberPortalCopiedUrlDescription": "A URL do recurso foi copiada para sua área de transferência.",
|
||||||
|
"memberPortalOpenResource": "Abrir Recurso",
|
||||||
|
"memberPortalPrivateResources": "Recursos Privados",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Recursos da rede interna acessíveis via cliente",
|
||||||
|
"memberPortalResourceDetails": "Detalhes do Recurso",
|
||||||
|
"memberPortalMode": "Modo",
|
||||||
|
"memberPortalDestination": "Destino",
|
||||||
|
"memberPortalAlias": "Apelido",
|
||||||
|
"memberPortalCopiedAliasDescription": "O apelido do recurso foi copiado para sua área de transferência.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "O destino do recurso foi copiado para sua área de transferência.",
|
||||||
|
"memberPortalRequiresClientConnection": "Requer Conexão de Cliente",
|
||||||
|
"memberPortalAuthMethods": "Métodos de Autenticação",
|
||||||
|
"memberPortalSso": "Logon Único (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Protegido por Senha",
|
||||||
|
"memberPortalPinCode": "Código PIN",
|
||||||
|
"memberPortalEmailWhitelist": "Lista de E-mails Permitidos",
|
||||||
|
"memberPortalResourceDisabled": "Recurso Desativado",
|
||||||
|
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
|
||||||
|
"memberPortalPrevious": "Anterior",
|
||||||
|
"memberPortalNext": "Próximo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.",
|
"subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.",
|
||||||
"trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.",
|
"trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.",
|
||||||
"trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.",
|
"trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.",
|
||||||
|
"billingTrialBannerTitle": "Бесплатная версия активна",
|
||||||
|
"billingTrialBannerDescription": "Вы в настоящее время находитесь на бесплатном пробном периоде бизнес-уровня. Когда пробный период закончится, ваш аккаунт автоматически вернётся к функциям и лимитам базового уровня. Обновите в любое время, чтобы сохранить доступ к функциям текущего плана.",
|
||||||
|
"billingTrialBannerUpgrade": "Обновить сейчас",
|
||||||
|
"billingTrialBadge": "Бесплатная версия",
|
||||||
"trialActive": "Бесплатный пробный период активен",
|
"trialActive": "Бесплатный пробный период активен",
|
||||||
"trialExpired": "Пробный период истек",
|
"trialExpired": "Пробный период истек",
|
||||||
"trialHasEnded": "Ваш пробный период окончен.",
|
"trialHasEnded": "Ваш пробный период окончен.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "Секретный ключ",
|
"newtSecretKey": "Секретный ключ",
|
||||||
|
"newtVersion": "Версия",
|
||||||
"architecture": "Архитектура",
|
"architecture": "Архитектура",
|
||||||
"sites": "Сайты",
|
"sites": "Сайты",
|
||||||
"siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.",
|
"siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Создать учётную запись администратора",
|
"createAdminAccount": "Создать учётную запись администратора",
|
||||||
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
|
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
|
||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
|
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
||||||
"loading": "Загрузка",
|
"loading": "Загрузка",
|
||||||
"loadingAnalytics": "Загрузка аналитики",
|
"loadingAnalytics": "Загрузка аналитики",
|
||||||
"restart": "Перезагрузка",
|
"restart": "Перезагрузка",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
|
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
|
||||||
"newtUpdateAvailable": "Доступно обновление",
|
"newtUpdateAvailable": "Доступно обновление",
|
||||||
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
||||||
"domainPickerEnterDomain": "Домен",
|
"domainPickerEnterDomain": "Домен",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
|
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
|
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
|
||||||
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
|
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
|
||||||
"orgAuthSignInWithPangolin": "Войти через Pangolin",
|
"orgAuthSignInWithPangolin": "Войти через Pangolin",
|
||||||
"orgAuthSignInToOrg": "Войти в организацию",
|
"orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Вход в организацию",
|
"orgAuthSelectOrgTitle": "Вход в организацию",
|
||||||
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
|
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
|
||||||
"orgAuthOrgIdPlaceholder": "ваша-организация",
|
"orgAuthOrgIdPlaceholder": "ваша-организация",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "No Valid Auth",
|
"noMoreAuthMethods": "No Valid Auth",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Причина",
|
"reason": "Причина",
|
||||||
"requestLogs": "Запросить журналы",
|
"requestLogs": "HTTP Запросы Логи",
|
||||||
"requestAnalytics": "Аналитика запроса",
|
"requestAnalytics": "Аналитика запроса",
|
||||||
"host": "Хост",
|
"host": "Хост",
|
||||||
"location": "Местоположение",
|
"location": "Местоположение",
|
||||||
"actionLogs": "Журнал действий",
|
"actionLogs": "Журнал действий",
|
||||||
"sidebarLogsRequest": "Запросить журналы",
|
"sidebarLogsRequest": "HTTP Запросы Логи",
|
||||||
"sidebarLogsAccess": "Журналы доступа",
|
"sidebarLogsAccess": "Журналы доступа",
|
||||||
"sidebarLogsAction": "Журнал действий",
|
"sidebarLogsAction": "Журнал действий",
|
||||||
"logRetention": "Сохранение журнала",
|
"logRetention": "Сохранение журнала",
|
||||||
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
|
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
|
||||||
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
|
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
|
||||||
"requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации",
|
"requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации",
|
||||||
"logRetentionRequestLabel": "Запросить сохранение журнала",
|
"logRetentionRequestLabel": "Сохранение HTTP Запросов Лога",
|
||||||
"logRetentionRequestDescription": "Как долго сохранять журналы запросов",
|
"logRetentionRequestDescription": "Как долго сохранять журналы запросов",
|
||||||
"logRetentionAccessLabel": "Хранение журнала доступа",
|
"logRetentionAccessLabel": "Хранение журнала доступа",
|
||||||
"logRetentionAccessDescription": "Как долго сохранять журналы доступа",
|
"logRetentionAccessDescription": "Как долго сохранять журналы доступа",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.",
|
"httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.",
|
||||||
"httpDestConnectionLogsTitle": "Журнал подключений",
|
"httpDestConnectionLogsTitle": "Журнал подключений",
|
||||||
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
|
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
|
||||||
"httpDestRequestLogsTitle": "Запросить журналы",
|
"httpDestRequestLogsTitle": "HTTP Запросы Логи",
|
||||||
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
|
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
|
||||||
"httpDestSaveChanges": "Сохранить изменения",
|
"httpDestSaveChanges": "Сохранить изменения",
|
||||||
"httpDestCreateDestination": "Создать адрес назначения",
|
"httpDestCreateDestination": "Создать адрес назначения",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.",
|
"domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.",
|
||||||
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
|
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
|
||||||
"domainPickerWildcardCertWarningLink": "Узнать больше",
|
"domainPickerWildcardCertWarningLink": "Узнать больше",
|
||||||
"health": "Состояние"
|
"health": "Состояние",
|
||||||
|
"domainPendingErrorTitle": "Проблема с подтверждением",
|
||||||
|
"memberPortalTitle": "Ресурсы",
|
||||||
|
"memberPortalDescription": "Ресурсы, к которым у вас есть доступ в этой организации",
|
||||||
|
"memberPortalSortBy": "Сортировать по...",
|
||||||
|
"memberPortalSortNameAsc": "Имя A-Я",
|
||||||
|
"memberPortalSortNameDesc": "Имя Я-A",
|
||||||
|
"memberPortalSortDomainAsc": "Домен A-Я",
|
||||||
|
"memberPortalSortDomainDesc": "Домен Я-A",
|
||||||
|
"memberPortalSortEnabledFirst": "Включённые сначала",
|
||||||
|
"memberPortalSortDisabledFirst": "Отключённые сначала",
|
||||||
|
"memberPortalRefresh": "Обновить",
|
||||||
|
"memberPortalRefreshResources": "Обновить ресурсы",
|
||||||
|
"memberPortalFailedToLoad": "Не удалось загрузить ресурсы",
|
||||||
|
"memberPortalFailedToLoadDescription": "Не удалось загрузить ресурсы. Пожалуйста, проверьте подключение и попробуйте снова.",
|
||||||
|
"memberPortalUnableToLoad": "Не удалось загрузить ресурсы",
|
||||||
|
"memberPortalTryAgain": "Попробуйте снова",
|
||||||
|
"memberPortalNoResourcesFound": "Ресурсы не найдены",
|
||||||
|
"memberPortalNoResourcesAvailable": "Нет доступных ресурсов",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Нет ресурсов, соответствующих \"{query}\". Попробуйте изменить условия поиска или очистить поиск, чтобы увидеть все ресурсы.",
|
||||||
|
"memberPortalNoResourcesAccess": "У вас пока нет доступа к ресурсам. Свяжитесь с администратором, чтобы получить доступ к нужным вам ресурсам.",
|
||||||
|
"memberPortalClearSearch": "Очистить поиск",
|
||||||
|
"memberPortalPublicResources": "Публичные ресурсы",
|
||||||
|
"memberPortalPublicResourcesDescription": "Веб-приложения и сервисы, доступные через браузер",
|
||||||
|
"memberPortalCopiedToClipboard": "Скопировано в буфер обмена",
|
||||||
|
"memberPortalCopiedUrlDescription": "URL ресурса был скопирован в ваш буфер обмена.",
|
||||||
|
"memberPortalOpenResource": "Открыть ресурс",
|
||||||
|
"memberPortalPrivateResources": "Приватные ресурсы",
|
||||||
|
"memberPortalPrivateResourcesDescription": "Ресурсы внутренней сети, доступные через клиент",
|
||||||
|
"memberPortalResourceDetails": "Детали ресурса",
|
||||||
|
"memberPortalMode": "Режим",
|
||||||
|
"memberPortalDestination": "Назначение",
|
||||||
|
"memberPortalAlias": "Псевдоним",
|
||||||
|
"memberPortalCopiedAliasDescription": "Псевдоним ресурса был скопирован в ваш буфер обмена.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Назначение ресурса было скопировано в ваш буфер обмена.",
|
||||||
|
"memberPortalRequiresClientConnection": "Требуется подключение клиента",
|
||||||
|
"memberPortalAuthMethods": "Методы аутентификации",
|
||||||
|
"memberPortalSso": "Единый вход (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Защищено паролем",
|
||||||
|
"memberPortalPinCode": "PIN-код",
|
||||||
|
"memberPortalEmailWhitelist": "Белый список email",
|
||||||
|
"memberPortalResourceDisabled": "Ресурс отключён",
|
||||||
|
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
|
||||||
|
"memberPortalPrevious": "Предыдущий",
|
||||||
|
"memberPortalNext": "Следующий"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.",
|
"subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.",
|
||||||
"trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.",
|
"trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.",
|
||||||
"trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.",
|
"trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.",
|
||||||
|
"billingTrialBannerTitle": "Ücretsiz Deneme Aktif",
|
||||||
|
"billingTrialBannerDescription": "Şu anda iş seviyesi için ücretsiz deneme sürümündesiniz. Deneme süresi sona erdiğinde, hesabınız otomatik olarak Temel seviye özelliklerine ve limitlerine geri dönecektir. Mevcut planınızın özelliklerine erişimi sürdürmek için istediğiniz zaman yükseltin.",
|
||||||
|
"billingTrialBannerUpgrade": "Şimdi Yükselt",
|
||||||
|
"billingTrialBadge": "Ücretsiz Deneme",
|
||||||
"trialActive": "Ücretsiz Deneme Aktif",
|
"trialActive": "Ücretsiz Deneme Aktif",
|
||||||
"trialExpired": "Deneme Süresi Doldu",
|
"trialExpired": "Deneme Süresi Doldu",
|
||||||
"trialHasEnded": "Deneme süreniz sona erdi.",
|
"trialHasEnded": "Deneme süreniz sona erdi.",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Uç Nokta",
|
"newtEndpoint": "Uç Nokta",
|
||||||
"newtId": "Kimlik",
|
"newtId": "Kimlik",
|
||||||
"newtSecretKey": "Gizli",
|
"newtSecretKey": "Gizli",
|
||||||
|
"newtVersion": "Sürüm",
|
||||||
"architecture": "Mimari",
|
"architecture": "Mimari",
|
||||||
"sites": "Siteler",
|
"sites": "Siteler",
|
||||||
"siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.",
|
"siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "Yönetici Hesabı Oluştur",
|
"createAdminAccount": "Yönetici Hesabı Oluştur",
|
||||||
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
|
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
|
||||||
"certificateStatus": "Sertifika",
|
"certificateStatus": "Sertifika",
|
||||||
|
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
|
||||||
"loading": "Yükleniyor",
|
"loading": "Yükleniyor",
|
||||||
"loadingAnalytics": "Analiz Yükleniyor",
|
"loadingAnalytics": "Analiz Yükleniyor",
|
||||||
"restart": "Yeniden Başlat",
|
"restart": "Yeniden Başlat",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle",
|
"pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle",
|
||||||
"newtUpdateAvailable": "Güncelleme Mevcut",
|
"newtUpdateAvailable": "Güncelleme Mevcut",
|
||||||
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "Pangolin Node'un yeni bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
||||||
"domainPickerEnterDomain": "Alan Adı",
|
"domainPickerEnterDomain": "Alan Adı",
|
||||||
"domainPickerPlaceholder": "myapp.example.com",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
|
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin",
|
"orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin",
|
||||||
"orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.",
|
"orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.",
|
||||||
"orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap",
|
"orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap",
|
||||||
"orgAuthSignInToOrg": "Bir kuruluşa giriş yapın",
|
"orgAuthSignInToOrg": "Kuruluş Kimlik Sağlayıcısı (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "Kuruluş Giriş",
|
"orgAuthSelectOrgTitle": "Kuruluş Giriş",
|
||||||
"orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin",
|
"orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin",
|
||||||
"orgAuthOrgIdPlaceholder": "kuruluşunuz",
|
"orgAuthOrgIdPlaceholder": "kuruluşunuz",
|
||||||
@@ -2653,19 +2660,19 @@
|
|||||||
"noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok",
|
"noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok",
|
||||||
"ip": "IP",
|
"ip": "IP",
|
||||||
"reason": "Sebep",
|
"reason": "Sebep",
|
||||||
"requestLogs": "İstek Günlükleri",
|
"requestLogs": "HTTP İstek Günlükleri",
|
||||||
"requestAnalytics": "İstek Analizi",
|
"requestAnalytics": "İstek Analizi",
|
||||||
"host": "Sunucu",
|
"host": "Sunucu",
|
||||||
"location": "Konum",
|
"location": "Konum",
|
||||||
"actionLogs": "Eylem Günlükleri",
|
"actionLogs": "Eylem Günlükleri",
|
||||||
"sidebarLogsRequest": "İstek Günlükleri",
|
"sidebarLogsRequest": "HTTP İstek Günlükleri",
|
||||||
"sidebarLogsAccess": "Erişim Günlükleri",
|
"sidebarLogsAccess": "Erişim Günlükleri",
|
||||||
"sidebarLogsAction": "Eylem Günlükleri",
|
"sidebarLogsAction": "Eylem Günlükleri",
|
||||||
"logRetention": "Kayıt Saklama",
|
"logRetention": "Kayıt Saklama",
|
||||||
"logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın",
|
"logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın",
|
||||||
"requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin",
|
"requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin",
|
||||||
"requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.",
|
"requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.",
|
||||||
"logRetentionRequestLabel": "İstek Günlüğü Saklama",
|
"logRetentionRequestLabel": "HTTP İstek Günlüğü Saklama",
|
||||||
"logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle",
|
"logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle",
|
||||||
"logRetentionAccessLabel": "Erişim Günlüğü Saklama",
|
"logRetentionAccessLabel": "Erişim Günlüğü Saklama",
|
||||||
"logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle",
|
"logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle",
|
||||||
@@ -3127,7 +3134,7 @@
|
|||||||
"httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.",
|
"httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.",
|
||||||
"httpDestConnectionLogsTitle": "Bağlantı Kayıtları",
|
"httpDestConnectionLogsTitle": "Bağlantı Kayıtları",
|
||||||
"httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.",
|
"httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.",
|
||||||
"httpDestRequestLogsTitle": "İstek Kayıtları",
|
"httpDestRequestLogsTitle": "HTTP İstek Günlükleri",
|
||||||
"httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.",
|
"httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.",
|
||||||
"httpDestSaveChanges": "Değişiklikleri Kaydet",
|
"httpDestSaveChanges": "Değişiklikleri Kaydet",
|
||||||
"httpDestCreateDestination": "Hedef Oluştur",
|
"httpDestCreateDestination": "Hedef Oluştur",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "Genel alt alanlara izin verilmiyor.",
|
"domainPickerWildcardSubdomainNotAllowed": "Genel alt alanlara izin verilmiyor.",
|
||||||
"domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.",
|
"domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.",
|
||||||
"domainPickerWildcardCertWarningLink": "Daha fazla bilgi",
|
"domainPickerWildcardCertWarningLink": "Daha fazla bilgi",
|
||||||
"health": "Sağlık"
|
"health": "Sağlık",
|
||||||
|
"domainPendingErrorTitle": "Doğrulama Sorunu",
|
||||||
|
"memberPortalTitle": "Kaynaklar",
|
||||||
|
"memberPortalDescription": "Bu organizasyondaki erişiminiz olan kaynaklar",
|
||||||
|
"memberPortalSortBy": "Şuna göre sırala...",
|
||||||
|
"memberPortalSortNameAsc": "İsim A-Z",
|
||||||
|
"memberPortalSortNameDesc": "İsim Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "Alan A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "Alan Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "İlk Etkinleştirilenler",
|
||||||
|
"memberPortalSortDisabledFirst": "İlk Devre Dışı Bırakılanlar",
|
||||||
|
"memberPortalRefresh": "Yenile",
|
||||||
|
"memberPortalRefreshResources": "Kaynakları Yenile",
|
||||||
|
"memberPortalFailedToLoad": "Kaynaklar yüklenemedi",
|
||||||
|
"memberPortalFailedToLoadDescription": "Kaynaklar yüklenemedi. Lütfen bağlantınızı kontrol edin ve tekrar deneyin.",
|
||||||
|
"memberPortalUnableToLoad": "Kaynaklar Yüklenemiyor",
|
||||||
|
"memberPortalTryAgain": "Tekrar Dene",
|
||||||
|
"memberPortalNoResourcesFound": "Hiçbir Kaynak Bulunamadı",
|
||||||
|
"memberPortalNoResourcesAvailable": "Uygun Kaynak Yok",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "Hiçbir kaynak \"{query}\" ile eşleşmiyor. Arama terimlerinizi değiştirerek veya tüm kaynakları görmek için aramayı temizleyerek deneyin.",
|
||||||
|
"memberPortalNoResourcesAccess": "Henüz herhangi bir kaynağa erişiminiz yok. İhtiyacınız olan kaynaklara erişim sağlamak için yöneticinizle iletişime geçin.",
|
||||||
|
"memberPortalClearSearch": "Aramayı Temizle",
|
||||||
|
"memberPortalPublicResources": "Genel Kaynaklar",
|
||||||
|
"memberPortalPublicResourcesDescription": "Tarayıcı üzerinden erişilebilen web uygulamaları ve hizmetler",
|
||||||
|
"memberPortalCopiedToClipboard": "Panoya kopyalandı",
|
||||||
|
"memberPortalCopiedUrlDescription": "Kaynak URL'si panonuza kopyalandı.",
|
||||||
|
"memberPortalOpenResource": "Kaynağı Aç",
|
||||||
|
"memberPortalPrivateResources": "Özel Kaynaklar",
|
||||||
|
"memberPortalPrivateResourcesDescription": "İstemci üzerinden erişilebilen dahili ağ kaynakları",
|
||||||
|
"memberPortalResourceDetails": "Kaynak Detayları",
|
||||||
|
"memberPortalMode": "Mod",
|
||||||
|
"memberPortalDestination": "Hedef",
|
||||||
|
"memberPortalAlias": "Takma İsim",
|
||||||
|
"memberPortalCopiedAliasDescription": "Kaynak takma adı panonuza kopyalandı.",
|
||||||
|
"memberPortalCopiedDestinationDescription": "Kaynak hedefi panonuza kopyalandı.",
|
||||||
|
"memberPortalRequiresClientConnection": "İstemci Bağlantısı Gerektirir",
|
||||||
|
"memberPortalAuthMethods": "Kimlik Doğrulama Yöntemleri",
|
||||||
|
"memberPortalSso": "Tek Oturum Açma (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "Parola ile Korunan",
|
||||||
|
"memberPortalPinCode": "PIN Kodu",
|
||||||
|
"memberPortalEmailWhitelist": "E-posta Beyaz Listesi",
|
||||||
|
"memberPortalResourceDisabled": "Kaynak Devre Dışı",
|
||||||
|
"memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor",
|
||||||
|
"memberPortalPrevious": "Önceki",
|
||||||
|
"memberPortalNext": "Sonraki"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
|
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
|
||||||
"trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。",
|
"trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。",
|
||||||
"trialBannerExpired": "您的试用已到期。立即升级以恢复访问。",
|
"trialBannerExpired": "您的试用已到期。立即升级以恢复访问。",
|
||||||
|
"billingTrialBannerTitle": "免费试用激活中",
|
||||||
|
"billingTrialBannerDescription": "您目前正在商用层进行免费试用。试用结束后,您的账户将自动回到基础层功能和限制。可随时升级以保持当前计划的功能访问。",
|
||||||
|
"billingTrialBannerUpgrade": "立即升级",
|
||||||
|
"billingTrialBadge": "免费试用",
|
||||||
"trialActive": "免费试用中",
|
"trialActive": "免费试用中",
|
||||||
"trialExpired": "试用到期",
|
"trialExpired": "试用到期",
|
||||||
"trialHasEnded": "您的试用已结束。",
|
"trialHasEnded": "您的试用已结束。",
|
||||||
@@ -763,6 +767,7 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "密钥",
|
"newtSecretKey": "密钥",
|
||||||
|
"newtVersion": "版本",
|
||||||
"architecture": "架构",
|
"architecture": "架构",
|
||||||
"sites": "站点",
|
"sites": "站点",
|
||||||
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
|
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
|
||||||
@@ -1597,6 +1602,7 @@
|
|||||||
"createAdminAccount": "创建管理员帐户",
|
"createAdminAccount": "创建管理员帐户",
|
||||||
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
||||||
"certificateStatus": "证书",
|
"certificateStatus": "证书",
|
||||||
|
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
"loadingAnalytics": "加载分析",
|
"loadingAnalytics": "加载分析",
|
||||||
"restart": "重启",
|
"restart": "重启",
|
||||||
@@ -1665,6 +1671,7 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
|
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
|
||||||
"newtUpdateAvailable": "更新可用",
|
"newtUpdateAvailable": "更新可用",
|
||||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
||||||
|
"pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
|
||||||
"domainPickerEnterDomain": "域名",
|
"domainPickerEnterDomain": "域名",
|
||||||
"domainPickerPlaceholder": "example.com",
|
"domainPickerPlaceholder": "example.com",
|
||||||
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
||||||
@@ -2352,7 +2359,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
|
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
|
||||||
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
|
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
|
||||||
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
|
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
|
||||||
"orgAuthSignInToOrg": "登录到组织",
|
"orgAuthSignInToOrg": "组织身份提供商 (SSO)",
|
||||||
"orgAuthSelectOrgTitle": "组织登录",
|
"orgAuthSelectOrgTitle": "组织登录",
|
||||||
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
|
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
|
||||||
"orgAuthOrgIdPlaceholder": "您的组织",
|
"orgAuthOrgIdPlaceholder": "您的组织",
|
||||||
@@ -2665,7 +2672,7 @@
|
|||||||
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
|
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
|
||||||
"requestLogsDescription": "查看此机构资源的详细请求日志",
|
"requestLogsDescription": "查看此机构资源的详细请求日志",
|
||||||
"requestAnalyticsDescription": "查看此机构资源的详细请求分析",
|
"requestAnalyticsDescription": "查看此机构资源的详细请求分析",
|
||||||
"logRetentionRequestLabel": "请求日志保留",
|
"logRetentionRequestLabel": "HTTP 请求日志保留",
|
||||||
"logRetentionRequestDescription": "保留请求日志的时间",
|
"logRetentionRequestDescription": "保留请求日志的时间",
|
||||||
"logRetentionAccessLabel": "访问日志保留",
|
"logRetentionAccessLabel": "访问日志保留",
|
||||||
"logRetentionAccessDescription": "保留访问日志的时间",
|
"logRetentionAccessDescription": "保留访问日志的时间",
|
||||||
@@ -3200,5 +3207,49 @@
|
|||||||
"domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。",
|
"domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。",
|
||||||
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
|
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
|
||||||
"domainPickerWildcardCertWarningLink": "了解更多",
|
"domainPickerWildcardCertWarningLink": "了解更多",
|
||||||
"health": "健康"
|
"health": "健康",
|
||||||
|
"domainPendingErrorTitle": "验证问题",
|
||||||
|
"memberPortalTitle": "资源",
|
||||||
|
"memberPortalDescription": "您在此组织中可以访问的资源",
|
||||||
|
"memberPortalSortBy": "排序依据……",
|
||||||
|
"memberPortalSortNameAsc": "名称 A-Z",
|
||||||
|
"memberPortalSortNameDesc": "名称 Z-A",
|
||||||
|
"memberPortalSortDomainAsc": "域名 A-Z",
|
||||||
|
"memberPortalSortDomainDesc": "域名 Z-A",
|
||||||
|
"memberPortalSortEnabledFirst": "启用优先",
|
||||||
|
"memberPortalSortDisabledFirst": "禁用优先",
|
||||||
|
"memberPortalRefresh": "刷新",
|
||||||
|
"memberPortalRefreshResources": "刷新资源",
|
||||||
|
"memberPortalFailedToLoad": "加载资源失败",
|
||||||
|
"memberPortalFailedToLoadDescription": "加载资源失败。请检查您的连接并再试一次。",
|
||||||
|
"memberPortalUnableToLoad": "无法加载资源",
|
||||||
|
"memberPortalTryAgain": "再试一次",
|
||||||
|
"memberPortalNoResourcesFound": "找不到资源",
|
||||||
|
"memberPortalNoResourcesAvailable": "无可用资源",
|
||||||
|
"memberPortalNoResourcesMatchSearch": "没有与\"{query}\"匹配的资源。尝试调整您的搜索词或清除搜索以查看所有资源。",
|
||||||
|
"memberPortalNoResourcesAccess": "您尚无访问任何资源的权限。请联系您的管理员获取所需资源的访问权限。",
|
||||||
|
"memberPortalClearSearch": "清除搜索",
|
||||||
|
"memberPortalPublicResources": "公共资源",
|
||||||
|
"memberPortalPublicResourcesDescription": "通过浏览器可访问的网络应用和服务",
|
||||||
|
"memberPortalCopiedToClipboard": "已复制到剪贴板",
|
||||||
|
"memberPortalCopiedUrlDescription": "资源 URL 已复制到您的剪贴板。",
|
||||||
|
"memberPortalOpenResource": "打开资源",
|
||||||
|
"memberPortalPrivateResources": "私有资源",
|
||||||
|
"memberPortalPrivateResourcesDescription": "通过客户端可访问的内部网络资源",
|
||||||
|
"memberPortalResourceDetails": "资源详情",
|
||||||
|
"memberPortalMode": "模式",
|
||||||
|
"memberPortalDestination": "目标",
|
||||||
|
"memberPortalAlias": "别名",
|
||||||
|
"memberPortalCopiedAliasDescription": "资源别名已复制到您的剪贴板。",
|
||||||
|
"memberPortalCopiedDestinationDescription": "资源目的地已复制到您的剪贴板。",
|
||||||
|
"memberPortalRequiresClientConnection": "需要客户端连接",
|
||||||
|
"memberPortalAuthMethods": "身份验证方法",
|
||||||
|
"memberPortalSso": "单一登录 (SSO)",
|
||||||
|
"memberPortalPasswordProtected": "密码保护",
|
||||||
|
"memberPortalPinCode": "PIN 码",
|
||||||
|
"memberPortalEmailWhitelist": "电子邮件白名单",
|
||||||
|
"memberPortalResourceDisabled": "资源已禁用",
|
||||||
|
"memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源",
|
||||||
|
"memberPortalPrevious": "上一页",
|
||||||
|
"memberPortalNext": "下一页"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 569 KiB After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 434 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 516 KiB |
@@ -122,8 +122,6 @@ export enum ActionsEnum {
|
|||||||
createOrgDomain = "createOrgDomain",
|
createOrgDomain = "createOrgDomain",
|
||||||
deleteOrgDomain = "deleteOrgDomain",
|
deleteOrgDomain = "deleteOrgDomain",
|
||||||
restartOrgDomain = "restartOrgDomain",
|
restartOrgDomain = "restartOrgDomain",
|
||||||
sendUsageNotification = "sendUsageNotification",
|
|
||||||
sendTrialNotification = "sendTrialNotification",
|
|
||||||
createRemoteExitNode = "createRemoteExitNode",
|
createRemoteExitNode = "createRemoteExitNode",
|
||||||
updateRemoteExitNode = "updateRemoteExitNode",
|
updateRemoteExitNode = "updateRemoteExitNode",
|
||||||
getRemoteExitNode = "getRemoteExitNode",
|
getRemoteExitNode = "getRemoteExitNode",
|
||||||
|
|||||||
@@ -1,94 +1,53 @@
|
|||||||
{
|
{
|
||||||
"PowerMac4,4": "eMac",
|
|
||||||
"PowerMac6,4": "eMac",
|
|
||||||
"PowerBook2,1": "iBook",
|
|
||||||
"PowerBook2,2": "iBook",
|
|
||||||
"PowerBook4,1": "iBook",
|
|
||||||
"PowerBook4,2": "iBook",
|
|
||||||
"PowerBook4,3": "iBook",
|
|
||||||
"PowerBook6,3": "iBook",
|
|
||||||
"PowerBook6,5": "iBook",
|
|
||||||
"PowerBook6,7": "iBook",
|
|
||||||
"iMac,1": "iMac",
|
|
||||||
"PowerMac2,1": "iMac",
|
|
||||||
"PowerMac2,2": "iMac",
|
|
||||||
"PowerMac4,1": "iMac",
|
|
||||||
"PowerMac4,2": "iMac",
|
|
||||||
"PowerMac4,5": "iMac",
|
|
||||||
"PowerMac6,1": "iMac",
|
|
||||||
"PowerMac6,3*": "iMac",
|
|
||||||
"PowerMac6,3": "iMac",
|
|
||||||
"PowerMac8,1": "iMac",
|
|
||||||
"PowerMac8,2": "iMac",
|
|
||||||
"PowerMac12,1": "iMac",
|
|
||||||
"iMac4,1": "iMac",
|
|
||||||
"iMac4,2": "iMac",
|
|
||||||
"iMac5,2": "iMac",
|
|
||||||
"iMac5,1": "iMac",
|
|
||||||
"iMac6,1": "iMac",
|
|
||||||
"iMac7,1": "iMac",
|
|
||||||
"iMac8,1": "iMac",
|
|
||||||
"iMac9,1": "iMac",
|
|
||||||
"iMac10,1": "iMac",
|
|
||||||
"iMac11,1": "iMac",
|
|
||||||
"iMac11,2": "iMac",
|
|
||||||
"iMac11,3": "iMac",
|
|
||||||
"iMac12,1": "iMac",
|
|
||||||
"iMac12,2": "iMac",
|
|
||||||
"iMac13,1": "iMac",
|
|
||||||
"iMac13,2": "iMac",
|
|
||||||
"iMac14,1": "iMac",
|
|
||||||
"iMac14,3": "iMac",
|
|
||||||
"iMac14,2": "iMac",
|
|
||||||
"iMac14,4": "iMac",
|
|
||||||
"iMac15,1": "iMac",
|
|
||||||
"iMac16,1": "iMac",
|
|
||||||
"iMac16,2": "iMac",
|
|
||||||
"iMac17,1": "iMac",
|
|
||||||
"iMac18,1": "iMac",
|
|
||||||
"iMac18,2": "iMac",
|
|
||||||
"iMac18,3": "iMac",
|
|
||||||
"iMac19,2": "iMac",
|
|
||||||
"iMac19,1": "iMac",
|
|
||||||
"iMac20,1": "iMac",
|
|
||||||
"iMac20,2": "iMac",
|
|
||||||
"iMac21,2": "iMac",
|
|
||||||
"iMac21,1": "iMac",
|
|
||||||
"iMacPro1,1": "iMac Pro",
|
|
||||||
"PowerMac10,1": "Mac mini",
|
|
||||||
"PowerMac10,2": "Mac mini",
|
|
||||||
"Macmini1,1": "Mac mini",
|
|
||||||
"Macmini2,1": "Mac mini",
|
|
||||||
"Macmini3,1": "Mac mini",
|
|
||||||
"Macmini4,1": "Mac mini",
|
|
||||||
"Macmini5,1": "Mac mini",
|
|
||||||
"Macmini5,2": "Mac mini",
|
|
||||||
"Macmini5,3": "Mac mini",
|
|
||||||
"Macmini6,1": "Mac mini",
|
|
||||||
"Macmini6,2": "Mac mini",
|
|
||||||
"Macmini7,1": "Mac mini",
|
|
||||||
"Macmini8,1": "Mac mini",
|
|
||||||
"ADP3,2": "Mac mini",
|
"ADP3,2": "Mac mini",
|
||||||
"Macmini9,1": "Mac mini",
|
|
||||||
"Mac14,3": "Mac mini",
|
|
||||||
"Mac14,12": "Mac mini",
|
|
||||||
"MacPro1,1*": "Mac Pro",
|
|
||||||
"MacPro2,1": "Mac Pro",
|
|
||||||
"MacPro3,1": "Mac Pro",
|
|
||||||
"MacPro4,1": "Mac Pro",
|
|
||||||
"MacPro5,1": "Mac Pro",
|
|
||||||
"MacPro6,1": "Mac Pro",
|
|
||||||
"MacPro7,1": "Mac Pro",
|
|
||||||
"N/A*": "Power Macintosh",
|
|
||||||
"PowerMac1,1": "Power Macintosh",
|
|
||||||
"PowerMac3,1": "Power Macintosh",
|
|
||||||
"PowerMac3,3": "Power Macintosh",
|
|
||||||
"PowerMac3,4": "Power Macintosh",
|
|
||||||
"PowerMac3,5": "Power Macintosh",
|
|
||||||
"PowerMac3,6": "Power Macintosh",
|
|
||||||
"Mac13,1": "Mac Studio",
|
"Mac13,1": "Mac Studio",
|
||||||
"Mac13,2": "Mac Studio",
|
"Mac13,2": "Mac Studio",
|
||||||
|
"Mac14,10": "MacBook Pro",
|
||||||
|
"Mac14,12": "Mac mini",
|
||||||
|
"Mac14,13": "Mac Studio",
|
||||||
|
"Mac14,14": "Mac Studio",
|
||||||
|
"Mac14,15": "MacBook Air",
|
||||||
|
"Mac14,2": "MacBook Air",
|
||||||
|
"Mac14,3": "Mac mini",
|
||||||
|
"Mac14,5": "MacBook Pro",
|
||||||
|
"Mac14,6": "MacBook Pro",
|
||||||
|
"Mac14,7": "MacBook Pro",
|
||||||
|
"Mac14,8": "Mac Pro",
|
||||||
|
"Mac14,9": "MacBook Pro",
|
||||||
|
"Mac15,10": "MacBook Pro",
|
||||||
|
"Mac15,11": "MacBook Pro",
|
||||||
|
"Mac15,12": "MacBook Air",
|
||||||
|
"Mac15,13": "MacBook Air",
|
||||||
|
"Mac15,14": "Mac Studio",
|
||||||
|
"Mac15,3": "MacBook Pro",
|
||||||
|
"Mac15,4": "iMac",
|
||||||
|
"Mac15,5": "iMac",
|
||||||
|
"Mac15,6": "MacBook Pro",
|
||||||
|
"Mac15,7": "MacBook Pro",
|
||||||
|
"Mac15,8": "MacBook Pro",
|
||||||
|
"Mac15,9": "MacBook Pro",
|
||||||
|
"Mac16,1": "MacBook Pro",
|
||||||
|
"Mac16,10": "Mac mini",
|
||||||
|
"Mac16,11": "Mac mini",
|
||||||
|
"Mac16,12": "MacBook Air",
|
||||||
|
"Mac16,13": "MacBook Air",
|
||||||
|
"Mac16,2": "iMac",
|
||||||
|
"Mac16,3": "iMac",
|
||||||
|
"Mac16,5": "MacBook Pro",
|
||||||
|
"Mac16,6": "MacBook Pro",
|
||||||
|
"Mac16,7": "MacBook Pro",
|
||||||
|
"Mac16,8": "MacBook Pro",
|
||||||
|
"Mac16,9": "Mac Studio",
|
||||||
|
"Mac17,2": "MacBook Pro",
|
||||||
|
"Mac17,3": "MacBook Air",
|
||||||
|
"Mac17,4": "MacBook Air",
|
||||||
|
"Mac17,5": "MacBook Neo",
|
||||||
|
"Mac17,6": "MacBook Pro",
|
||||||
|
"Mac17,7": "MacBook Pro",
|
||||||
|
"Mac17,8": "MacBook Pro",
|
||||||
|
"Mac17,9": "MacBook Pro",
|
||||||
"MacBook1,1": "MacBook",
|
"MacBook1,1": "MacBook",
|
||||||
|
"MacBook10,1": "MacBook",
|
||||||
"MacBook2,1": "MacBook",
|
"MacBook2,1": "MacBook",
|
||||||
"MacBook3,1": "MacBook",
|
"MacBook3,1": "MacBook",
|
||||||
"MacBook4,1": "MacBook",
|
"MacBook4,1": "MacBook",
|
||||||
@@ -98,8 +57,8 @@
|
|||||||
"MacBook7,1": "MacBook",
|
"MacBook7,1": "MacBook",
|
||||||
"MacBook8,1": "MacBook",
|
"MacBook8,1": "MacBook",
|
||||||
"MacBook9,1": "MacBook",
|
"MacBook9,1": "MacBook",
|
||||||
"MacBook10,1": "MacBook",
|
|
||||||
"MacBookAir1,1": "MacBook Air",
|
"MacBookAir1,1": "MacBook Air",
|
||||||
|
"MacBookAir10,1": "MacBook Air",
|
||||||
"MacBookAir2,1": "MacBook Air",
|
"MacBookAir2,1": "MacBook Air",
|
||||||
"MacBookAir3,1": "MacBook Air",
|
"MacBookAir3,1": "MacBook Air",
|
||||||
"MacBookAir3,2": "MacBook Air",
|
"MacBookAir3,2": "MacBook Air",
|
||||||
@@ -114,88 +73,163 @@
|
|||||||
"MacBookAir8,1": "MacBook Air",
|
"MacBookAir8,1": "MacBook Air",
|
||||||
"MacBookAir8,2": "MacBook Air",
|
"MacBookAir8,2": "MacBook Air",
|
||||||
"MacBookAir9,1": "MacBook Air",
|
"MacBookAir9,1": "MacBook Air",
|
||||||
"MacBookAir10,1": "MacBook Air",
|
|
||||||
"Mac14,2": "MacBook Air",
|
|
||||||
"MacBookPro1,1": "MacBook Pro",
|
"MacBookPro1,1": "MacBook Pro",
|
||||||
"MacBookPro1,2": "MacBook Pro",
|
"MacBookPro1,2": "MacBook Pro",
|
||||||
"MacBookPro2,2": "MacBook Pro",
|
|
||||||
"MacBookPro2,1": "MacBook Pro",
|
|
||||||
"MacBookPro3,1": "MacBook Pro",
|
|
||||||
"MacBookPro4,1": "MacBook Pro",
|
|
||||||
"MacBookPro5,1": "MacBook Pro",
|
|
||||||
"MacBookPro5,2": "MacBook Pro",
|
|
||||||
"MacBookPro5,5": "MacBook Pro",
|
|
||||||
"MacBookPro5,4": "MacBook Pro",
|
|
||||||
"MacBookPro5,3": "MacBook Pro",
|
|
||||||
"MacBookPro7,1": "MacBook Pro",
|
|
||||||
"MacBookPro6,2": "MacBook Pro",
|
|
||||||
"MacBookPro6,1": "MacBook Pro",
|
|
||||||
"MacBookPro8,1": "MacBook Pro",
|
|
||||||
"MacBookPro8,2": "MacBook Pro",
|
|
||||||
"MacBookPro8,3": "MacBook Pro",
|
|
||||||
"MacBookPro9,2": "MacBook Pro",
|
|
||||||
"MacBookPro9,1": "MacBook Pro",
|
|
||||||
"MacBookPro10,1": "MacBook Pro",
|
"MacBookPro10,1": "MacBook Pro",
|
||||||
"MacBookPro10,2": "MacBook Pro",
|
"MacBookPro10,2": "MacBook Pro",
|
||||||
"MacBookPro11,1": "MacBook Pro",
|
"MacBookPro11,1": "MacBook Pro",
|
||||||
"MacBookPro11,2": "MacBook Pro",
|
"MacBookPro11,2": "MacBook Pro",
|
||||||
"MacBookPro11,3": "MacBook Pro",
|
"MacBookPro11,3": "MacBook Pro",
|
||||||
"MacBookPro12,1": "MacBook Pro",
|
|
||||||
"MacBookPro11,4": "MacBook Pro",
|
"MacBookPro11,4": "MacBook Pro",
|
||||||
"MacBookPro11,5": "MacBook Pro",
|
"MacBookPro11,5": "MacBook Pro",
|
||||||
|
"MacBookPro12,1": "MacBook Pro",
|
||||||
"MacBookPro13,1": "MacBook Pro",
|
"MacBookPro13,1": "MacBook Pro",
|
||||||
"MacBookPro13,2": "MacBook Pro",
|
"MacBookPro13,2": "MacBook Pro",
|
||||||
"MacBookPro13,3": "MacBook Pro",
|
"MacBookPro13,3": "MacBook Pro",
|
||||||
"MacBookPro14,1": "MacBook Pro",
|
"MacBookPro14,1": "MacBook Pro",
|
||||||
"MacBookPro14,2": "MacBook Pro",
|
"MacBookPro14,2": "MacBook Pro",
|
||||||
"MacBookPro14,3": "MacBook Pro",
|
"MacBookPro14,3": "MacBook Pro",
|
||||||
"MacBookPro15,2": "MacBook Pro",
|
|
||||||
"MacBookPro15,1": "MacBook Pro",
|
"MacBookPro15,1": "MacBook Pro",
|
||||||
|
"MacBookPro15,2": "MacBook Pro",
|
||||||
"MacBookPro15,3": "MacBook Pro",
|
"MacBookPro15,3": "MacBook Pro",
|
||||||
"MacBookPro15,4": "MacBook Pro",
|
"MacBookPro15,4": "MacBook Pro",
|
||||||
"MacBookPro16,1": "MacBook Pro",
|
"MacBookPro16,1": "MacBook Pro",
|
||||||
"MacBookPro16,3": "MacBook Pro",
|
|
||||||
"MacBookPro16,2": "MacBook Pro",
|
"MacBookPro16,2": "MacBook Pro",
|
||||||
|
"MacBookPro16,3": "MacBook Pro",
|
||||||
"MacBookPro16,4": "MacBook Pro",
|
"MacBookPro16,4": "MacBook Pro",
|
||||||
"MacBookPro17,1": "MacBook Pro",
|
"MacBookPro17,1": "MacBook Pro",
|
||||||
"MacBookPro18,3": "MacBook Pro",
|
|
||||||
"MacBookPro18,4": "MacBook Pro",
|
|
||||||
"MacBookPro18,1": "MacBook Pro",
|
"MacBookPro18,1": "MacBook Pro",
|
||||||
"MacBookPro18,2": "MacBook Pro",
|
"MacBookPro18,2": "MacBook Pro",
|
||||||
"Mac14,7": "MacBook Pro",
|
"MacBookPro18,3": "MacBook Pro",
|
||||||
"Mac14,9": "MacBook Pro",
|
"MacBookPro18,4": "MacBook Pro",
|
||||||
"Mac14,5": "MacBook Pro",
|
"MacBookPro2,1": "MacBook Pro",
|
||||||
"Mac14,10": "MacBook Pro",
|
"MacBookPro2,2": "MacBook Pro",
|
||||||
"Mac14,6": "MacBook Pro",
|
"MacBookPro3,1": "MacBook Pro",
|
||||||
"PowerMac1,2": "Power Macintosh",
|
"MacBookPro4,1": "MacBook Pro",
|
||||||
"PowerMac5,1": "Power Macintosh",
|
"MacBookPro5,1": "MacBook Pro",
|
||||||
"PowerMac7,2": "Power Macintosh",
|
"MacBookPro5,2": "MacBook Pro",
|
||||||
"PowerMac7,3": "Power Macintosh",
|
"MacBookPro5,3": "MacBook Pro",
|
||||||
"PowerMac9,1": "Power Macintosh",
|
"MacBookPro5,4": "MacBook Pro",
|
||||||
"PowerMac11,2": "Power Macintosh",
|
"MacBookPro5,5": "MacBook Pro",
|
||||||
|
"MacBookPro6,1": "MacBook Pro",
|
||||||
|
"MacBookPro6,2": "MacBook Pro",
|
||||||
|
"MacBookPro7,1": "MacBook Pro",
|
||||||
|
"MacBookPro8,1": "MacBook Pro",
|
||||||
|
"MacBookPro8,2": "MacBook Pro",
|
||||||
|
"MacBookPro8,3": "MacBook Pro",
|
||||||
|
"MacBookPro9,1": "MacBook Pro",
|
||||||
|
"MacBookPro9,2": "MacBook Pro",
|
||||||
|
"MacPro1,1": "Mac Pro",
|
||||||
|
"MacPro2,1": "Mac Pro",
|
||||||
|
"MacPro3,1": "Mac Pro",
|
||||||
|
"MacPro4,1": "Mac Pro",
|
||||||
|
"MacPro5,1": "Mac Pro",
|
||||||
|
"MacPro6,1": "Mac Pro",
|
||||||
|
"MacPro7,1": "Mac Pro",
|
||||||
|
"Macmini1,1": "Mac mini",
|
||||||
|
"Macmini2,1": "Mac mini",
|
||||||
|
"Macmini3,1": "Mac mini",
|
||||||
|
"Macmini4,1": "Mac mini",
|
||||||
|
"Macmini5,1": "Mac mini",
|
||||||
|
"Macmini5,2": "Mac mini",
|
||||||
|
"Macmini5,3": "Mac mini",
|
||||||
|
"Macmini6,1": "Mac mini",
|
||||||
|
"Macmini6,2": "Mac mini",
|
||||||
|
"Macmini7,1": "Mac mini",
|
||||||
|
"Macmini8,1": "Mac mini",
|
||||||
|
"Macmini9,1": "Mac mini",
|
||||||
"PowerBook1,1": "PowerBook",
|
"PowerBook1,1": "PowerBook",
|
||||||
|
"PowerBook2,1": "iBook",
|
||||||
|
"PowerBook2,2": "iBook",
|
||||||
"PowerBook3,1": "PowerBook",
|
"PowerBook3,1": "PowerBook",
|
||||||
"PowerBook3,2": "PowerBook",
|
"PowerBook3,2": "PowerBook",
|
||||||
"PowerBook3,3": "PowerBook",
|
"PowerBook3,3": "PowerBook",
|
||||||
"PowerBook3,4": "PowerBook",
|
"PowerBook3,4": "PowerBook",
|
||||||
"PowerBook3,5": "PowerBook",
|
"PowerBook3,5": "PowerBook",
|
||||||
"PowerBook6,1": "PowerBook",
|
"PowerBook4,1": "iBook",
|
||||||
|
"PowerBook4,2": "iBook",
|
||||||
|
"PowerBook4,3": "iBook",
|
||||||
"PowerBook5,1": "PowerBook",
|
"PowerBook5,1": "PowerBook",
|
||||||
"PowerBook6,2": "PowerBook",
|
|
||||||
"PowerBook5,2": "PowerBook",
|
"PowerBook5,2": "PowerBook",
|
||||||
"PowerBook5,3": "PowerBook",
|
"PowerBook5,3": "PowerBook",
|
||||||
"PowerBook6,4": "PowerBook",
|
|
||||||
"PowerBook5,4": "PowerBook",
|
"PowerBook5,4": "PowerBook",
|
||||||
"PowerBook5,5": "PowerBook",
|
"PowerBook5,5": "PowerBook",
|
||||||
"PowerBook6,8": "PowerBook",
|
|
||||||
"PowerBook5,6": "PowerBook",
|
"PowerBook5,6": "PowerBook",
|
||||||
"PowerBook5,7": "PowerBook",
|
"PowerBook5,7": "PowerBook",
|
||||||
"PowerBook5,8": "PowerBook",
|
"PowerBook5,8": "PowerBook",
|
||||||
"PowerBook5,9": "PowerBook",
|
"PowerBook5,9": "PowerBook",
|
||||||
|
"PowerBook6,1": "PowerBook",
|
||||||
|
"PowerBook6,2": "PowerBook",
|
||||||
|
"PowerBook6,3": "iBook",
|
||||||
|
"PowerBook6,4": "PowerBook",
|
||||||
|
"PowerBook6,5": "iBook",
|
||||||
|
"PowerBook6,7": "iBook",
|
||||||
|
"PowerBook6,8": "PowerBook",
|
||||||
|
"PowerMac1,1": "Power Macintosh",
|
||||||
|
"PowerMac1,2": "Power Macintosh",
|
||||||
|
"PowerMac10,1": "Mac mini",
|
||||||
|
"PowerMac10,2": "Mac mini",
|
||||||
|
"PowerMac11,2": "Power Macintosh",
|
||||||
|
"PowerMac12,1": "iMac",
|
||||||
|
"PowerMac2,1": "iMac",
|
||||||
|
"PowerMac2,2": "iMac",
|
||||||
|
"PowerMac3,1": "Mac Server",
|
||||||
|
"PowerMac3,3": "Power Macintosh",
|
||||||
|
"PowerMac3,4": "Power Macintosh",
|
||||||
|
"PowerMac3,5": "Power Macintosh",
|
||||||
|
"PowerMac3,6": "Power Macintosh",
|
||||||
|
"PowerMac4,1": "iMac",
|
||||||
|
"PowerMac4,2": "iMac",
|
||||||
|
"PowerMac4,4": "eMac",
|
||||||
|
"PowerMac4,5": "iMac",
|
||||||
|
"PowerMac5,1": "Power Macintosh",
|
||||||
|
"PowerMac6,1": "iMac",
|
||||||
|
"PowerMac6,3": "iMac",
|
||||||
|
"PowerMac6,4": "eMac",
|
||||||
|
"PowerMac7,2": "Power Macintosh",
|
||||||
|
"PowerMac7,3": "Power Macintosh",
|
||||||
|
"PowerMac8,1": "iMac",
|
||||||
|
"PowerMac8,2": "iMac",
|
||||||
|
"PowerMac9,1": "Power Macintosh",
|
||||||
"RackMac1,1": "Xserve",
|
"RackMac1,1": "Xserve",
|
||||||
"RackMac1,2": "Xserve",
|
"RackMac1,2": "Xserve",
|
||||||
"RackMac3,1": "Xserve",
|
"RackMac3,1": "Xserve",
|
||||||
"Xserve1,1": "Xserve",
|
"Xserve1,1": "Xserve",
|
||||||
"Xserve2,1": "Xserve",
|
"Xserve2,1": "Xserve",
|
||||||
"Xserve3,1": "Xserve"
|
"Xserve3,1": "Xserve",
|
||||||
|
"iMac,1": "iMac",
|
||||||
|
"iMac10,1": "iMac",
|
||||||
|
"iMac11,1": "iMac",
|
||||||
|
"iMac11,2": "iMac",
|
||||||
|
"iMac11,3": "iMac",
|
||||||
|
"iMac12,1": "iMac",
|
||||||
|
"iMac12,2": "iMac",
|
||||||
|
"iMac13,1": "iMac",
|
||||||
|
"iMac13,2": "iMac",
|
||||||
|
"iMac14,1": "iMac",
|
||||||
|
"iMac14,2": "iMac",
|
||||||
|
"iMac14,3": "iMac",
|
||||||
|
"iMac14,4": "iMac",
|
||||||
|
"iMac15,1": "iMac",
|
||||||
|
"iMac16,1": "iMac",
|
||||||
|
"iMac16,2": "iMac",
|
||||||
|
"iMac17,1": "iMac",
|
||||||
|
"iMac18,1": "iMac",
|
||||||
|
"iMac18,2": "iMac",
|
||||||
|
"iMac18,3": "iMac",
|
||||||
|
"iMac19,1": "iMac",
|
||||||
|
"iMac19,2": "iMac",
|
||||||
|
"iMac20,1": "iMac",
|
||||||
|
"iMac20,2": "iMac",
|
||||||
|
"iMac21,1": "iMac",
|
||||||
|
"iMac21,2": "iMac",
|
||||||
|
"iMac4,1": "iMac",
|
||||||
|
"iMac4,2": "iMac",
|
||||||
|
"iMac5,1": "iMac",
|
||||||
|
"iMac5,2": "iMac",
|
||||||
|
"iMac6,1": "iMac",
|
||||||
|
"iMac7,1": "iMac",
|
||||||
|
"iMac8,1": "iMac",
|
||||||
|
"iMac9,1": "iMac",
|
||||||
|
"iMacPro1,1": "iMac Pro"
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export const primaryDb = db.$primary;
|
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -566,6 +566,17 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
|
|||||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
|
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const trialNotifications = pgTable("trialNotifications", {
|
||||||
|
notificationId: serial("notificationId").primaryKey(),
|
||||||
|
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => subscriptions.subscriptionId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
|
||||||
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -604,3 +615,12 @@ export type EventStreamingCursor = InferSelectModel<
|
|||||||
typeof eventStreamingCursors
|
typeof eventStreamingCursors
|
||||||
>;
|
>;
|
||||||
export type AlertResources = InferSelectModel<typeof alertResources>;
|
export type AlertResources = InferSelectModel<typeof alertResources>;
|
||||||
|
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
|
||||||
|
export type AlertSites = InferSelectModel<typeof alertSites>;
|
||||||
|
export type AlertRules = InferSelectModel<typeof alertRules>;
|
||||||
|
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
|
||||||
|
export type AlertEmailRecipients = InferSelectModel<
|
||||||
|
typeof alertEmailRecipients
|
||||||
|
>;
|
||||||
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -11,8 +12,69 @@ export const exists = checkFileExists(location);
|
|||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
||||||
|
* execution, freeing native sqlite3_stmt memory deterministically instead
|
||||||
|
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
||||||
|
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
||||||
|
* reusable .prepare() builders. No such usage exists in this codebase.
|
||||||
|
*/
|
||||||
|
function autoFinalizeStatement(
|
||||||
|
stmt: BetterSqlite3.Statement
|
||||||
|
): BetterSqlite3.Statement {
|
||||||
|
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
try {
|
||||||
|
return fn.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// finalize() exists on the native Statement at runtime but
|
||||||
|
// is missing from @types/better-sqlite3.
|
||||||
|
(stmt as any).finalize();
|
||||||
|
} catch {
|
||||||
|
// Already finalized — harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.run = wrapExec(stmt.run);
|
||||||
|
stmt.get = wrapExec(stmt.get);
|
||||||
|
stmt.all = wrapExec(stmt.all);
|
||||||
|
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
|
|
||||||
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||||
|
// retry loops that accumulate memory.
|
||||||
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
|
||||||
|
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||||
|
// TraefikConfigManager JOINs that block the event loop.
|
||||||
|
sqlite.pragma("cache_size = -65536");
|
||||||
|
|
||||||
|
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||||
|
// reducing event-loop blocking.
|
||||||
|
sqlite.pragma("mmap_size = 268435456");
|
||||||
|
|
||||||
|
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
||||||
|
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
||||||
|
const originalPrepare = sqlite.prepare.bind(sqlite);
|
||||||
|
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
||||||
|
return autoFinalizeStatement(originalPrepare(source));
|
||||||
|
};
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
});
|
});
|
||||||
@@ -23,7 +85,7 @@ export default db;
|
|||||||
export const primaryDb = db;
|
export const primaryDb = db;
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
export const DB_TYPE: "pg" | "sqlite" = "sqlite";
|
export const DB_TYPE: "pg" | "sqlite" = "sqlite";
|
||||||
|
|
||||||
function checkFileExists(filePath: string): boolean {
|
function checkFileExists(filePath: string): boolean {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { serial, varchar } from "drizzle-orm/mysql-core";
|
||||||
|
import { pgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { bigint } from "zod";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -569,6 +572,19 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", {
|
|||||||
lastSentAt: integer("lastSentAt")
|
lastSentAt: integer("lastSentAt")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const trialNotifications = sqliteTable("trialNotifications", {
|
||||||
|
notificationId: integer("notificationId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
subscriptionId: text("subscriptionId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => subscriptions.subscriptionId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
|
||||||
|
sentAt: integer("sentAt").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -601,3 +617,10 @@ export type EventStreamingCursor = InferSelectModel<
|
|||||||
typeof eventStreamingCursors
|
typeof eventStreamingCursors
|
||||||
>;
|
>;
|
||||||
export type AlertResources = InferSelectModel<typeof alertResources>;
|
export type AlertResources = InferSelectModel<typeof alertResources>;
|
||||||
|
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
|
||||||
|
export type AlertSites = InferSelectModel<typeof alertSites>;
|
||||||
|
export type AlertRule = InferSelectModel<typeof alertRules>;
|
||||||
|
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||||
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Some features and resources may now be
|
Some features and resources may now be
|
||||||
restricted or disconnected. To restore full
|
restricted. To restore full
|
||||||
access and continue using all the features
|
access and continue using all the features
|
||||||
you had during your trial, please upgrade to
|
you had during your trial, please upgrade to
|
||||||
a paid plan.
|
a paid plan.
|
||||||
@@ -85,7 +85,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
<strong>{orgName}</strong> will end on{" "}
|
<strong>{orgName}</strong> will end on{" "}
|
||||||
<strong>{trialEndsAt}</strong>
|
<strong>{trialEndsAt}</strong>
|
||||||
{isLastDay
|
{isLastDay
|
||||||
? " — that's tomorrow!"
|
? " - that's tomorrow!"
|
||||||
: `, in ${daysRemaining} days`}
|
: `, in ${daysRemaining} days`}
|
||||||
.
|
.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
@@ -93,8 +93,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
<EmailText>
|
<EmailText>
|
||||||
After your trial ends, your account will be
|
After your trial ends, your account will be
|
||||||
moved to the free plan and some
|
moved to the free plan and some
|
||||||
functionality may be restricted or your
|
functionality may be restricted.
|
||||||
sites may disconnect.
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
|
|||||||
@@ -1,27 +1,153 @@
|
|||||||
// stub
|
import logger from "@server/logger";
|
||||||
|
import { processAlerts } from "#dynamic/lib/alerts";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
statusHistory,
|
||||||
|
targetHealthCheck,
|
||||||
|
targets,
|
||||||
|
resources,
|
||||||
|
Transaction,
|
||||||
|
logsDb
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
||||||
|
import {
|
||||||
|
fireResourceDegradedAlert,
|
||||||
|
fireResourceHealthyAlert,
|
||||||
|
fireResourceUnhealthyAlert,
|
||||||
|
fireResourceUnknownAlert
|
||||||
|
} from "./resourceEvents";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `health_check_healthy` alert for the given health check.
|
||||||
|
*
|
||||||
|
* Call this after a previously-failing health check has recovered so that any
|
||||||
|
* matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the health check.
|
||||||
|
* @param healthCheckId - Numeric primary key of the health check.
|
||||||
|
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
export async function fireHealthCheckHealthyAlert(
|
export async function fireHealthCheckHealthyAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
healthCheckId: number,
|
healthCheckId: number,
|
||||||
healthCheckName?: string,
|
healthCheckName?: string | null,
|
||||||
healthCheckTargetId?: number | null,
|
healthCheckTargetId?: number | null,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
send: boolean = true,
|
send: boolean = true,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "health_check",
|
||||||
|
entityId: healthCheckId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "healthy",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
||||||
|
|
||||||
|
await handleResource(orgId, healthCheckTargetId, send, trx);
|
||||||
|
|
||||||
|
if (!send) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "health_check_healthy",
|
||||||
|
orgId,
|
||||||
|
healthCheckId,
|
||||||
|
data: {
|
||||||
|
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "health_check_toggle",
|
||||||
|
orgId,
|
||||||
|
healthCheckId,
|
||||||
|
data: {
|
||||||
|
healthCheckId,
|
||||||
|
status: "healthy",
|
||||||
|
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `health_check_unhealthy` alert for the given health check.
|
||||||
|
*
|
||||||
|
* Call this after a health check has been detected as failing so that any
|
||||||
|
* matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the health check.
|
||||||
|
* @param healthCheckId - Numeric primary key of the health check.
|
||||||
|
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
export async function fireHealthCheckUnhealthyAlert(
|
export async function fireHealthCheckUnhealthyAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
healthCheckId: number,
|
healthCheckId: number,
|
||||||
healthCheckName?: string,
|
healthCheckName?: string | null,
|
||||||
healthCheckTargetId?: number | null,
|
healthCheckTargetId?: number | null,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
send: boolean = true,
|
send: boolean = true,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "health_check",
|
||||||
|
entityId: healthCheckId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "unhealthy",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
||||||
|
|
||||||
|
await handleResource(orgId, healthCheckTargetId, send, trx);
|
||||||
|
|
||||||
|
if (!send) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "health_check_unhealthy",
|
||||||
|
orgId,
|
||||||
|
healthCheckId,
|
||||||
|
data: {
|
||||||
|
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "health_check_toggle",
|
||||||
|
orgId,
|
||||||
|
healthCheckId,
|
||||||
|
data: {
|
||||||
|
healthCheckId,
|
||||||
|
status: "unhealthy",
|
||||||
|
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fireHealthCheckUnknownAlert(
|
export async function fireHealthCheckUnknownAlert(
|
||||||
@@ -31,7 +157,137 @@ export async function fireHealthCheckUnknownAlert(
|
|||||||
healthCheckTargetId?: number | null,
|
healthCheckTargetId?: number | null,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
send: boolean = true,
|
send: boolean = true,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "health_check",
|
||||||
|
entityId: healthCheckId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "unknown",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
||||||
|
|
||||||
|
await handleResource(orgId, healthCheckTargetId, send, trx);
|
||||||
|
|
||||||
|
if (!send) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResource(
|
||||||
|
orgId: string,
|
||||||
|
healthCheckTargetId?: number | null,
|
||||||
|
send: boolean = true,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
) {
|
||||||
|
if (!healthCheckTargetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// we have targets lets get them
|
||||||
|
const [target] = await trx
|
||||||
|
.select()
|
||||||
|
.from(targets)
|
||||||
|
.where(eq(targets.targetId, healthCheckTargetId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [resource] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, target.resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherTargets = await trx
|
||||||
|
.select({ hcHealth: targetHealthCheck.hcHealth })
|
||||||
|
.from(targets)
|
||||||
|
.innerJoin(
|
||||||
|
targetHealthCheck,
|
||||||
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
|
)
|
||||||
|
.where(eq(targets.resourceId, resource.resourceId));
|
||||||
|
|
||||||
|
let health = "healthy";
|
||||||
|
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
||||||
|
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
||||||
|
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
||||||
|
|
||||||
|
if (allUnknown) {
|
||||||
|
logger.debug(
|
||||||
|
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
|
||||||
|
);
|
||||||
|
health = "unknown";
|
||||||
|
} else if (allHealthy) {
|
||||||
|
health = "healthy";
|
||||||
|
} else if (allUnhealthy) {
|
||||||
|
logger.debug(
|
||||||
|
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
|
||||||
|
);
|
||||||
|
health = "unhealthy";
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
|
||||||
|
);
|
||||||
|
health = "degraded";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health != resource.health) {
|
||||||
|
// it changed
|
||||||
|
await trx
|
||||||
|
.update(resources)
|
||||||
|
.set({ health })
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId));
|
||||||
|
|
||||||
|
if (health === "unknown") {
|
||||||
|
await fireResourceUnknownAlert(
|
||||||
|
orgId,
|
||||||
|
resource.resourceId,
|
||||||
|
resource.name,
|
||||||
|
undefined,
|
||||||
|
send,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else if (health === "unhealthy") {
|
||||||
|
await fireResourceUnhealthyAlert(
|
||||||
|
orgId,
|
||||||
|
resource.resourceId,
|
||||||
|
resource.name,
|
||||||
|
undefined,
|
||||||
|
send,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else if (health === "healthy") {
|
||||||
|
await fireResourceHealthyAlert(
|
||||||
|
orgId,
|
||||||
|
resource.resourceId,
|
||||||
|
resource.name,
|
||||||
|
undefined,
|
||||||
|
send,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else if (health === "degraded") {
|
||||||
|
await fireResourceDegradedAlert(
|
||||||
|
orgId,
|
||||||
|
resource.resourceId,
|
||||||
|
resource.name,
|
||||||
|
undefined,
|
||||||
|
send,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,243 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
import { processAlerts } from "#dynamic/lib/alerts";
|
||||||
|
import { db, logsDb, statusHistory, Transaction } from "@server/db";
|
||||||
|
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `resource_healthy` alert for the given resource.
|
||||||
|
*
|
||||||
|
* Call this after a previously-unhealthy resource has recovered so that any
|
||||||
|
* matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the resource.
|
||||||
|
* @param resourceId - Numeric primary key of the resource.
|
||||||
|
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
export async function fireResourceHealthyAlert(
|
export async function fireResourceHealthyAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
resourceName?: string | null,
|
resourceName?: string | null,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
send: boolean = true,
|
send: boolean = true,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {}
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: resourceId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "healthy",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("resource", resourceId);
|
||||||
|
|
||||||
|
if (!send) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_healthy",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_toggle",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
resourceId,
|
||||||
|
status: "healthy",
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `resource_unhealthy` alert for the given resource.
|
||||||
|
*
|
||||||
|
* Call this after a resource has been detected as unhealthy so that any
|
||||||
|
* matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the resource.
|
||||||
|
* @param resourceId - Numeric primary key of the resource.
|
||||||
|
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
export async function fireResourceUnhealthyAlert(
|
export async function fireResourceUnhealthyAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
resourceName?: string | null,
|
resourceName?: string | null,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
send: boolean = true,
|
send: boolean = true,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {}
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: resourceId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "unhealthy",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("resource", resourceId);
|
||||||
|
|
||||||
export async function fireResourceToggleAlert(
|
if (!send) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_unhealthy",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_toggle",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
resourceId,
|
||||||
|
status: "unhealthy",
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `resource_degraded` alert for the given resource.
|
||||||
|
*
|
||||||
|
* Call this after a resource has been detected as degraded so that any
|
||||||
|
* matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the resource.
|
||||||
|
* @param resourceId - Numeric primary key of the resource.
|
||||||
|
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
|
export async function fireResourceDegradedAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
resourceName?: string | null,
|
resourceName?: string | null,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
send: boolean = true,
|
send: boolean = true,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {}
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: resourceId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "degraded",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("resource", resourceId);
|
||||||
|
|
||||||
|
if (!send) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_degraded",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_toggle",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
resourceId,
|
||||||
|
status: "degraded",
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `resource_unknown` alert for the given resource.
|
||||||
|
*
|
||||||
|
* Call this when all health checks on a resource are disabled so that the
|
||||||
|
* resource status transitions to unknown.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the resource.
|
||||||
|
* @param resourceId - Numeric primary key of the resource.
|
||||||
|
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
|
export async function fireResourceUnknownAlert(
|
||||||
|
orgId: string,
|
||||||
|
resourceId: number,
|
||||||
|
resourceName?: string | null,
|
||||||
|
extra?: Record<string, unknown>,
|
||||||
|
send: boolean = true,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: resourceId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "unknown",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("resource", resourceId);
|
||||||
|
|
||||||
|
if (!send) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "resource_toggle",
|
||||||
|
orgId,
|
||||||
|
resourceId,
|
||||||
|
data: {
|
||||||
|
resourceId,
|
||||||
|
status: "unknown",
|
||||||
|
...(resourceName != null ? { resourceName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,156 @@
|
|||||||
// stub
|
import logger from "@server/logger";
|
||||||
|
import { processAlerts } from "#dynamic/lib/alerts";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
logsDb,
|
||||||
|
statusHistory,
|
||||||
|
targetHealthCheck,
|
||||||
|
Transaction
|
||||||
|
} from "@server/db";
|
||||||
|
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `site_online` alert for the given site.
|
||||||
|
*
|
||||||
|
* Call this after the site has been confirmed reachable / connected so that
|
||||||
|
* any matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the site.
|
||||||
|
* @param siteId - Numeric primary key of the site.
|
||||||
|
* @param siteName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
export async function fireSiteOnlineAlert(
|
export async function fireSiteOnlineAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
siteId: number,
|
siteId: number,
|
||||||
siteName?: string,
|
siteName?: string,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return;
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "site",
|
||||||
|
entityId: siteId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "online",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("site", siteId);
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "site_online",
|
||||||
|
orgId,
|
||||||
|
siteId,
|
||||||
|
data: {
|
||||||
|
...(siteName != null ? { siteName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "site_toggle",
|
||||||
|
orgId,
|
||||||
|
siteId,
|
||||||
|
data: {
|
||||||
|
siteId,
|
||||||
|
status: "online",
|
||||||
|
...(siteName != null ? { siteName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a `site_offline` alert for the given site.
|
||||||
|
*
|
||||||
|
* Call this after the site has been detected as unreachable / disconnected so
|
||||||
|
* that any matching `alertRules` can dispatch their email and webhook actions.
|
||||||
|
*
|
||||||
|
* @param orgId - Organisation that owns the site.
|
||||||
|
* @param siteId - Numeric primary key of the site.
|
||||||
|
* @param siteName - Human-readable name shown in notifications (optional).
|
||||||
|
* @param extra - Any additional key/value pairs to include in the payload.
|
||||||
|
*/
|
||||||
export async function fireSiteOfflineAlert(
|
export async function fireSiteOfflineAlert(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
siteId: number,
|
siteId: number,
|
||||||
siteName?: string,
|
siteName?: string,
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
trx?: unknown
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return;
|
try {
|
||||||
|
await logsDb.insert(statusHistory).values({
|
||||||
|
entityType: "site",
|
||||||
|
entityId: siteId,
|
||||||
|
orgId: orgId,
|
||||||
|
status: "offline",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
await invalidateStatusHistoryCache("site", siteId);
|
||||||
|
|
||||||
|
const unhealthyHealthChecks = await trx
|
||||||
|
.update(targetHealthCheck)
|
||||||
|
.set({ hcHealth: "unhealthy" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(targetHealthCheck.orgId, orgId),
|
||||||
|
eq(targetHealthCheck.siteId, siteId),
|
||||||
|
eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
for (const healthCheck of unhealthyHealthChecks) {
|
||||||
|
logger.info(
|
||||||
|
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
|
||||||
|
);
|
||||||
|
|
||||||
|
await fireHealthCheckUnhealthyAlert(
|
||||||
|
healthCheck.orgId,
|
||||||
|
healthCheck.targetHealthCheckId,
|
||||||
|
healthCheck.name,
|
||||||
|
healthCheck.targetId, // for the resource if we have one
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "site_offline",
|
||||||
|
orgId,
|
||||||
|
siteId,
|
||||||
|
data: {
|
||||||
|
...(siteName != null ? { siteName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await processAlerts({
|
||||||
|
eventType: "site_toggle",
|
||||||
|
orgId,
|
||||||
|
siteId,
|
||||||
|
data: {
|
||||||
|
siteId,
|
||||||
|
status: "offline",
|
||||||
|
...(siteName != null ? { siteName } : {}),
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./events/siteEvents";
|
export * from "./events/siteEvents";
|
||||||
export * from "./events/healthCheckEvents";
|
export * from "./events/healthCheckEvents";
|
||||||
export * from "./events/resourceEvents";
|
export * from "./events/resourceEvents";
|
||||||
|
export * from "./processAlerts";
|
||||||
|
|||||||
5
server/lib/alerts/processAlerts.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AlertContext } from "@server/routers/alertRule/types";
|
||||||
|
|
||||||
|
export async function processAlerts(context: AlertContext): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
export async function getOrgTierData(
|
export async function getOrgTierData(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<{ tier: string | null; active: boolean }> {
|
): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> {
|
||||||
const tier = null;
|
const tier = null;
|
||||||
const active = false;
|
const active = false;
|
||||||
|
const isTrial = false;
|
||||||
|
|
||||||
return { tier, active };
|
return { tier, active, isTrial };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = {
|
|||||||
|
|
||||||
export const tier2LimitSet: LimitSet = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 100,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
@@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = {
|
|||||||
|
|
||||||
export const tier3LimitSet: LimitSet = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 500,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.SIEM]: ["enterprise"],
|
[TierFeature.SIEM]: ["enterprise"],
|
||||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,42 +131,23 @@ export async function updateClientResources(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const allSites: { siteId: number }[] = [];
|
const allSites: { siteId: number }[] = [];
|
||||||
if (resourceData.site) {
|
|
||||||
let siteSingle;
|
|
||||||
const resourceSiteId = resourceData.site;
|
|
||||||
|
|
||||||
if (resourceSiteId) {
|
if (resourceData.site) {
|
||||||
// Look up site by niceId
|
// Look up site by niceId
|
||||||
[siteSingle] = await trx
|
const [siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sites.niceId, resourceSiteId),
|
eq(sites.niceId, resourceData.site),
|
||||||
eq(sites.orgId, orgId)
|
eq(sites.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
} else if (siteId) {
|
if (siteSingle) {
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
|
||||||
[siteSingle] = await trx
|
|
||||||
.select({ siteId: sites.siteId })
|
|
||||||
.from(sites)
|
|
||||||
.where(
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Target site is required`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!siteSingle) {
|
|
||||||
throw new Error(
|
|
||||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
allSites.push(siteSingle);
|
allSites.push(siteSingle);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resourceData.sites) {
|
if (resourceData.sites) {
|
||||||
for (const siteNiceId of resourceData.sites) {
|
for (const siteNiceId of resourceData.sites) {
|
||||||
@@ -180,14 +161,30 @@ export async function updateClientResources(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!site) {
|
if (site) {
|
||||||
throw new Error(
|
|
||||||
`Site not found: ${siteId} in org ${orgId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
allSites.push(site);
|
allSites.push(site);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteId && allSites.length === 0) {
|
||||||
|
// only add if there are not provided sites
|
||||||
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
|
const [siteSingle] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
if (siteSingle) {
|
||||||
|
allSites.push(siteSingle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSites.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
@@ -364,7 +361,7 @@ export async function updateClientResources(
|
|||||||
} else {
|
} else {
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { hashPassword } from "@server/auth/password";
|
|||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
|
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
@@ -165,7 +165,8 @@ export async function updateProxyResources(
|
|||||||
hcStatus: healthcheckData?.status,
|
hcStatus: healthcheckData?.status,
|
||||||
hcHealth: "unknown",
|
hcHealth: "unknown",
|
||||||
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
||||||
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
|
hcUnhealthyThreshold:
|
||||||
|
healthcheckData?.["unhealthy-threshold"]
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -544,8 +545,10 @@ export async function updateProxyResources(
|
|||||||
healthcheckData?.["follow-redirects"],
|
healthcheckData?.["follow-redirects"],
|
||||||
hcMethod: healthcheckData?.method,
|
hcMethod: healthcheckData?.method,
|
||||||
hcStatus: healthcheckData?.status,
|
hcStatus: healthcheckData?.status,
|
||||||
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
hcHealthyThreshold:
|
||||||
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
|
healthcheckData?.["healthy-threshold"],
|
||||||
|
hcUnhealthyThreshold:
|
||||||
|
healthcheckData?.["unhealthy-threshold"]
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -1120,8 +1123,10 @@ function checkIfHealthcheckChanged(
|
|||||||
JSON.stringify(incoming.hcHeaders)
|
JSON.stringify(incoming.hcHeaders)
|
||||||
)
|
)
|
||||||
return true;
|
return true;
|
||||||
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true;
|
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold)
|
||||||
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true;
|
return true;
|
||||||
|
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold)
|
||||||
|
return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1189,11 @@ async function getDomainId(
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
|
): Promise<{
|
||||||
|
subdomain: string | null;
|
||||||
|
domainId: string;
|
||||||
|
wildcard: boolean;
|
||||||
|
} | null> {
|
||||||
const isWildcardFullDomain = fullDomain.startsWith("*.");
|
const isWildcardFullDomain = fullDomain.startsWith("*.");
|
||||||
|
|
||||||
const possibleDomains = await trx
|
const possibleDomains = await trx
|
||||||
|
|||||||
@@ -25,9 +25,162 @@ import { tierMatrix } from "./billing/tierMatrix";
|
|||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx?: Transaction
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const execute = async (transaction: Transaction) => {
|
const execute = async (transaction: Transaction | typeof db) => {
|
||||||
|
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||||
|
const adminRoleCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof roles.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const exitNodesCache = new Map<
|
||||||
|
string,
|
||||||
|
Awaited<ReturnType<typeof listExitNodes>>
|
||||||
|
>();
|
||||||
|
const isOrgLicensedCache = new Map<string, boolean>();
|
||||||
|
const existingClientCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof clients.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const roleClientAccessCache = new Map<string, boolean>();
|
||||||
|
const userClientAccessCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
const getOrgOlmKey = (orgId: string, olmId: string) =>
|
||||||
|
`${orgId}:${olmId}`;
|
||||||
|
const getRoleClientKey = (roleId: number, clientId: number) =>
|
||||||
|
`${roleId}:${clientId}`;
|
||||||
|
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
||||||
|
`${cachedUserId}:${clientId}`;
|
||||||
|
|
||||||
|
const getOrg = async (orgId: string) => {
|
||||||
|
if (orgCache.has(orgId)) {
|
||||||
|
return orgCache.get(orgId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [org] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
orgCache.set(orgId, org ?? null);
|
||||||
|
|
||||||
|
return org ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminRole = async (orgId: string) => {
|
||||||
|
if (adminRoleCache.has(orgId)) {
|
||||||
|
return adminRoleCache.get(orgId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [adminRole] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
adminRoleCache.set(orgId, adminRole ?? null);
|
||||||
|
|
||||||
|
return adminRole ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExitNodes = async (orgId: string) => {
|
||||||
|
if (exitNodesCache.has(orgId)) {
|
||||||
|
return exitNodesCache.get(orgId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitNodes = await listExitNodes(orgId);
|
||||||
|
exitNodesCache.set(orgId, exitNodes);
|
||||||
|
|
||||||
|
return exitNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIsOrgLicensed = async (orgId: string) => {
|
||||||
|
if (isOrgLicensedCache.has(orgId)) {
|
||||||
|
return isOrgLicensedCache.get(orgId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.deviceApprovals
|
||||||
|
);
|
||||||
|
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
||||||
|
|
||||||
|
return isOrgLicensed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExistingClient = async (orgId: string, olmId: string) => {
|
||||||
|
const key = getOrgOlmKey(orgId, olmId);
|
||||||
|
if (existingClientCache.has(key)) {
|
||||||
|
return existingClientCache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
eq(clients.olmId, olmId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
existingClientCache.set(key, existingClient ?? null);
|
||||||
|
|
||||||
|
return existingClient ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRoleClientAccess = async (
|
||||||
|
roleId: number,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getRoleClientKey(roleId, clientId);
|
||||||
|
if (roleClientAccessCache.has(key)) {
|
||||||
|
return roleClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingRoleClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(roleClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleClients.roleId, roleId),
|
||||||
|
eq(roleClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingRoleClient);
|
||||||
|
roleClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserClientAccess = async (
|
||||||
|
cachedUserId: string,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getUserClientKey(cachedUserId, clientId);
|
||||||
|
if (userClientAccessCache.has(key)) {
|
||||||
|
return userClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUserClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userClients.userId, cachedUserId),
|
||||||
|
eq(userClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingUserClient);
|
||||||
|
userClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
// Get all OLMs for this user
|
// Get all OLMs for this user
|
||||||
const userOlms = await transaction
|
const userOlms = await transaction
|
||||||
.select()
|
.select()
|
||||||
@@ -54,7 +207,9 @@ export async function calculateUserClientsForOrgs(
|
|||||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
.where(eq(userOrgs.userId, userId));
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
|
const userOrgIds = [
|
||||||
|
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
||||||
|
];
|
||||||
const orgIdToRoleRows = new Map<
|
const orgIdToRoleRows = new Map<
|
||||||
string,
|
string,
|
||||||
(typeof userOrgRoleRows)[0][]
|
(typeof userOrgRoleRows)[0][]
|
||||||
@@ -64,6 +219,13 @@ export async function calculateUserClientsForOrgs(
|
|||||||
list.push(r);
|
list.push(r);
|
||||||
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
||||||
}
|
}
|
||||||
|
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
||||||
|
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
||||||
|
orgRequiresDeviceApprovalRole.set(
|
||||||
|
orgId,
|
||||||
|
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// For each OLM, ensure there's a client in each org the user is in
|
// For each OLM, ensure there's a client in each org the user is in
|
||||||
for (const olm of userOlms) {
|
for (const olm of userOlms) {
|
||||||
@@ -71,10 +233,7 @@ export async function calculateUserClientsForOrgs(
|
|||||||
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
||||||
const userOrg = roleRowsForOrg[0].userOrgs;
|
const userOrg = roleRowsForOrg[0].userOrgs;
|
||||||
|
|
||||||
const [org] = await transaction
|
const org = await getOrg(orgId);
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -91,11 +250,7 @@ export async function calculateUserClientsForOrgs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get admin role for this org (needed for access grants)
|
// Get admin role for this org (needed for access grants)
|
||||||
const [adminRole] = await transaction
|
const adminRole = await getAdminRole(orgId);
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
if (!adminRole) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -105,64 +260,50 @@ export async function calculateUserClientsForOrgs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if a client already exists for this OLM+user+org combination
|
// Check if a client already exists for this OLM+user+org combination
|
||||||
const [existingClient] = await transaction
|
const existingClient = await getExistingClient(
|
||||||
.select()
|
orgId,
|
||||||
.from(clients)
|
olm.olmId
|
||||||
.where(
|
);
|
||||||
and(
|
|
||||||
eq(clients.userId, userId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
eq(clients.olmId, olm.olmId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingClient) {
|
if (existingClient) {
|
||||||
// Ensure admin role has access to the client
|
// Ensure admin role has access to the client
|
||||||
const [existingRoleClient] = await transaction
|
const hasRoleAccess = await hasRoleClientAccess(
|
||||||
.select()
|
adminRole.roleId,
|
||||||
.from(roleClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleClients.roleId, adminRole.roleId),
|
|
||||||
eq(
|
|
||||||
roleClients.clientId,
|
|
||||||
existingClient.clientId
|
existingClient.clientId
|
||||||
)
|
);
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existingRoleClient) {
|
if (!hasRoleAccess) {
|
||||||
await transaction.insert(roleClients).values({
|
await transaction.insert(roleClients).values({
|
||||||
roleId: adminRole.roleId,
|
roleId: adminRole.roleId,
|
||||||
clientId: existingClient.clientId
|
clientId: existingClient.clientId
|
||||||
});
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user has access to the client
|
// Ensure user has access to the client
|
||||||
const [existingUserClient] = await transaction
|
const hasUserAccess = await hasUserClientAccess(
|
||||||
.select()
|
userId,
|
||||||
.from(userClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userClients.userId, userId),
|
|
||||||
eq(
|
|
||||||
userClients.clientId,
|
|
||||||
existingClient.clientId
|
existingClient.clientId
|
||||||
)
|
);
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existingUserClient) {
|
if (!hasUserAccess) {
|
||||||
await transaction.insert(userClients).values({
|
await transaction.insert(userClients).values({
|
||||||
userId,
|
userId,
|
||||||
clientId: existingClient.clientId
|
clientId: existingClient.clientId
|
||||||
});
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, existingClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
);
|
);
|
||||||
@@ -175,7 +316,7 @@ export async function calculateUserClientsForOrgs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get exit nodes for this org
|
// Get exit nodes for this org
|
||||||
const exitNodesList = await listExitNodes(orgId);
|
const exitNodesList = await getExitNodes(orgId);
|
||||||
|
|
||||||
if (exitNodesList.length === 0) {
|
if (exitNodesList.length === 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -206,14 +347,11 @@ export async function calculateUserClientsForOrgs(
|
|||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
||||||
userOrg.orgId,
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
);
|
|
||||||
const requireApproval =
|
const requireApproval =
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
isOrgLicensed &&
|
isOrgLicensed &&
|
||||||
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
|
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
||||||
|
|
||||||
const newClientData: InferInsertModel<typeof clients> = {
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
userId,
|
userId,
|
||||||
@@ -232,6 +370,10 @@ export async function calculateUserClientsForOrgs(
|
|||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values(newClientData)
|
.values(newClientData)
|
||||||
.returning();
|
.returning();
|
||||||
|
existingClientCache.set(
|
||||||
|
getOrgOlmKey(orgId, olm.olmId),
|
||||||
|
newClient
|
||||||
|
);
|
||||||
|
|
||||||
// create approval request
|
// create approval request
|
||||||
if (requireApproval) {
|
if (requireApproval) {
|
||||||
@@ -257,12 +399,20 @@ export async function calculateUserClientsForOrgs(
|
|||||||
roleId: adminRole.roleId,
|
roleId: adminRole.roleId,
|
||||||
clientId: newClient.clientId
|
clientId: newClient.clientId
|
||||||
});
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
// Grant user access to the client
|
// Grant user access to the client
|
||||||
await transaction.insert(userClients).values({
|
await transaction.insert(userClients).values({
|
||||||
userId,
|
userId,
|
||||||
clientId: newClient.clientId
|
clientId: newClient.clientId
|
||||||
});
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
||||||
@@ -287,7 +437,7 @@ export async function calculateUserClientsForOrgs(
|
|||||||
|
|
||||||
async function cleanupOrphanedClients(
|
async function cleanupOrphanedClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction,
|
trx: Transaction | typeof db,
|
||||||
userOrgIds: string[] = []
|
userOrgIds: string[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Find all OLM clients for this user that should be deleted
|
// Find all OLM clients for this user that should be deleted
|
||||||
|
|||||||
@@ -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.18.0";
|
export const APP_VERSION = "1.18.3";
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import z from "zod";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
|
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
interface IPRange {
|
interface IPRange {
|
||||||
start: bigint;
|
start: bigint;
|
||||||
@@ -327,6 +328,9 @@ export async function getNextAvailableClientSubnet(
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
transaction: Transaction | typeof db = db
|
transaction: Transaction | typeof db = db
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`client-subnet-allocation:${orgId}`,
|
||||||
|
async () => {
|
||||||
const [org] = await transaction
|
const [org] = await transaction
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
@@ -337,7 +341,9 @@ export async function getNextAvailableClientSubnet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org.subnet) {
|
||||||
throw new Error(`Organization with ID ${orgId} has no subnet defined`);
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingAddressesSites = await transaction
|
const existingAddressesSites = await transaction
|
||||||
@@ -352,7 +358,9 @@ export async function getNextAvailableClientSubnet(
|
|||||||
address: clients.subnet
|
address: clients.subnet
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
.where(
|
||||||
|
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
const addresses = [
|
const addresses = [
|
||||||
...existingAddressesSites.map(
|
...existingAddressesSites.map(
|
||||||
@@ -369,19 +377,30 @@ export async function getNextAvailableClientSubnet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return subnet;
|
return subnet;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableAliasAddress(
|
export async function getNextAvailableAliasAddress(
|
||||||
orgId: string
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
return await lockManager.withLock(
|
||||||
|
`alias-address-allocation:${orgId}`,
|
||||||
|
async () => {
|
||||||
|
const [org] = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org.subnet) {
|
||||||
throw new Error(`Organization with ID ${orgId} has no subnet defined`);
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org.utilitySubnet) {
|
if (!org.utilitySubnet) {
|
||||||
@@ -390,7 +409,7 @@ export async function getNextAvailableAliasAddress(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingAddresses = await db
|
const existingAddresses = await trx
|
||||||
.select({
|
.select({
|
||||||
aliasAddress: siteResources.aliasAddress
|
aliasAddress: siteResources.aliasAddress
|
||||||
})
|
})
|
||||||
@@ -410,7 +429,11 @@ export async function getNextAvailableAliasAddress(
|
|||||||
`${org.utilitySubnet.split("/")[0]}/29`
|
`${org.utilitySubnet.split("/")[0]}/29`
|
||||||
].filter((address) => address !== null) as string[];
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
|
let subnet = findNextAvailableCidr(
|
||||||
|
addresses,
|
||||||
|
32,
|
||||||
|
org.utilitySubnet
|
||||||
|
);
|
||||||
if (!subnet) {
|
if (!subnet) {
|
||||||
throw new Error("No available subnets remaining in space");
|
throw new Error("No available subnets remaining in space");
|
||||||
}
|
}
|
||||||
@@ -419,9 +442,12 @@ export async function getNextAvailableAliasAddress(
|
|||||||
subnet = subnet.split("/")[0];
|
subnet = subnet.split("/")[0];
|
||||||
|
|
||||||
return subnet;
|
return subnet;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableOrgSubnet(): Promise<string> {
|
export async function getNextAvailableOrgSubnet(): Promise<string> {
|
||||||
|
return await lockManager.withLock("org-subnet-allocation", async () => {
|
||||||
const existingAddresses = await db
|
const existingAddresses = await db
|
||||||
.select({
|
.select({
|
||||||
subnet: orgs.subnet
|
subnet: orgs.subnet
|
||||||
@@ -441,6 +467,7 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return subnet;
|
return subnet;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRemoteSubnets(
|
export function generateRemoteSubnets(
|
||||||
@@ -478,7 +505,12 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
|
|||||||
|
|
||||||
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||||
return allSiteResources
|
return allSiteResources
|
||||||
.filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http")))
|
.filter(
|
||||||
|
(sr) =>
|
||||||
|
sr.aliasAddress &&
|
||||||
|
((sr.alias && sr.mode == "host") ||
|
||||||
|
(sr.fullDomain && sr.mode == "http"))
|
||||||
|
)
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
alias: sr.alias || sr.fullDomain,
|
alias: sr.alias || sr.fullDomain,
|
||||||
aliasAddress: sr.aliasAddress
|
aliasAddress: sr.aliasAddress
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ export async function getCachedStatusHistory(
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
// Anchor to UTC midnight so the query window aligns with stable calendar days
|
||||||
const startSec = nowSec - days * 86400;
|
const utcToday = new Date();
|
||||||
|
utcToday.setUTCHours(0, 0, 0, 0);
|
||||||
|
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
|
||||||
|
const startSec = todayMidnightSec - days * 86400;
|
||||||
|
|
||||||
const events = await logsDb
|
const events = await logsDb
|
||||||
.select()
|
.select()
|
||||||
@@ -110,11 +113,18 @@ export function computeBuckets(
|
|||||||
days: number
|
days: number
|
||||||
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
|
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Anchor bucket boundaries to UTC midnight so dates are stable calendar days
|
||||||
|
// and don't drift as the cache expires and is recomputed
|
||||||
|
const utcToday = new Date();
|
||||||
|
utcToday.setUTCHours(0, 0, 0, 0);
|
||||||
|
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
|
||||||
|
|
||||||
const buckets: StatusHistoryDayBucket[] = [];
|
const buckets: StatusHistoryDayBucket[] = [];
|
||||||
let totalDowntime = 0;
|
let totalDowntime = 0;
|
||||||
|
|
||||||
for (let d = 0; d < days; d++) {
|
for (let d = 0; d < days; d++) {
|
||||||
const dayStartSec = nowSec - (days - d) * 86400;
|
const dayStartSec = todayMidnightSec - (days - 1 - d) * 86400;
|
||||||
const dayEndSec = dayStartSec + 86400;
|
const dayEndSec = dayStartSec + 86400;
|
||||||
|
|
||||||
const dayEvents = events.filter(
|
const dayEvents = events.filter(
|
||||||
|
|||||||
@@ -535,6 +535,24 @@ export class TraefikConfigManager {
|
|||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
domains.add(match[1]);
|
domains.add(match[1]);
|
||||||
}
|
}
|
||||||
|
// Match HostRegexp(`^[^.]+\.parent.domain$`) generated for wildcard resources
|
||||||
|
const hostRegexpMatch = router.rule.match(
|
||||||
|
/HostRegexp\(`([^`]+)`\)/
|
||||||
|
);
|
||||||
|
if (hostRegexpMatch && hostRegexpMatch[1]) {
|
||||||
|
const innerRegex = hostRegexpMatch[1];
|
||||||
|
// Pattern is always ^[^.]+\.PARENT_DOMAIN$ where dots are escaped as \.
|
||||||
|
const domainMatch = innerRegex.match(
|
||||||
|
/^\^\[\^\.\]\+\\\.(.+)\$$/
|
||||||
|
);
|
||||||
|
if (domainMatch && domainMatch[1]) {
|
||||||
|
const parentDomain = domainMatch[1].replace(
|
||||||
|
/\\\./g,
|
||||||
|
"."
|
||||||
|
);
|
||||||
|
domains.add(`*.${parentDomain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import {
|
import {
|
||||||
certificates,
|
certificates,
|
||||||
@@ -250,71 +251,129 @@ function extractFirstCert(pemBundle: string): string | null {
|
|||||||
return match ? match[0] : null;
|
return match ? match[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncAcmeCerts(
|
/**
|
||||||
acmeJsonPath: string,
|
* Determine whether an ACME cert entry represents a wildcard cert by checking
|
||||||
resolver: string
|
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
|
||||||
): Promise<void> {
|
* Traefik) store the bare apex in `main` and only put the wildcard form in
|
||||||
let raw: string;
|
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
|
||||||
|
*/
|
||||||
|
function detectWildcard(
|
||||||
|
main: string,
|
||||||
|
sans: string[] | undefined
|
||||||
|
): { wildcard: boolean; wildcardSan: string | null } {
|
||||||
|
if (main.startsWith("*.")) {
|
||||||
|
return { wildcard: true, wildcardSan: null };
|
||||||
|
}
|
||||||
|
if (Array.isArray(sans)) {
|
||||||
|
for (const san of sans) {
|
||||||
|
if (typeof san !== "string") continue;
|
||||||
|
if (san === `*.${main}` || san.startsWith("*.")) {
|
||||||
|
return { wildcard: true, wildcardSan: san };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { wildcard: false, wildcardSan: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpCert {
|
||||||
|
wildcard: boolean;
|
||||||
|
altName: string;
|
||||||
|
certName: string;
|
||||||
|
commonName: string;
|
||||||
|
certFile: string;
|
||||||
|
keyFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
|
||||||
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
response = await fetch(endpoint);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
logger.debug(
|
||||||
|
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let acmeJson: AcmeJson;
|
if (!response.ok) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: HTTP endpoint returned status ${response.status}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpCerts: HttpCert[];
|
||||||
try {
|
try {
|
||||||
acmeJson = JSON.parse(raw);
|
httpCerts = await response.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolverData = acmeJson[resolver];
|
|
||||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const cert of resolverData.Certificates) {
|
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
|
||||||
const domain = cert.domain?.main;
|
|
||||||
const wildcard = domain.startsWith("*.");
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cert.certificate || !cert.key) {
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
`acmeCertSync: no certificates returned from HTTP endpoint`
|
||||||
);
|
);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
for (const cert of httpCerts) {
|
||||||
"utf8"
|
const domain = cert?.certName;
|
||||||
);
|
|
||||||
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
|
||||||
|
|
||||||
if (!certPem.trim() || !keyPem.trim()) {
|
if (!domain || typeof domain !== "string") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
`acmeCertSync: skipping HTTP cert with missing certName`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cert already exists in DB
|
const certPem = cert.certFile;
|
||||||
|
const keyPem = cert.keyFile;
|
||||||
|
|
||||||
|
if (!certPem?.trim() || !keyPem?.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||||
|
if (!firstCertPemForValidation) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedX509: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
validatedX509 = new crypto.X509Certificate(
|
||||||
|
firstCertPemForValidation
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(keyPem);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcard = cert.wildcard ?? false;
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(certificates)
|
.from(certificates)
|
||||||
.where(
|
.where(eq(certificates.domain, domain))
|
||||||
and(
|
|
||||||
eq(certificates.domain, domain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let oldCertPem: string | null = null;
|
let oldCertPem: string | null = null;
|
||||||
@@ -326,14 +385,10 @@ async function syncAcmeCerts(
|
|||||||
existing[0].certFile,
|
existing[0].certFile,
|
||||||
config.getRawConfig().server.secret!
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
if (storedCertPem === certPem) {
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
logger.debug(
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Cert has changed; capture old values so we can send a correct
|
|
||||||
// update message to the newt after the DB write.
|
|
||||||
oldCertPem = storedCertPem;
|
oldCertPem = storedCertPem;
|
||||||
if (existing[0].keyFile) {
|
if (existing[0].keyFile) {
|
||||||
try {
|
try {
|
||||||
@@ -348,26 +403,22 @@ async function syncAcmeCerts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Decryption failure means we should proceed with the update
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse cert expiry from the first cert in the PEM bundle
|
|
||||||
let expiresAt: number | null = null;
|
let expiresAt: number | null = null;
|
||||||
const firstCertPem = extractFirstCert(certPem);
|
|
||||||
if (firstCertPem) {
|
|
||||||
try {
|
try {
|
||||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
expiresAt = Math.floor(
|
||||||
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
new Date(validatedX509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedCert = encrypt(
|
const encryptedCert = encrypt(
|
||||||
certPem,
|
certPem,
|
||||||
@@ -379,6 +430,126 @@ async function syncAcmeCerts(
|
|||||||
);
|
);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
oldCertPem,
|
||||||
|
oldKeyPem
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeCertForDomain(
|
||||||
|
domain: string,
|
||||||
|
certPem: string,
|
||||||
|
keyPem: string,
|
||||||
|
validatedX509: crypto.X509Certificate
|
||||||
|
): Promise<void> {
|
||||||
|
const wildcard = domain.startsWith("*.");
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let oldCertPem: string | null = null;
|
||||||
|
let oldKeyPem: string | null = null;
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decrypt(
|
||||||
|
existing[0].certFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oldCertPem = storedCertPem;
|
||||||
|
if (existing[0].keyFile) {
|
||||||
|
try {
|
||||||
|
oldKeyPem = decrypt(
|
||||||
|
existing[0].keyFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
} catch (keyErr) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
try {
|
||||||
|
expiresAt = Math.floor(
|
||||||
|
new Date(validatedX509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedCert = encrypt(
|
||||||
|
certPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const domainId = await findDomainId(domain);
|
const domainId = await findDomainId(domain);
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -437,9 +608,196 @@ async function syncAcmeCerts(
|
|||||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// For a brand-new cert, push to any SSL resources that were waiting for it
|
|
||||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAcmeJsonFiles(dirPath: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not read directory "${dirPath}": ${err}`
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...findAcmeJsonFiles(fullPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
// check if it is a json file
|
||||||
|
if (entry.name.endsWith(".json")) {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(fullPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not read file "${fullPath}": ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acmeJson: AcmeJson;
|
||||||
|
try {
|
||||||
|
acmeJson = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvers = Object.keys(acmeJson || {});
|
||||||
|
if (resolvers.length === 0) {
|
||||||
|
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect certificates from every resolver. If the same domain appears in
|
||||||
|
// multiple resolvers, the last one wins (resolvers iterated in object order).
|
||||||
|
const allCerts: AcmeCert[] = [];
|
||||||
|
for (const resolver of resolvers) {
|
||||||
|
const resolverData = acmeJson[resolver];
|
||||||
|
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
for (const cert of resolverData.Certificates) {
|
||||||
|
allCerts.push(cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cert of allCerts) {
|
||||||
|
const mainDomain = cert?.domain?.main;
|
||||||
|
|
||||||
|
if (!mainDomain || typeof mainDomain !== "string") {
|
||||||
|
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cert.certificate || !cert.key) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let certPem: string;
|
||||||
|
let keyPem: string;
|
||||||
|
try {
|
||||||
|
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
|
||||||
|
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the decoded data actually parses as a real X.509 cert
|
||||||
|
// before we touch the database. This prevents importing partially-written
|
||||||
|
// or corrupted entries from acme.json.
|
||||||
|
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||||
|
if (!firstCertPemForValidation) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedX509: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
validatedX509 = new crypto.X509Certificate(
|
||||||
|
firstCertPemForValidation
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity-check the private key parses too
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(keyPem);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all domains covered by this cert: main + every SAN.
|
||||||
|
// Each domain gets its own row in the certificates table so that
|
||||||
|
// lookups by any hostname on the cert succeed independently.
|
||||||
|
const allDomains = new Set<string>([mainDomain]);
|
||||||
|
if (Array.isArray(cert.domain?.sans)) {
|
||||||
|
for (const san of cert.domain.sans) {
|
||||||
|
if (typeof san === "string" && san.trim()) {
|
||||||
|
allDomains.add(san.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const domain of allDomains) {
|
||||||
|
try {
|
||||||
|
await storeCertForDomain(
|
||||||
|
domain,
|
||||||
|
certPem,
|
||||||
|
keyPem,
|
||||||
|
validatedX509
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,21 +826,63 @@ export function initAcmeCertSync(): void {
|
|||||||
const acmeJsonPath =
|
const acmeJsonPath =
|
||||||
privateConfigData.acme?.acme_json_path ??
|
privateConfigData.acme?.acme_json_path ??
|
||||||
"config/letsencrypt/acme.json";
|
"config/letsencrypt/acme.json";
|
||||||
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
|
||||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||||
|
const httpEndpoint = privateConfigData.acme?.acme_http_endpoint;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
||||||
);
|
);
|
||||||
|
if (httpEndpoint) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Run immediately on init, then on the configured interval
|
const runSync = () => {
|
||||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
if (httpEndpoint) {
|
||||||
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
|
||||||
|
let stat: fs.Stats | null = null;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(acmeJsonPath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
if (stat.isDirectory()) {
|
||||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
const files = findAcmeJsonFiles(acmeJsonPath);
|
||||||
|
if (files.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
|
||||||
|
);
|
||||||
|
for (const file of files) {
|
||||||
|
syncAcmeCerts(file).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
`acmeCertSync: error during sync of "${file}": ${err}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
});
|
});
|
||||||
}, intervalMs);
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run immediately on init, then on the configured interval
|
||||||
|
runSync();
|
||||||
|
|
||||||
|
setInterval(runSync, intervalMs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 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 logger from "@server/logger";
|
|
||||||
import { processAlerts } from "../processAlerts";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
statusHistory,
|
|
||||||
targetHealthCheck,
|
|
||||||
targets,
|
|
||||||
resources,
|
|
||||||
Transaction,
|
|
||||||
logsDb
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
|
||||||
import {
|
|
||||||
fireResourceDegradedAlert,
|
|
||||||
fireResourceHealthyAlert,
|
|
||||||
fireResourceUnhealthyAlert,
|
|
||||||
fireResourceUnknownAlert
|
|
||||||
} from "./resourceEvents";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `health_check_healthy` alert for the given health check.
|
|
||||||
*
|
|
||||||
* Call this after a previously-failing health check has recovered so that any
|
|
||||||
* matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the health check.
|
|
||||||
* @param healthCheckId - Numeric primary key of the health check.
|
|
||||||
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireHealthCheckHealthyAlert(
|
|
||||||
orgId: string,
|
|
||||||
healthCheckId: number,
|
|
||||||
healthCheckName?: string | null,
|
|
||||||
healthCheckTargetId?: number | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "health_check",
|
|
||||||
entityId: healthCheckId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "healthy",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
|
||||||
|
|
||||||
await handleResource(orgId, healthCheckTargetId, send, trx);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "health_check_healthy",
|
|
||||||
orgId,
|
|
||||||
healthCheckId,
|
|
||||||
data: {
|
|
||||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "health_check_toggle",
|
|
||||||
orgId,
|
|
||||||
healthCheckId,
|
|
||||||
data: {
|
|
||||||
healthCheckId,
|
|
||||||
status: "healthy",
|
|
||||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `health_check_unhealthy` alert for the given health check.
|
|
||||||
*
|
|
||||||
* Call this after a health check has been detected as failing so that any
|
|
||||||
* matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the health check.
|
|
||||||
* @param healthCheckId - Numeric primary key of the health check.
|
|
||||||
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireHealthCheckUnhealthyAlert(
|
|
||||||
orgId: string,
|
|
||||||
healthCheckId: number,
|
|
||||||
healthCheckName?: string | null,
|
|
||||||
healthCheckTargetId?: number | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "health_check",
|
|
||||||
entityId: healthCheckId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "unhealthy",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
|
||||||
|
|
||||||
await handleResource(orgId, healthCheckTargetId, send, trx);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "health_check_unhealthy",
|
|
||||||
orgId,
|
|
||||||
healthCheckId,
|
|
||||||
data: {
|
|
||||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "health_check_toggle",
|
|
||||||
orgId,
|
|
||||||
healthCheckId,
|
|
||||||
data: {
|
|
||||||
healthCheckId,
|
|
||||||
status: "unhealthy",
|
|
||||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fireHealthCheckUnknownAlert(
|
|
||||||
orgId: string,
|
|
||||||
healthCheckId: number,
|
|
||||||
healthCheckName?: string | null,
|
|
||||||
healthCheckTargetId?: number | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "health_check",
|
|
||||||
entityId: healthCheckId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "unknown",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
|
||||||
|
|
||||||
await handleResource(orgId, healthCheckTargetId, send, trx);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResource(
|
|
||||||
orgId: string,
|
|
||||||
healthCheckTargetId?: number | null,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
) {
|
|
||||||
if (!healthCheckTargetId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// we have targets lets get them
|
|
||||||
const [target] = await trx
|
|
||||||
.select()
|
|
||||||
.from(targets)
|
|
||||||
.where(eq(targets.targetId, healthCheckTargetId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [resource] = await trx
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.resourceId, target.resourceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherTargets = await trx
|
|
||||||
.select({ hcHealth: targetHealthCheck.hcHealth })
|
|
||||||
.from(targets)
|
|
||||||
.innerJoin(
|
|
||||||
targetHealthCheck,
|
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
|
||||||
)
|
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
|
||||||
|
|
||||||
let health = "healthy";
|
|
||||||
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
|
||||||
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
|
||||||
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
|
||||||
|
|
||||||
if (allUnknown) {
|
|
||||||
logger.debug(
|
|
||||||
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
|
|
||||||
);
|
|
||||||
health = "unknown";
|
|
||||||
} else if (allHealthy) {
|
|
||||||
health = "healthy";
|
|
||||||
} else if (allUnhealthy) {
|
|
||||||
logger.debug(
|
|
||||||
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
|
|
||||||
);
|
|
||||||
health = "unhealthy";
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
|
|
||||||
);
|
|
||||||
health = "degraded";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (health != resource.health) {
|
|
||||||
// it changed
|
|
||||||
await trx
|
|
||||||
.update(resources)
|
|
||||||
.set({ health })
|
|
||||||
.where(eq(resources.resourceId, resource.resourceId));
|
|
||||||
|
|
||||||
if (health === "unknown") {
|
|
||||||
await fireResourceUnknownAlert(
|
|
||||||
orgId,
|
|
||||||
resource.resourceId,
|
|
||||||
resource.name,
|
|
||||||
undefined,
|
|
||||||
send,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
} else if (health === "unhealthy") {
|
|
||||||
await fireResourceUnhealthyAlert(
|
|
||||||
orgId,
|
|
||||||
resource.resourceId,
|
|
||||||
resource.name,
|
|
||||||
undefined,
|
|
||||||
send,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
} else if (health === "healthy") {
|
|
||||||
await fireResourceHealthyAlert(
|
|
||||||
orgId,
|
|
||||||
resource.resourceId,
|
|
||||||
resource.name,
|
|
||||||
undefined,
|
|
||||||
send,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
} else if (health === "degraded") {
|
|
||||||
await fireResourceDegradedAlert(
|
|
||||||
orgId,
|
|
||||||
resource.resourceId,
|
|
||||||
resource.name,
|
|
||||||
undefined,
|
|
||||||
send,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 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 logger from "@server/logger";
|
|
||||||
import { processAlerts } from "../processAlerts";
|
|
||||||
import { db, logsDb, statusHistory, Transaction } from "@server/db";
|
|
||||||
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `resource_healthy` alert for the given resource.
|
|
||||||
*
|
|
||||||
* Call this after a previously-unhealthy resource has recovered so that any
|
|
||||||
* matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the resource.
|
|
||||||
* @param resourceId - Numeric primary key of the resource.
|
|
||||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireResourceHealthyAlert(
|
|
||||||
orgId: string,
|
|
||||||
resourceId: number,
|
|
||||||
resourceName?: string | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "resource",
|
|
||||||
entityId: resourceId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "healthy",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("resource", resourceId);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_healthy",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_toggle",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
resourceId,
|
|
||||||
status: "healthy",
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `resource_unhealthy` alert for the given resource.
|
|
||||||
*
|
|
||||||
* Call this after a resource has been detected as unhealthy so that any
|
|
||||||
* matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the resource.
|
|
||||||
* @param resourceId - Numeric primary key of the resource.
|
|
||||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireResourceUnhealthyAlert(
|
|
||||||
orgId: string,
|
|
||||||
resourceId: number,
|
|
||||||
resourceName?: string | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "resource",
|
|
||||||
entityId: resourceId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "unhealthy",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("resource", resourceId);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_unhealthy",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_toggle",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
resourceId,
|
|
||||||
status: "unhealthy",
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `resource_degraded` alert for the given resource.
|
|
||||||
*
|
|
||||||
* Call this after a resource has been detected as degraded so that any
|
|
||||||
* matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the resource.
|
|
||||||
* @param resourceId - Numeric primary key of the resource.
|
|
||||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireResourceDegradedAlert(
|
|
||||||
orgId: string,
|
|
||||||
resourceId: number,
|
|
||||||
resourceName?: string | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "resource",
|
|
||||||
entityId: resourceId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "degraded",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("resource", resourceId);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_degraded",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_toggle",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
resourceId,
|
|
||||||
status: "degraded",
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `resource_unknown` alert for the given resource.
|
|
||||||
*
|
|
||||||
* Call this when all health checks on a resource are disabled so that the
|
|
||||||
* resource status transitions to unknown.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the resource.
|
|
||||||
* @param resourceId - Numeric primary key of the resource.
|
|
||||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireResourceUnknownAlert(
|
|
||||||
orgId: string,
|
|
||||||
resourceId: number,
|
|
||||||
resourceName?: string | null,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
send: boolean = true,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "resource",
|
|
||||||
entityId: resourceId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "unknown",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("resource", resourceId);
|
|
||||||
|
|
||||||
if (!send) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "resource_toggle",
|
|
||||||
orgId,
|
|
||||||
resourceId,
|
|
||||||
data: {
|
|
||||||
resourceId,
|
|
||||||
status: "unknown",
|
|
||||||
...(resourceName != null ? { resourceName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 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 logger from "@server/logger";
|
|
||||||
import { processAlerts } from "../processAlerts";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
logsDb,
|
|
||||||
statusHistory,
|
|
||||||
targetHealthCheck,
|
|
||||||
Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `site_online` alert for the given site.
|
|
||||||
*
|
|
||||||
* Call this after the site has been confirmed reachable / connected so that
|
|
||||||
* any matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the site.
|
|
||||||
* @param siteId - Numeric primary key of the site.
|
|
||||||
* @param siteName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireSiteOnlineAlert(
|
|
||||||
orgId: string,
|
|
||||||
siteId: number,
|
|
||||||
siteName?: string,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "site",
|
|
||||||
entityId: siteId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "online",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("site", siteId);
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "site_online",
|
|
||||||
orgId,
|
|
||||||
siteId,
|
|
||||||
data: {
|
|
||||||
...(siteName != null ? { siteName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "site_toggle",
|
|
||||||
orgId,
|
|
||||||
siteId,
|
|
||||||
data: {
|
|
||||||
siteId,
|
|
||||||
status: "online",
|
|
||||||
...(siteName != null ? { siteName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire a `site_offline` alert for the given site.
|
|
||||||
*
|
|
||||||
* Call this after the site has been detected as unreachable / disconnected so
|
|
||||||
* that any matching `alertRules` can dispatch their email and webhook actions.
|
|
||||||
*
|
|
||||||
* @param orgId - Organisation that owns the site.
|
|
||||||
* @param siteId - Numeric primary key of the site.
|
|
||||||
* @param siteName - Human-readable name shown in notifications (optional).
|
|
||||||
* @param extra - Any additional key/value pairs to include in the payload.
|
|
||||||
*/
|
|
||||||
export async function fireSiteOfflineAlert(
|
|
||||||
orgId: string,
|
|
||||||
siteId: number,
|
|
||||||
siteName?: string,
|
|
||||||
extra?: Record<string, unknown>,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "site",
|
|
||||||
entityId: siteId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "offline",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
await invalidateStatusHistoryCache("site", siteId);
|
|
||||||
|
|
||||||
const unhealthyHealthChecks = await trx
|
|
||||||
.update(targetHealthCheck)
|
|
||||||
.set({ hcHealth: "unhealthy" })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(targetHealthCheck.orgId, orgId),
|
|
||||||
eq(targetHealthCheck.siteId, siteId),
|
|
||||||
eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
for (const healthCheck of unhealthyHealthChecks) {
|
|
||||||
logger.info(
|
|
||||||
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
|
|
||||||
);
|
|
||||||
|
|
||||||
await fireHealthCheckUnhealthyAlert(
|
|
||||||
healthCheck.orgId,
|
|
||||||
healthCheck.targetHealthCheckId,
|
|
||||||
healthCheck.name,
|
|
||||||
healthCheck.targetId, // for the resource if we have one
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "site_offline",
|
|
||||||
orgId,
|
|
||||||
siteId,
|
|
||||||
data: {
|
|
||||||
...(siteName != null ? { siteName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await processAlerts({
|
|
||||||
eventType: "site_toggle",
|
|
||||||
orgId,
|
|
||||||
siteId,
|
|
||||||
data: {
|
|
||||||
siteId,
|
|
||||||
status: "offline",
|
|
||||||
...(siteName != null ? { siteName } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,3 @@
|
|||||||
export * from "./processAlerts";
|
export * from "./processAlerts";
|
||||||
export * from "./sendAlertWebhook";
|
export * from "./sendAlertWebhook";
|
||||||
export * from "./sendAlertEmail";
|
export * from "./sendAlertEmail";
|
||||||
export * from "./events/siteEvents";
|
|
||||||
export * from "./events/healthCheckEvents";
|
|
||||||
export * from "./events/resourceEvents";
|
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ import { decrypt } from "@server/lib/crypto";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sendAlertWebhook } from "./sendAlertWebhook";
|
import { sendAlertWebhook } from "./sendAlertWebhook";
|
||||||
import { sendAlertEmail } from "./sendAlertEmail";
|
import { sendAlertEmail } from "./sendAlertEmail";
|
||||||
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
|
import {
|
||||||
|
AlertContext,
|
||||||
|
WebhookAlertConfig
|
||||||
|
} from "@server/routers/alertRule/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core alert processing pipeline.
|
* Core alert processing pipeline.
|
||||||
@@ -99,7 +102,10 @@ export async function processAlerts(context: AlertContext): Promise<void> {
|
|||||||
baseConditions,
|
baseConditions,
|
||||||
or(
|
or(
|
||||||
eq(alertRules.allHealthChecks, true),
|
eq(alertRules.allHealthChecks, true),
|
||||||
eq(alertHealthChecks.healthCheckId, context.healthCheckId)
|
eq(
|
||||||
|
alertHealthChecks.healthCheckId,
|
||||||
|
context.healthCheckId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -208,14 +214,19 @@ async function processRule(
|
|||||||
|
|
||||||
for (const action of emailActions) {
|
for (const action of emailActions) {
|
||||||
try {
|
try {
|
||||||
const recipients = await resolveEmailRecipients(action.emailActionId);
|
const recipients = await resolveEmailRecipients(
|
||||||
|
action.emailActionId
|
||||||
|
);
|
||||||
if (recipients.length > 0) {
|
if (recipients.length > 0) {
|
||||||
await sendAlertEmail(recipients, context);
|
await sendAlertEmail(recipients, context);
|
||||||
await db
|
await db
|
||||||
.update(alertEmailActions)
|
.update(alertEmailActions)
|
||||||
.set({ lastSentAt: now })
|
.set({ lastSentAt: now })
|
||||||
.where(
|
.where(
|
||||||
eq(alertEmailActions.emailActionId, action.emailActionId)
|
eq(
|
||||||
|
alertEmailActions.emailActionId,
|
||||||
|
action.emailActionId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -269,7 +280,7 @@ async function processRule(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`processAlerts: failed to send alert webhook for action ${action.webhookActionId}`,
|
`processAlerts: failed to send alert webhook for action ${action.webhookActionId}`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
@@ -289,7 +300,9 @@ async function processRule(
|
|||||||
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
|
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
|
||||||
* - Direct external email addresses
|
* - Direct external email addresses
|
||||||
*/
|
*/
|
||||||
async function resolveEmailRecipients(emailActionId: number): Promise<string[]> {
|
async function resolveEmailRecipients(
|
||||||
|
emailActionId: number
|
||||||
|
): Promise<string[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(alertEmailRecipients)
|
.from(alertEmailRecipients)
|
||||||
|
|||||||
@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
|
|||||||
webhookConfig: WebhookAlertConfig,
|
webhookConfig: WebhookAlertConfig,
|
||||||
context: AlertContext
|
context: AlertContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const payload = {
|
const eventType = context.eventType;
|
||||||
event: context.eventType,
|
const timestamp = new Date().toISOString();
|
||||||
timestamp: new Date().toISOString(),
|
const status = deriveStatus(eventType, context.data);
|
||||||
status: deriveStatus(context.eventType, context.data),
|
const data = { orgId: context.orgId, ...context.data };
|
||||||
data: {
|
|
||||||
orgId: context.orgId,
|
let body: string;
|
||||||
...context.data
|
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
|
||||||
}
|
body = renderTemplate(webhookConfig.bodyTemplate, {
|
||||||
};
|
event: eventType,
|
||||||
|
timestamp,
|
||||||
|
status,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({ event: eventType, timestamp, status, data });
|
||||||
|
}
|
||||||
|
|
||||||
const body = JSON.stringify(payload);
|
|
||||||
const headers = buildHeaders(webhookConfig);
|
const headers = buildHeaders(webhookConfig);
|
||||||
|
|
||||||
let lastError: Error | undefined;
|
let lastError: Error | undefined;
|
||||||
@@ -217,3 +223,80 @@ function buildHeaders(
|
|||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Body template rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TemplateContext {
|
||||||
|
event: string;
|
||||||
|
timestamp: string;
|
||||||
|
status: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a body template with {{event}}, {{timestamp}}, {{status}}, {{data}},
|
||||||
|
* and individual data-field placeholders (e.g. {{orgId}}, {{siteId}}, …).
|
||||||
|
*
|
||||||
|
* Replacement order:
|
||||||
|
* 1. {{data}} → raw JSON of the full data object (prevents re-expansion of
|
||||||
|
* nested values that might look like placeholders).
|
||||||
|
* 2. Top-level scalar fields from data (string values are JSON-escaped;
|
||||||
|
* numbers and booleans are rendered as-is). Unknown placeholders are
|
||||||
|
* left untouched.
|
||||||
|
* 3. The fixed top-level keys: event, timestamp, status.
|
||||||
|
*/
|
||||||
|
function renderTemplate(template: string, ctx: TemplateContext): string {
|
||||||
|
// Step 1 – expand {{data}} first so its contents are already serialised
|
||||||
|
// and won't be touched by later passes.
|
||||||
|
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data));
|
||||||
|
|
||||||
|
// Step 2 – expand individual data fields. Only replace placeholders whose
|
||||||
|
// key actually exists in ctx.data; leave everything else as-is.
|
||||||
|
for (const [key, value] of Object.entries(ctx.data)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
const placeholder = new RegExp(
|
||||||
|
`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\}\\}`,
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
let serialised: string;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
serialised = escapeJsonString(value);
|
||||||
|
} else if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
serialised = String(value);
|
||||||
|
} else {
|
||||||
|
serialised = escapeJsonString(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
rendered = rendered.replace(placeholder, serialised);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 – expand the fixed top-level keys.
|
||||||
|
rendered = rendered
|
||||||
|
.replace(/\{\{event\}\}/g, escapeJsonString(ctx.event))
|
||||||
|
.replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
|
||||||
|
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));
|
||||||
|
|
||||||
|
// Validate the rendered result is valid JSON; if not, log a warning and
|
||||||
|
// fall back to the default payload so the webhook still fires.
|
||||||
|
try {
|
||||||
|
JSON.parse(rendered);
|
||||||
|
return rendered;
|
||||||
|
} catch {
|
||||||
|
logger.warn(
|
||||||
|
`sendAlertWebhook: body template produced invalid JSON for event ` +
|
||||||
|
`"${ctx.event}" destined for a webhook. Falling back to default ` +
|
||||||
|
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
|
||||||
|
);
|
||||||
|
return JSON.stringify({
|
||||||
|
event: ctx.event,
|
||||||
|
timestamp: ctx.timestamp,
|
||||||
|
status: ctx.status,
|
||||||
|
data: ctx.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJsonString(value: string): string {
|
||||||
|
return JSON.stringify(value).slice(1, -1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export interface WebhookAlertConfig {
|
|||||||
headers?: Array<{ key: string; value: string }>;
|
headers?: Array<{ key: string; value: string }>;
|
||||||
/** HTTP method (default POST) */
|
/** HTTP method (default POST) */
|
||||||
method?: string;
|
method?: string;
|
||||||
|
/** Whether to use a custom body template */
|
||||||
|
useBodyTemplate?: boolean;
|
||||||
|
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
|
||||||
|
bodyTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ import { eq, and, ne } from "drizzle-orm";
|
|||||||
|
|
||||||
export async function getOrgTierData(
|
export async function getOrgTierData(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<{ tier: Tier | null; active: boolean }> {
|
): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> {
|
||||||
let tier: Tier | null = null;
|
let tier: Tier | null = null;
|
||||||
let active = false;
|
let active = false;
|
||||||
|
let isTrial = false;
|
||||||
|
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
return { tier, active };
|
return { tier, active, isTrial };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +36,7 @@ export async function getOrgTierData(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return { tier, active };
|
return { tier, active, isTrial };
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = org.orgId;
|
let orgIdToUse = org.orgId;
|
||||||
@@ -44,7 +45,7 @@ export async function getOrgTierData(
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
`Org ${orgId} is not a billing org and does not have a billingOrgId`
|
`Org ${orgId} is not a billing org and does not have a billingOrgId`
|
||||||
);
|
);
|
||||||
return { tier, active };
|
return { tier, active, isTrial };
|
||||||
}
|
}
|
||||||
orgIdToUse = org.billingOrgId;
|
orgIdToUse = org.billingOrgId;
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ export async function getOrgTierData(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
return { tier, active };
|
return { tier, active, isTrial };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for active subscriptions that are not license type
|
// Query for active subscriptions that are not license type
|
||||||
@@ -84,11 +85,13 @@ export async function getOrgTierData(
|
|||||||
tier = subscription.type;
|
tier = subscription.type;
|
||||||
active = true;
|
active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTrial = subscription.trial ?? false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If org not found or error occurs, return null tier and inactive
|
// If org not found or error occurs, return null tier and inactive
|
||||||
// This is acceptable behavior as per the function signature
|
// This is acceptable behavior as per the function signature
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tier, active };
|
return { tier, active, isTrial };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
|
|||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
|
||||||
export const privateConfigSchema = z.object({
|
export const privateConfigSchema = z
|
||||||
|
.object({
|
||||||
app: z
|
app: z
|
||||||
.object({
|
.object({
|
||||||
region: z.string().optional().default("default"),
|
region: z.string().optional().default("default"),
|
||||||
@@ -70,10 +71,7 @@ export const privateConfigSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
tls: z
|
tls: z
|
||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
@@ -102,7 +100,7 @@ export const privateConfigSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.default("config/letsencrypt/acme.json"),
|
.default("config/letsencrypt/acme.json"),
|
||||||
resolver: z.string().optional().default("letsencrypt"),
|
acme_http_endpoint: z.string().optional(),
|
||||||
sync_interval_ms: z.number().optional().default(5000)
|
sync_interval_ms: z.number().optional().default(5000)
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -182,13 +180,13 @@ export const privateConfigSchema = z.object({
|
|||||||
webhook_secret: z
|
webhook_secret: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
|
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET"))
|
||||||
// s3Bucket: z.string(),
|
// s3Bucket: z.string(),
|
||||||
// s3Region: z.string().default("us-east-1"),
|
// s3Region: z.string().default("us-east-1"),
|
||||||
// localFilePath: z.string().optional()
|
// localFilePath: z.string().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
// this to maintain backwards compatibility with the old config file
|
// this to maintain backwards compatibility with the old config file
|
||||||
const identityProviderMode = data.app?.identity_provider_mode;
|
const identityProviderMode = data.app?.identity_provider_mode;
|
||||||
|
|||||||
@@ -277,8 +277,15 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let siteResourcesWithFullDomain: {
|
||||||
|
siteResourceId: number;
|
||||||
|
fullDomain: string | null;
|
||||||
|
mode: "http" | "host" | "cidr";
|
||||||
|
}[] = [];
|
||||||
|
if (build == "enterprise") {
|
||||||
|
// we dont want to do this on the cloud
|
||||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||||
const siteResourcesWithFullDomain = await db
|
siteResourcesWithFullDomain = await db
|
||||||
.select({
|
.select({
|
||||||
siteResourceId: siteResources.siteResourceId,
|
siteResourceId: siteResources.siteResourceId,
|
||||||
fullDomain: siteResources.fullDomain,
|
fullDomain: siteResources.fullDomain,
|
||||||
@@ -296,18 +303,11 @@ export async function getTraefikConfig(
|
|||||||
isNotNull(siteResources.fullDomain),
|
isNotNull(siteResources.fullDomain),
|
||||||
eq(siteResources.mode, "http"),
|
eq(siteResources.mode, "http"),
|
||||||
eq(siteResources.ssl, true),
|
eq(siteResources.ssl, true),
|
||||||
or(
|
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
and(
|
|
||||||
isNull(sites.exitNodeId),
|
|
||||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
|
||||||
eq(sites.type, "local"),
|
|
||||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
inArray(sites.type, siteTypes)
|
inArray(sites.type, siteTypes)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
fireHealthCheckHealthyAlert,
|
fireHealthCheckHealthyAlert,
|
||||||
fireHealthCheckUnhealthyAlert
|
fireHealthCheckUnhealthyAlert
|
||||||
} from "#private/lib/alerts/events/healthCheckEvents";
|
} from "@server/lib/alerts";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty(),
|
orgId: z.string().nonempty(),
|
||||||
@@ -73,10 +73,7 @@ export async function triggerHealthCheckAlert(
|
|||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||||
targetHealthCheck.targetHealthCheckId,
|
|
||||||
healthCheckId
|
|
||||||
),
|
|
||||||
eq(targetHealthCheck.orgId, orgId)
|
eq(targetHealthCheck.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
fireResourceHealthyAlert,
|
fireResourceHealthyAlert,
|
||||||
fireResourceUnhealthyAlert,
|
fireResourceUnhealthyAlert,
|
||||||
fireResourceDegradedAlert
|
fireResourceDegradedAlert
|
||||||
} from "#private/lib/alerts/events/resourceEvents";
|
} from "@server/lib/alerts";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty(),
|
orgId: z.string().nonempty(),
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import {
|
import { fireSiteOnlineAlert, fireSiteOfflineAlert } from "@server/lib/alerts";
|
||||||
fireSiteOnlineAlert,
|
|
||||||
fireSiteOfflineAlert
|
|
||||||
} from "#private/lib/alerts/events/siteEvents";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty(),
|
orgId: z.string().nonempty(),
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
siteProvisioningKeyOrg,
|
siteProvisioningKeyOrg,
|
||||||
siteProvisioningKeys,
|
siteProvisioningKeys,
|
||||||
|
alertRules,
|
||||||
|
targetHealthCheck
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the maximum allowed retention days for a given tier
|
* Get the maximum allowed retention days for a given tier
|
||||||
@@ -318,6 +320,14 @@ async function disableFeature(
|
|||||||
await disableSiteProvisioningKeys(orgId);
|
await disableSiteProvisioningKeys(orgId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case TierFeature.AlertingRules:
|
||||||
|
await disableAlertingRules(orgId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TierFeature.StandaloneHealthChecks:
|
||||||
|
await disableStandaloneHealthChecks(orgId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Unknown feature ${feature} for org ${orgId}, skipping`
|
`Unknown feature ${feature} for org ${orgId}, skipping`
|
||||||
@@ -360,8 +370,7 @@ async function disableFullRbac(orgId: string): Promise<void> {
|
|||||||
async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
|
async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
siteProvisioningKeyId:
|
siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId
|
||||||
siteProvisioningKeyOrg.siteProvisioningKeyId
|
|
||||||
})
|
})
|
||||||
.from(siteProvisioningKeyOrg)
|
.from(siteProvisioningKeyOrg)
|
||||||
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
|
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
|
||||||
@@ -525,6 +534,29 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
|
|||||||
logger.info(`Disabled password expiration policies for org ${orgId}`);
|
logger.info(`Disabled password expiration policies for org ${orgId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function disableAlertingRules(orgId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(alertRules)
|
||||||
|
.set({ enabled: false })
|
||||||
|
.where(eq(alertRules.orgId, orgId));
|
||||||
|
|
||||||
|
logger.info(`Disabled all alert rules for org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(targetHealthCheck)
|
||||||
|
.set({ hcEnabled: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(targetHealthCheck.orgId, orgId),
|
||||||
|
isNull(targetHealthCheck.targetId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Disabled standalone health checks for org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function disableAutoProvisioning(orgId: string): Promise<void> {
|
async function disableAutoProvisioning(orgId: string): Promise<void> {
|
||||||
// Get all IDP IDs for this org through the idpOrg join table
|
// Get all IDP IDs for this org through the idpOrg join table
|
||||||
const orgIdps = await db
|
const orgIdps = await db
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { customers, db, subscriptions } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
|
|
||||||
export async function handleCustomerCreated(
|
export async function handleCustomerCreated(
|
||||||
customer: Stripe.Customer
|
customer: Stripe.Customer
|
||||||
@@ -62,6 +63,13 @@ export async function handleCustomerCreated(
|
|||||||
expiresAt: trialExpiresAt,
|
expiresAt: trialExpiresAt,
|
||||||
trial: true
|
trial: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// update to the business limits for the trial
|
||||||
|
await handleSubscriptionLifesycle(
|
||||||
|
customer.metadata.orgId,
|
||||||
|
"active",
|
||||||
|
"tier3"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Customer with ID ${customer.id} created successfully.`);
|
logger.info(`Customer with ID ${customer.id} created successfully.`);
|
||||||
|
|||||||
@@ -174,6 +174,19 @@ export async function handleSubscriptionCreated(
|
|||||||
// TODO: update user in Sendy
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete the trial subscrition if we have one
|
||||||
|
await db
|
||||||
|
.delete(subscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
subscriptions.customerId,
|
||||||
|
subscription.customer as string
|
||||||
|
),
|
||||||
|
eq(subscriptions.trial, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
|
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function getLimitSetForSubscriptionType(
|
|||||||
export async function handleSubscriptionLifesycle(
|
export async function handleSubscriptionLifesycle(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
status: string,
|
status: string,
|
||||||
subType: SubscriptionType | null
|
subType: SubscriptionType | null = null
|
||||||
) {
|
) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export async function createCertificate(
|
|||||||
|
|
||||||
let domainToWrite = domain;
|
let domainToWrite = domain;
|
||||||
if (
|
if (
|
||||||
domainRecord.type == "wildcard" &&
|
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
|
||||||
domainRecord.preferWildcardCert &&
|
domainRecord.preferWildcardCert &&
|
||||||
!domain.startsWith("*.")
|
!domain.startsWith("*.")
|
||||||
) {
|
) {
|
||||||
@@ -89,6 +89,15 @@ export async function createCertificate(
|
|||||||
domainToWrite = parts.slice(1).join(".");
|
domainToWrite = parts.slice(1).join(".");
|
||||||
domainToWrite = `*.${domainToWrite}`;
|
domainToWrite = `*.${domainToWrite}`;
|
||||||
}
|
}
|
||||||
|
} else if (domainRecord.type == "ns") {
|
||||||
|
if (domain == domainRecord.baseDomain) {
|
||||||
|
domainToWrite = domainRecord.baseDomain;
|
||||||
|
} else {
|
||||||
|
const parts = domain.split(".");
|
||||||
|
if (parts.length > 2) {
|
||||||
|
domainToWrite = parts.slice(1).join(".");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cert found, create a new one in pending state
|
// No cert found, create a new one in pending state
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/certificate/:domainId/:domain",
|
"/org/:orgId/certificate/:domainId/:domain",
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyCertificateAccess,
|
verifyCertificateAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||||
import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts";
|
import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||||
import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckHealthyAlert } from "#private/lib/alerts";
|
import {
|
||||||
|
fireHealthCheckUnhealthyAlert,
|
||||||
|
fireHealthCheckUnknownAlert,
|
||||||
|
fireHealthCheckHealthyAlert
|
||||||
|
} from "@server/lib/alerts";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -234,7 +238,10 @@ export async function updateHealthCheck(
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") {
|
if (
|
||||||
|
updated.hcHealth === "unhealthy" &&
|
||||||
|
existingHealthCheck.hcHealth !== "unhealthy"
|
||||||
|
) {
|
||||||
await fireHealthCheckUnhealthyAlert(
|
await fireHealthCheckUnhealthyAlert(
|
||||||
updated.orgId,
|
updated.orgId,
|
||||||
updated.targetHealthCheckId,
|
updated.targetHealthCheckId,
|
||||||
@@ -243,7 +250,10 @@ export async function updateHealthCheck(
|
|||||||
undefined,
|
undefined,
|
||||||
false // dont send the alert because we just want to create the alert, not notify users yet
|
false // dont send the alert because we just want to create the alert, not notify users yet
|
||||||
);
|
);
|
||||||
} else if (updated.hcHealth === "unknown" && existingHealthCheck.hcHealth !== "unknown") {
|
} else if (
|
||||||
|
updated.hcHealth === "unknown" &&
|
||||||
|
existingHealthCheck.hcHealth !== "unknown"
|
||||||
|
) {
|
||||||
// if the health is unknown, we want to fire an alert to notify users to enable health checks
|
// if the health is unknown, we want to fire an alert to notify users to enable health checks
|
||||||
await fireHealthCheckUnknownAlert(
|
await fireHealthCheckUnknownAlert(
|
||||||
updated.orgId,
|
updated.orgId,
|
||||||
@@ -253,7 +263,10 @@ export async function updateHealthCheck(
|
|||||||
undefined,
|
undefined,
|
||||||
false // dont send the alert because we just want to create the alert, not notify users yet
|
false // dont send the alert because we just want to create the alert, not notify users yet
|
||||||
);
|
);
|
||||||
} else if (updated.hcHealth === "healthy" && existingHealthCheck.hcHealth !== "healthy") {
|
} else if (
|
||||||
|
updated.hcHealth === "healthy" &&
|
||||||
|
existingHealthCheck.hcHealth !== "healthy"
|
||||||
|
) {
|
||||||
await fireHealthCheckHealthyAlert(
|
await fireHealthCheckHealthyAlert(
|
||||||
updated.orgId,
|
updated.orgId,
|
||||||
updated.targetHealthCheckId,
|
updated.targetHealthCheckId,
|
||||||
@@ -264,7 +277,6 @@ export async function updateHealthCheck(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Push updated health check to newt if the site is a newt site
|
// Push updated health check to newt if the site is a newt site
|
||||||
const [newt] = await db
|
const [newt] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -67,23 +67,19 @@ if (build == "saas") {
|
|||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
certificates.syncCertToNewts
|
certificates.syncCertToNewts
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/org/:orgId/send-usage-notification`,
|
`/org/:orgId/send-usage-notification`,
|
||||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||||
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
|
|
||||||
logActionAudit(ActionsEnum.sendUsageNotification),
|
|
||||||
org.sendUsageNotification
|
org.sendUsageNotification
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/org/:orgId/send-trial-notification`,
|
`/org/:orgId/send-trial-notification`,
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
|
|
||||||
logActionAudit(ActionsEnum.sendTrialNotification),
|
|
||||||
org.sendTrialNotification
|
org.sendTrialNotification
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/idp/:idpId",
|
"/idp/:idpId",
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
|
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { handleSubscriptionLifesycle } from "../billing/subscriptionLifecycle";
|
||||||
|
|
||||||
const sendTrialNotificationParamsSchema = z.object({
|
const sendTrialNotificationParamsSchema = z.object({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendTrialNotificationBodySchema = z.object({
|
const sendTrialNotificationBodySchema = z.object({
|
||||||
notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]),
|
notificationType: z.enum([
|
||||||
|
"trial_ending_5d",
|
||||||
|
"trial_ending_24h",
|
||||||
|
"trial_ended"
|
||||||
|
]),
|
||||||
orgName: z.string(),
|
orgName: z.string(),
|
||||||
trialEndsAt: z.number(),
|
trialEndsAt: z.number(),
|
||||||
billingLink: z.string().optional()
|
billingLink: z.string().optional()
|
||||||
@@ -69,9 +74,7 @@ async function getOrgAdmins(orgId: string) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const byUserId = new Map(
|
const byUserId = new Map(admins.map((a) => [a.userId, a]));
|
||||||
admins.map((a) => [a.userId, a])
|
|
||||||
);
|
|
||||||
const orgAdmins = Array.from(byUserId.values()).filter(
|
const orgAdmins = Array.from(byUserId.values()).filter(
|
||||||
(admin) => admin.email && admin.email.length > 0
|
(admin) => admin.email && admin.email.length > 0
|
||||||
);
|
);
|
||||||
@@ -108,8 +111,12 @@ export async function sendTrialNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } =
|
const {
|
||||||
parsedBody.data;
|
notificationType,
|
||||||
|
orgName,
|
||||||
|
trialEndsAt,
|
||||||
|
billingLink: bodyBillingLink
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Verify organization exists
|
// Verify organization exists
|
||||||
const org = await db
|
const org = await db
|
||||||
@@ -146,13 +153,17 @@ export async function sendTrialNotification(
|
|||||||
bodyBillingLink ??
|
bodyBillingLink ??
|
||||||
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
|
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
|
||||||
|
|
||||||
const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString(
|
const trialEndsAtFormatted = new Date(
|
||||||
"en-US",
|
trialEndsAt * 1000
|
||||||
{ year: "numeric", month: "long", day: "numeric" }
|
).toLocaleDateString("en-US", {
|
||||||
);
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
});
|
||||||
|
|
||||||
let daysRemaining: number | null;
|
let daysRemaining: number | null;
|
||||||
let subject: string;
|
let subject: string;
|
||||||
|
let resetLimits = false;
|
||||||
|
|
||||||
if (notificationType === "trial_ending_5d") {
|
if (notificationType === "trial_ending_5d") {
|
||||||
daysRemaining = 5;
|
daysRemaining = 5;
|
||||||
@@ -163,6 +174,7 @@ export async function sendTrialNotification(
|
|||||||
} else {
|
} else {
|
||||||
daysRemaining = null;
|
daysRemaining = null;
|
||||||
subject = "Your trial has ended";
|
subject = "Your trial has ended";
|
||||||
|
resetLimits = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let emailsSent = 0;
|
let emailsSent = 0;
|
||||||
@@ -201,6 +213,14 @@ export async function sendTrialNotification(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resetLimits) {
|
||||||
|
// this will only fire if they have not upgraded yet because when upgrading we delete the trial
|
||||||
|
await handleSubscriptionLifesycle(orgId, "cancled");
|
||||||
|
logger.debug(
|
||||||
|
`Trial ended for org ${orgId}, limits reset to free tier`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<SendTrialNotificationResponse>(res, {
|
return response<SendTrialNotificationResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -22,6 +22,91 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||||
|
import cache from "#private/lib/cache";
|
||||||
|
import semver from "semver";
|
||||||
|
|
||||||
|
let stalePangolinNodeVersion: string | null = null;
|
||||||
|
|
||||||
|
async function getLatestPangolinNodeVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const cachedVersion = await cache.get<string>(
|
||||||
|
"cache:latestPangolinNodeVersion"
|
||||||
|
);
|
||||||
|
if (cachedVersion) {
|
||||||
|
return cachedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
"https://api.github.com/repos/fosrl/pangolin-node/tags",
|
||||||
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch latest pangolin-node version from GitHub: ${res.status} ${res.statusText}`
|
||||||
|
);
|
||||||
|
return stalePangolinNodeVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = await res.json();
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
logger.warn("No tags found for pangolin-node repository");
|
||||||
|
return stalePangolinNodeVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = tags.filter((tag: any) => !tag.name.includes("rc"));
|
||||||
|
tags.sort((a: any, b: any) => {
|
||||||
|
const va = semver.coerce(a.name);
|
||||||
|
const vb = semver.coerce(b.name);
|
||||||
|
if (!va && !vb) return 0;
|
||||||
|
if (!va) return 1;
|
||||||
|
if (!vb) return -1;
|
||||||
|
return semver.rcompare(va, vb);
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
tags = tags.filter((tag: any) => {
|
||||||
|
const normalised = semver.coerce(tag.name)?.version;
|
||||||
|
if (!normalised || seen.has(normalised)) return false;
|
||||||
|
seen.add(normalised);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tags.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
"No valid semver tags found for pangolin-node repository"
|
||||||
|
);
|
||||||
|
return stalePangolinNodeVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = tags[0].name;
|
||||||
|
stalePangolinNodeVersion = latestVersion;
|
||||||
|
await cache.set("cache:latestPangolinNodeVersion", latestVersion, 3600);
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
logger.warn(
|
||||||
|
"Request to fetch latest pangolin-node version timed out (1.5s)"
|
||||||
|
);
|
||||||
|
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
|
logger.warn(
|
||||||
|
"Connection timeout while fetching latest pangolin-node version"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Error fetching latest pangolin-node version:",
|
||||||
|
error.message || error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stalePangolinNodeVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listRemoteExitNodesParamsSchema = z.strictObject({
|
const listRemoteExitNodesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -118,9 +203,41 @@ export async function listRemoteExitNodes(
|
|||||||
const totalCountResult = await countQuery;
|
const totalCountResult = await countQuery;
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
const latestPangolinNodeVersionPromise = getLatestPangolinNodeVersion();
|
||||||
|
|
||||||
|
const nodesWithUpdates = remoteExitNodesList.map((node) => ({
|
||||||
|
...node,
|
||||||
|
updateAvailable: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestPangolinNodeVersion =
|
||||||
|
await latestPangolinNodeVersionPromise;
|
||||||
|
|
||||||
|
if (latestPangolinNodeVersion) {
|
||||||
|
nodesWithUpdates.forEach((node) => {
|
||||||
|
if (node.version) {
|
||||||
|
try {
|
||||||
|
node.updateAvailable = semver.lt(
|
||||||
|
node.version,
|
||||||
|
latestPangolinNodeVersion
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
node.updateAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
"Failed to check for pangolin-node updates, continuing without update info:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<ListRemoteExitNodesResponse>(res, {
|
return response<ListRemoteExitNodesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
remoteExitNodes: remoteExitNodesList,
|
remoteExitNodes: nodesWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { clients, db } from "@server/db";
|
import { clients, db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -122,8 +122,12 @@ export async function addUserRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let newUserRole: { userId: string; orgId: string; roleId: number } | null =
|
let newUserRole: {
|
||||||
null;
|
userId: string;
|
||||||
|
orgId: string;
|
||||||
|
roleId: number;
|
||||||
|
} | null = null;
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const inserted = await trx
|
const inserted = await trx
|
||||||
.insert(userOrgRoles)
|
.insert(userOrgRoles)
|
||||||
@@ -149,11 +153,19 @@ export async function addUserRole(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
|
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
|
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -129,6 +129,7 @@ export async function removeUserRole(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(userOrgRoles)
|
.delete(userOrgRoles)
|
||||||
@@ -150,11 +151,19 @@ export async function removeUserRole(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { userId, orgId: role.orgId, roleId },
|
data: { userId, orgId: role.orgId, roleId },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { clients, db } from "@server/db";
|
import { clients, db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -115,6 +115,7 @@ export async function setUserOrgRoles(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(userOrgRoles)
|
.delete(userOrgRoles)
|
||||||
@@ -142,11 +143,19 @@ export async function setUserOrgRoles(
|
|||||||
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
|
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { userId, orgId, roleIds: uniqueRoleIds },
|
data: { userId, orgId, roleIds: uniqueRoleIds },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Olm,
|
Olm,
|
||||||
olms,
|
olms,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
remoteExitNodes,
|
remoteExitNodes
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
@@ -194,8 +194,6 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
|||||||
// Config version tracking map (local to this node, resets on server restart)
|
// Config version tracking map (local to this node, resets on server restart)
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
const clientConfigVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
@@ -406,6 +404,9 @@ const removeClient = async (
|
|||||||
const updatedClients = existingClients.filter((client) => client !== ws);
|
const updatedClients = existingClients.filter((client) => client !== ws);
|
||||||
if (updatedClients.length === 0) {
|
if (updatedClients.length === 0) {
|
||||||
connectedClients.delete(mapKey);
|
connectedClients.delete(mapKey);
|
||||||
|
// Remove clientId from clientConfigVersions on disconnect — prevents
|
||||||
|
// unbounded memory growth from stale entries.
|
||||||
|
clientConfigVersions.delete(clientId);
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
@@ -1097,6 +1098,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Eagerly remove client — close event may not fire if socket is already
|
||||||
|
// CLOSING, leaving zombie entries.
|
||||||
|
connectedClients.delete(mapKey);
|
||||||
|
clientConfigVersions.delete(clientId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
|
|||||||
headers?: Array<{ key: string; value: string }>;
|
headers?: Array<{ key: string; value: string }>;
|
||||||
/** HTTP method (default POST) */
|
/** HTTP method (default POST) */
|
||||||
method?: string;
|
method?: string;
|
||||||
|
/** Whether to use a custom body template */
|
||||||
|
useBodyTemplate?: boolean;
|
||||||
|
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
|
||||||
|
bodyTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, orgs, userOrgs, users } from "@server/db";
|
import { db, orgs, userOrgs, users, primaryDb } from "@server/db";
|
||||||
import { eq, and, inArray, not } from "drizzle-orm";
|
import { eq, and, inArray, not } 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";
|
||||||
@@ -104,8 +104,9 @@ export async function deleteMyAccount(
|
|||||||
(r) => r.isBillingOrg && r.isOwner
|
(r) => r.isBillingOrg && r.isOwner
|
||||||
)?.orgId;
|
)?.orgId;
|
||||||
if (primaryOrgId) {
|
if (primaryOrgId) {
|
||||||
const { tier, active } = await getOrgTierData(primaryOrgId);
|
const { tier, active, isTrial } =
|
||||||
if (active && tier) {
|
await getOrgTierData(primaryOrgId);
|
||||||
|
if (active && tier && !isTrial) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -217,13 +218,18 @@ export async function deleteMyAccount(
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx.delete(users).where(eq(users.userId, userId));
|
await trx.delete(users).where(eq(users.userId, userId));
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
|
||||||
// loop through the other orgs and decrement the count
|
// loop through the other orgs and decrement the count
|
||||||
for (const userOrg of otherOrgsTheUserWasIn) {
|
for (const userOrg of otherOrgsTheUserWasIn) {
|
||||||
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
|
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invalidateSession(session.sessionId);
|
await invalidateSession(session.sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1003,7 +1003,11 @@ async function checkRules(
|
|||||||
isIpInCidr(clientIp, rule.value)
|
isIpInCidr(clientIp, rule.value)
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
} else if (
|
||||||
|
clientIp &&
|
||||||
|
rule.match == "IP" &&
|
||||||
|
clientIp == rule.value
|
||||||
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (
|
||||||
path &&
|
path &&
|
||||||
@@ -1013,16 +1017,35 @@ async function checkRules(
|
|||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (
|
||||||
clientIp &&
|
clientIp &&
|
||||||
rule.match == "COUNTRY" &&
|
rule.match == "COUNTRY"
|
||||||
(await isIpInGeoIP(ipCC, rule.value))
|
|
||||||
) {
|
) {
|
||||||
|
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||||
|
if (
|
||||||
|
rule.value.toUpperCase() === "ALL" &&
|
||||||
|
isLocalOrCarrierGradeNatIp(clientIp)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isIpInGeoIP(ipCC, rule.value)) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
clientIp &&
|
clientIp &&
|
||||||
rule.match == "ASN" &&
|
rule.match == "ASN"
|
||||||
(await isIpInAsn(ipAsn, rule.value))
|
|
||||||
) {
|
) {
|
||||||
|
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
||||||
|
if (
|
||||||
|
(rule.value.toUpperCase() === "ALL" ||
|
||||||
|
rule.value.toUpperCase() === "AS0") &&
|
||||||
|
isLocalOrCarrierGradeNatIp(clientIp)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isIpInAsn(ipAsn, rule.value)) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
clientIp &&
|
clientIp &&
|
||||||
rule.match == "REGION" &&
|
rule.match == "REGION" &&
|
||||||
@@ -1184,6 +1207,26 @@ async function isIpInGeoIP(
|
|||||||
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLocalOrCarrierGradeNatIp(ip: string): boolean {
|
||||||
|
const localAndCgnatCidrs = [
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"100.64.0.0/10",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"::1/128",
|
||||||
|
"fc00::/7",
|
||||||
|
"fe80::/10"
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return localAndCgnatCidrs.some((cidr) => isIpInCidr(ip, cidr));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function isIpInAsn(
|
async function isIpInAsn(
|
||||||
ipAsn: number | undefined,
|
ipAsn: number | undefined,
|
||||||
checkAsn: string
|
checkAsn: string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb } from "@server/db";
|
||||||
import {
|
import {
|
||||||
roles,
|
roles,
|
||||||
Client,
|
Client,
|
||||||
@@ -92,7 +92,10 @@ export async function createClient(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
if (
|
||||||
|
req.user &&
|
||||||
|
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
@@ -198,7 +201,10 @@ export async function createClient(
|
|||||||
|
|
||||||
if (!randomExitNode) {
|
if (!randomExitNode) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,10 +262,18 @@ export async function createClient(
|
|||||||
clientId: newClient.clientId,
|
clientId: newClient.clientId,
|
||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(newClient, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newClient) {
|
||||||
|
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after creating client: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<CreateClientResponse>(res, {
|
return response<CreateClientResponse>(res, {
|
||||||
data: newClient,
|
data: newClient,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb } from "@server/db";
|
||||||
import {
|
import {
|
||||||
roles,
|
roles,
|
||||||
Client,
|
Client,
|
||||||
@@ -237,10 +237,18 @@ export async function createUserClient(
|
|||||||
userId,
|
userId,
|
||||||
clientId: newClient.clientId
|
clientId: newClient.clientId
|
||||||
});
|
});
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(newClient, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newClient) {
|
||||||
|
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after creating user client: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<CreateClientAndOlmResponse>(res, {
|
return response<CreateClientAndOlmResponse>(res, {
|
||||||
data: newClient,
|
data: newClient,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms, primaryDb, Client, Olm } from "@server/db";
|
||||||
import { clients, clientSitesAssociationsCache } from "@server/db";
|
import { clients, clientSitesAssociationsCache } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -71,14 +71,17 @@ export async function deleteClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deletedClient: Client | undefined;
|
||||||
|
let olm: Olm | undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Then delete the client itself
|
// Then delete the client itself
|
||||||
const [deletedClient] = await trx
|
[deletedClient] = await trx
|
||||||
.delete(clients)
|
.delete(clients)
|
||||||
.where(eq(clients.clientId, clientId))
|
.where(eq(clients.clientId, clientId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const [olm] = await trx
|
[olm] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(olms)
|
.from(olms)
|
||||||
.where(eq(olms.clientId, clientId))
|
.where(eq(olms.clientId, clientId))
|
||||||
@@ -88,14 +91,29 @@ export async function deleteClient(
|
|||||||
if (!client.userId && client.olmId) {
|
if (!client.userId && client.olmId) {
|
||||||
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
|
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
|
||||||
|
|
||||||
if (olm) {
|
|
||||||
await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deletedClient) {
|
||||||
|
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (olm) {
|
||||||
|
sendTerminateClient(
|
||||||
|
deletedClient.clientId,
|
||||||
|
OlmErrorCodes.TERMINATED_DELETED,
|
||||||
|
olm.olmId
|
||||||
|
).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting client ${clientId}: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
|
|||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import {
|
import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg";
|
||||||
assignUserToOrg,
|
|
||||||
removeUserFromOrg
|
|
||||||
} from "@server/lib/userOrg";
|
|
||||||
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
@@ -336,31 +333,15 @@ export async function validateOidcCallback(
|
|||||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
allOrgs = idpOrgs.map((o) => o.orgs);
|
||||||
|
|
||||||
// TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1
|
for (const org of allOrgs) {
|
||||||
if (allOrgs.length > 1) {
|
|
||||||
// for some reason there is more than one org
|
|
||||||
logger.error(
|
|
||||||
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
|
|
||||||
);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Multiple organizations linked to this IdP. Please contact support."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribed = await isSubscribed(
|
const subscribed = await isSubscribed(
|
||||||
allOrgs[0].orgId,
|
org.orgId,
|
||||||
tierMatrix.autoProvisioning
|
tierMatrix.autoProvisioning
|
||||||
);
|
);
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
return next(
|
// filter out the org
|
||||||
createHttpError(
|
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
||||||
HttpCode.FORBIDDEN,
|
}
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
allOrgs = await db.select().from(orgs);
|
allOrgs = await db.select().from(orgs);
|
||||||
@@ -405,16 +386,14 @@ export async function validateOidcCallback(
|
|||||||
idpOrgRes?.roleMapping || defaultRoleMapping;
|
idpOrgRes?.roleMapping || defaultRoleMapping;
|
||||||
if (roleMapping) {
|
if (roleMapping) {
|
||||||
logger.debug("Role Mapping", { roleMapping });
|
logger.debug("Role Mapping", { roleMapping });
|
||||||
const roleMappingJmes = unwrapRoleMapping(
|
const roleMappingJmes =
|
||||||
roleMapping
|
unwrapRoleMapping(roleMapping).evaluationExpression;
|
||||||
).evaluationExpression;
|
|
||||||
const roleMappingResult = jmespath.search(
|
const roleMappingResult = jmespath.search(
|
||||||
claims,
|
claims,
|
||||||
roleMappingJmes
|
roleMappingJmes
|
||||||
);
|
);
|
||||||
const roleNames = normalizeRoleMappingResult(
|
const roleNames =
|
||||||
roleMappingResult
|
normalizeRoleMappingResult(roleMappingResult);
|
||||||
);
|
|
||||||
|
|
||||||
const supportsMultiRole = await isLicensedOrSubscribed(
|
const supportsMultiRole = await isLicensedOrSubscribed(
|
||||||
org.orgId,
|
org.orgId,
|
||||||
@@ -504,7 +483,14 @@ export async function validateOidcCallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(existingUser.userId);
|
calculateUserClientsForOrgs(existingUser.userId).catch(
|
||||||
|
(err) => {
|
||||||
|
logger.error(
|
||||||
|
"Error calculating user clients after removing all orgs for user with no valid IdP mappings",
|
||||||
|
{ error: err }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -526,10 +512,9 @@ export async function validateOidcCallback(
|
|||||||
|
|
||||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||||
|
|
||||||
|
let userId = existingUser?.userId;
|
||||||
// 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) => {
|
||||||
let userId = existingUser?.userId;
|
|
||||||
|
|
||||||
// create user if not exists
|
// create user if not exists
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
userId = generateId(15);
|
userId = generateId(15);
|
||||||
@@ -637,7 +622,7 @@ export async function validateOidcCallback(
|
|||||||
{
|
{
|
||||||
orgId: org.orgId,
|
orgId: org.orgId,
|
||||||
userId: userId!,
|
userId: userId!,
|
||||||
autoProvisioned: true,
|
autoProvisioned: true
|
||||||
},
|
},
|
||||||
org.roleIds,
|
org.roleIds,
|
||||||
trx
|
trx
|
||||||
@@ -659,8 +644,15 @@ export async function validateOidcCallback(
|
|||||||
userCount: userCount.length
|
userCount: userCount.length
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.transaction(async (trx) => {
|
||||||
await calculateUserClientsForOrgs(userId!, trx);
|
await calculateUserClientsForOrgs(userId!, trx);
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
"Error calculating user clients after syncing orgs and roles for OIDC user",
|
||||||
|
{ error: err }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const orgCount of orgUserCounts) {
|
for (const orgCount of orgUserCounts) {
|
||||||
@@ -767,9 +759,7 @@ function hydrateOrgMapping(
|
|||||||
return orgMapping.split("{{orgId}}").join(orgId);
|
return orgMapping.split("{{orgId}}").join(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRoleMappingResult(
|
function normalizeRoleMappingResult(result: unknown): string[] {
|
||||||
result: unknown
|
|
||||||
): string[] {
|
|
||||||
if (typeof result === "string") {
|
if (typeof result === "string") {
|
||||||
const role = result.trim();
|
const role = result.trim();
|
||||||
return role ? [role] : [];
|
return role ? [role] : [];
|
||||||
@@ -779,7 +769,9 @@ function normalizeRoleMappingResult(
|
|||||||
return [
|
return [
|
||||||
...new Set(
|
...new Set(
|
||||||
result
|
result
|
||||||
.filter((value): value is string => typeof value === "string")
|
.filter(
|
||||||
|
(value): value is string => typeof value === "string"
|
||||||
|
)
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import {
|
import { db, Newt, sites } from "@server/db";
|
||||||
db,
|
|
||||||
Newt,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOfflineAlert } from "#dynamic/lib/alerts";
|
import { fireSiteOfflineAlert } from "@server/lib/alerts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles disconnecting messages from sites to show disconnected in the ui
|
* Handles disconnecting messages from sites to show disconnected in the ui
|
||||||
@@ -38,7 +34,13 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
.where(eq(sites.siteId, newt.siteId!))
|
.where(eq(sites.siteId, newt.siteId!))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx);
|
await fireSiteOfflineAlert(
|
||||||
|
site.orgId,
|
||||||
|
site.siteId,
|
||||||
|
site.name,
|
||||||
|
undefined,
|
||||||
|
trx
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling disconnecting message", { error });
|
logger.error("Error handling disconnecting message", { error });
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import {
|
import { db, newts, sites } from "@server/db";
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { hasActiveConnections } from "#dynamic/routers/ws";
|
import { hasActiveConnections } from "#dynamic/routers/ws";
|
||||||
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
|
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||||
|
|
||||||
// Track if the offline checker interval is running
|
// Track if the offline checker interval is running
|
||||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db } from "@server/db";
|
|||||||
import { sites, clients, olms } from "@server/db";
|
import { sites, clients, olms } from "@server/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping Accumulator
|
* Ping Accumulator
|
||||||
@@ -127,7 +127,11 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
eq(sites.online, false)
|
eq(sites.online, false)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning({ siteId: sites.siteId, orgId: sites.orgId, name: sites.name });
|
.returning({
|
||||||
|
siteId: sites.siteId,
|
||||||
|
orgId: sites.orgId,
|
||||||
|
name: sites.name
|
||||||
|
});
|
||||||
|
|
||||||
// Update lastPing for sites that were already online.
|
// Update lastPing for sites that were already online.
|
||||||
// After the update above, the newly-online sites now have
|
// After the update above, the newly-online sites now have
|
||||||
@@ -148,7 +152,13 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
for (const site of newlyOnlineSites) {
|
for (const site of newlyOnlineSites) {
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx);
|
await fireSiteOnlineAlert(
|
||||||
|
site.orgId,
|
||||||
|
site.siteId,
|
||||||
|
site.name,
|
||||||
|
undefined,
|
||||||
|
trx
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms, primaryDb } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -81,8 +81,7 @@ export async function createUserOlm(
|
|||||||
|
|
||||||
const secretHash = await hashPassword(secret);
|
const secretHash = await hashPassword(secret);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.insert(olms).values({
|
||||||
await trx.insert(olms).values({
|
|
||||||
olmId: olmId,
|
olmId: olmId,
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
@@ -90,7 +89,11 @@ export async function createUserOlm(
|
|||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"Error calculating user clients after creating olm:",
|
||||||
|
e
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response<CreateOlmResponse>(res, {
|
return response<CreateOlmResponse>(res, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { Client, db } from "@server/db";
|
import { Client, db, Olm, primaryDb } from "@server/db";
|
||||||
import { olms, clients, clientSitesAssociationsCache } from "@server/db";
|
import { olms, clients, clientSitesAssociationsCache } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -49,6 +49,7 @@ export async function deleteUserOlm(
|
|||||||
|
|
||||||
const { olmId } = parsedParams.data;
|
const { olmId } = parsedParams.data;
|
||||||
|
|
||||||
|
let deletedClient: Client | undefined;
|
||||||
// Delete associated clients and the OLM in a transaction
|
// Delete associated clients and the OLM in a transaction
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Find all clients associated with this OLM
|
// Find all clients associated with this OLM
|
||||||
@@ -57,7 +58,6 @@ export async function deleteUserOlm(
|
|||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.olmId, olmId));
|
.where(eq(clients.olmId, olmId));
|
||||||
|
|
||||||
let deletedClient: Client | null = null;
|
|
||||||
// Delete all associated clients
|
// Delete all associated clients
|
||||||
if (associatedClients.length > 0) {
|
if (associatedClients.length > 0) {
|
||||||
[deletedClient] = await trx
|
[deletedClient] = await trx
|
||||||
@@ -67,22 +67,27 @@ export async function deleteUserOlm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finally, delete the OLM itself
|
// Finally, delete the OLM itself
|
||||||
const [olm] = await trx
|
await trx.delete(olms).where(eq(olms.olmId, olmId)).returning();
|
||||||
.delete(olms)
|
});
|
||||||
.where(eq(olms.olmId, olmId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (deletedClient) {
|
if (deletedClient) {
|
||||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||||
if (olm) {
|
(e) => {
|
||||||
await sendTerminateClient(
|
logger.error(
|
||||||
|
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
sendTerminateClient(
|
||||||
deletedClient.clientId,
|
deletedClient.clientId,
|
||||||
OlmErrorCodes.TERMINATED_DELETED,
|
OlmErrorCodes.TERMINATED_DELETED,
|
||||||
olm.olmId
|
olmId
|
||||||
); // the olmId needs to be provided because it cant look it up after deletion
|
).catch((e) => {
|
||||||
}
|
logger.error(
|
||||||
}
|
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting OLM ${olmId}: ${e}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import { canCompress } from "@server/lib/clientVersionChecks";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
logger.info("Handling register olm message!");
|
logger.info("[handleOlmRegisterMessage] Handling register olm message");
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
if (!olm) {
|
if (!olm) {
|
||||||
logger.warn("Olm not found");
|
logger.warn("[handleOlmRegisterMessage] Olm not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,16 +46,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
} = message.data;
|
} = message.data;
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm client ID not found");
|
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Handling fingerprint insertion for olm register...", {
|
logger.debug(
|
||||||
|
"[handleOlmRegisterMessage] Handling fingerprint insertion for olm register...",
|
||||||
|
{
|
||||||
olmId: olm.olmId,
|
olmId: olm.olmId,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
postures
|
postures
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
||||||
|
|
||||||
@@ -85,14 +88,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
logger.warn("Client ID not found");
|
logger.warn("[handleOlmRegisterMessage] Client not found", {
|
||||||
|
clientId: olm.clientId
|
||||||
|
});
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.blocked) {
|
if (client.blocked) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client ${client.clientId} is blocked. Ignoring register.`
|
`[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -100,7 +106,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
if (client.approvalState == "pending") {
|
if (client.approvalState == "pending") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client ${client.clientId} approval is pending. Ignoring register.`
|
`[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -128,14 +135,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
logger.warn("Org not found");
|
logger.warn("[handleOlmRegisterMessage] Org not found", {
|
||||||
|
orgId: client.orgId
|
||||||
|
});
|
||||||
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
if (!olm.userId) {
|
if (!olm.userId) {
|
||||||
logger.warn("Olm has no user ID");
|
logger.warn("[handleOlmRegisterMessage] Olm has no user ID", {
|
||||||
|
orgId: client.orgId
|
||||||
|
});
|
||||||
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,12 +154,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
const { session: userSession, user } =
|
const { session: userSession, user } =
|
||||||
await validateSessionToken(userToken);
|
await validateSessionToken(userToken);
|
||||||
if (!userSession || !user) {
|
if (!userSession || !user) {
|
||||||
logger.warn("Invalid user session for olm register");
|
logger.warn(
|
||||||
|
"[handleOlmRegisterMessage] Invalid user session for olm register",
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
|
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.userId !== olm.userId) {
|
if (user.userId !== olm.userId) {
|
||||||
logger.warn("User ID mismatch for olm register");
|
logger.warn(
|
||||||
|
"[handleOlmRegisterMessage] User ID mismatch for olm register",
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
|
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -163,11 +180,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
sessionId // this is the user token passed in the message
|
sessionId // this is the user token passed in the message
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Policy check result:", policyCheck);
|
logger.debug("[handleOlmRegisterMessage] Policy check result", {
|
||||||
|
orgId: client.orgId,
|
||||||
|
policyCheck
|
||||||
|
});
|
||||||
|
|
||||||
if (policyCheck?.error) {
|
if (policyCheck?.error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`
|
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -175,7 +196,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(
|
sendOlmError(
|
||||||
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
|
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
|
||||||
@@ -186,7 +208,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
policyCheck.policies?.maxSessionLength?.compliant === false
|
policyCheck.policies?.maxSessionLength?.compliant === false
|
||||||
) {
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(
|
sendOlmError(
|
||||||
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
|
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
|
||||||
@@ -195,7 +218,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
} else if (policyCheck.policies?.requiredTwoFactor === false) {
|
} else if (policyCheck.policies?.requiredTwoFactor === false) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(
|
sendOlmError(
|
||||||
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
|
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
|
||||||
@@ -204,7 +228,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
} else if (!policyCheck.allowed) {
|
} else if (!policyCheck.allowed) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -226,29 +251,39 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
// Prepare an array to store site configurations
|
||||||
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
logger.debug(
|
||||||
|
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
|
|
||||||
let jitMode = false;
|
let jitMode = false;
|
||||||
if (sitesCount > 250 && build == "saas") {
|
if (sitesCount > 250 && build == "saas") {
|
||||||
// THIS IS THE MAX ON THE BUSINESS TIER
|
// THIS IS THE MAX ON THE BUSINESS TIER
|
||||||
// we have too many sites
|
// we have too many sites
|
||||||
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
||||||
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
|
logger.info(
|
||||||
|
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
jitMode = true;
|
jitMode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
`[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logger.warn("Public key not provided");
|
logger.warn("[handleOlmRegisterMessage] Public key not provided", {
|
||||||
|
orgId: client.orgId
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.pubKey !== publicKey || client.archived) {
|
if (client.pubKey !== publicKey || client.archived) {
|
||||||
logger.info(
|
logger.info(
|
||||||
"Public key mismatch. Updating public key and clearing session info..."
|
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
// Update the client's public key
|
// Update the client's public key
|
||||||
await db
|
await db
|
||||||
@@ -274,7 +309,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
`[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = {
|
|||||||
remoteExitNodeId: string;
|
remoteExitNodeId: string;
|
||||||
dateCreated: string;
|
dateCreated: string;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
updateAvailable?: boolean;
|
||||||
exitNodeId: number | null;
|
exitNodeId: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ export async function getUserResources(
|
|||||||
destination: string;
|
destination: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
scheme: string | null;
|
scheme: string | null;
|
||||||
|
ssl: boolean;
|
||||||
|
fullDomain: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
@@ -164,6 +166,8 @@ export async function getUserResources(
|
|||||||
destination: siteResources.destination,
|
destination: siteResources.destination,
|
||||||
mode: siteResources.mode,
|
mode: siteResources.mode,
|
||||||
scheme: siteResources.scheme,
|
scheme: siteResources.scheme,
|
||||||
|
ssl: siteResources.ssl,
|
||||||
|
fullDomain: siteResources.fullDomain,
|
||||||
enabled: siteResources.enabled,
|
enabled: siteResources.enabled,
|
||||||
alias: siteResources.alias,
|
alias: siteResources.alias,
|
||||||
aliasAddress: siteResources.aliasAddress
|
aliasAddress: siteResources.aliasAddress
|
||||||
@@ -251,6 +255,8 @@ export async function getUserResources(
|
|||||||
destination: siteResource.destination,
|
destination: siteResource.destination,
|
||||||
mode: siteResource.mode,
|
mode: siteResource.mode,
|
||||||
protocol: siteResource.scheme,
|
protocol: siteResource.scheme,
|
||||||
|
ssl: siteResource.ssl,
|
||||||
|
fullDomain: siteResource.fullDomain,
|
||||||
enabled: siteResource.enabled,
|
enabled: siteResource.enabled,
|
||||||
alias: siteResource.alias,
|
alias: siteResource.alias,
|
||||||
aliasAddress: siteResource.aliasAddress,
|
aliasAddress: siteResource.aliasAddress,
|
||||||
@@ -296,6 +302,8 @@ export type GetUserResourcesResponse = {
|
|||||||
destination: string;
|
destination: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
protocol: string | null;
|
protocol: string | null;
|
||||||
|
ssl: boolean;
|
||||||
|
fullDomain: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export type ResourceWithTargets = {
|
|||||||
siteId: number;
|
siteId: number;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
online: boolean;
|
online?: boolean; // undefined for local sites
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -383,12 +383,8 @@ export async function listResources(
|
|||||||
.select({ resourceId: targets.resourceId })
|
.select({ resourceId: targets.resourceId })
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.where(
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
|
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||||
);
|
|
||||||
conditions.push(
|
|
||||||
inArray(resources.resourceId, resourcesWithSite)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||||
@@ -426,7 +422,8 @@ export async function listResources(
|
|||||||
hcEnabled: targetHealthCheck.hcEnabled,
|
hcEnabled: targetHealthCheck.hcEnabled,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
siteNiceId: sites.niceId,
|
||||||
siteOnline: sites.online
|
siteOnline: sites.online,
|
||||||
|
siteType: sites.type
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.where(inArray(targets.resourceId, resourceIdList))
|
.where(inArray(targets.resourceId, resourceIdList))
|
||||||
@@ -481,18 +478,19 @@ export async function listResources(
|
|||||||
siteId: number;
|
siteId: number;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
online: boolean;
|
online?: boolean;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
for (const t of raw) {
|
for (const t of raw) {
|
||||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const isLocal = t.siteType === "local";
|
||||||
siteById.set(t.siteId, {
|
siteById.set(t.siteId, {
|
||||||
siteId: t.siteId,
|
siteId: t.siteId,
|
||||||
siteName: t.siteName ?? "",
|
siteName: t.siteName ?? "",
|
||||||
siteNiceId: t.siteNiceId ?? "",
|
siteNiceId: t.siteNiceId ?? "",
|
||||||
online: Boolean(t.siteOnline)
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entry.sites = Array.from(siteById.values());
|
entry.sites = Array.from(siteById.values());
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetSiteResponse = NonNullable<
|
type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
Awaited<ReturnType<typeof query>>
|
|
||||||
>["sites"] & { newtId: string | null };
|
export type GetSiteResponse = SiteQueryRow["sites"] & {
|
||||||
|
newtId: string | null;
|
||||||
|
newtVersion: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -100,7 +103,8 @@ export async function getSite(
|
|||||||
|
|
||||||
const data: GetSiteResponse = {
|
const data: GetSiteResponse = {
|
||||||
...site.sites,
|
...site.sites,
|
||||||
newtId: site.newt ? site.newt.newtId : null
|
newtId: site.newt ? site.newt.newtId : null,
|
||||||
|
newtVersion: site.newt?.version ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
return response<GetSiteResponse>(res, {
|
return response<GetSiteResponse>(res, {
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ let staleNewtVersion: string | null = null;
|
|||||||
|
|
||||||
async function getLatestNewtVersion(): Promise<string | null> {
|
async function getLatestNewtVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const cachedVersion = await cache.get<string>("cache:latestNewtVersion");
|
const cachedVersion = await cache.get<string>(
|
||||||
|
"cache:latestNewtVersion"
|
||||||
|
);
|
||||||
if (cachedVersion) {
|
if (cachedVersion) {
|
||||||
return cachedVersion;
|
return cachedVersion;
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,10 @@ function querySitesBase() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
||||||
|
|
||||||
|
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||||
|
online?: SiteRowBase["online"]; // undefined for local sites
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,7 +343,9 @@ export async function listSites(
|
|||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
querySitesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_sites")
|
||||||
);
|
);
|
||||||
|
|
||||||
const siteListQuery = baseQuery
|
const siteListQuery = baseQuery
|
||||||
@@ -397,9 +404,13 @@ export async function listSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sitesPayload = sitesWithUpdates.map((site) =>
|
||||||
|
site.type === "local" ? { ...site, online: undefined } : site
|
||||||
|
);
|
||||||
|
|
||||||
return response<ListSitesResponse>(res, {
|
return response<ListSitesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
sites: sitesWithUpdates,
|
sites: sitesPayload,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
siteResources,
|
siteResources,
|
||||||
apiKeyOrg
|
apiKeyOrg,
|
||||||
|
primaryDb
|
||||||
} 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 HttpCode from "@server/types/HttpCode";
|
||||||
@@ -220,8 +221,12 @@ export async function batchAddClientToSiteResources(
|
|||||||
siteResourceId: siteResource.siteResourceId
|
siteResourceId: siteResource.siteResourceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(client, trx);
|
rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
SiteResource,
|
SiteResource,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
userSiteResources
|
userSiteResources,
|
||||||
|
primaryDb
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +47,7 @@ const createSiteResourceSchema = z
|
|||||||
mode: z.enum(["host", "cidr", "http"]),
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
ssl: z.boolean().optional(), // only used for http mode
|
ssl: z.boolean().optional(), // only used for http mode
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
siteIds: z.array(z.int()),
|
siteIds: z.array(z.int()).optional(),
|
||||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
@@ -74,7 +75,6 @@ const createSiteResourceSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host") {
|
if (data.mode === "host") {
|
||||||
if (data.mode == "host") {
|
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
const isValidIP = z
|
const isValidIP = z
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
@@ -84,7 +84,6 @@ const createSiteResourceSchema = z
|
|||||||
if (isValidIP) {
|
if (isValidIP) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain (hostname pattern, TLD not required)
|
// Check if it's a valid domain (hostname pattern, TLD not required)
|
||||||
const domainRegex =
|
const domainRegex =
|
||||||
@@ -96,17 +95,12 @@ const createSiteResourceSchema = z
|
|||||||
data.alias.trim() !== "";
|
data.alias.trim() !== "";
|
||||||
|
|
||||||
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
||||||
|
} else if (data.mode === "http") {
|
||||||
|
// we have to have a domainId defined
|
||||||
|
if (!data.domainId) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
} else if (data.mode === "cidr") {
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Destination must be a valid IPV4 address or valid domain AND alias is required"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.mode === "cidr") {
|
|
||||||
// Check if it's a valid CIDR (v4 or v6)
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
const isValidCIDR = z
|
const isValidCIDR = z
|
||||||
.union([z.cidrv4(), z.cidrv6()])
|
.union([z.cidrv4(), z.cidrv6()])
|
||||||
@@ -116,7 +110,8 @@ const createSiteResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message:
|
||||||
|
"Destination must be a valid IPV4 address or valid domain AND alias is required"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -133,6 +128,17 @@ const createSiteResourceSchema = z
|
|||||||
message:
|
message:
|
||||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
return (
|
||||||
|
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||||
|
data.siteId !== undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "At least one of siteIds or siteId must be provided"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
||||||
@@ -188,7 +194,7 @@ export async function createSiteResource(
|
|||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId,
|
||||||
siteIds: siteIdsInput,
|
siteIds: siteIdsInput = [],
|
||||||
siteId,
|
siteId,
|
||||||
mode,
|
mode,
|
||||||
scheme,
|
scheme,
|
||||||
@@ -485,11 +491,6 @@ export async function createSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
|
||||||
newSiteResource,
|
|
||||||
trx
|
|
||||||
); // we need to call this because we added to the admin role
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!newSiteResource) {
|
if (!newSiteResource) {
|
||||||
@@ -515,6 +516,20 @@ export async function createSiteResource(
|
|||||||
await createCertificate(domainId, fullDomain, db);
|
await createCertificate(domainId, fullDomain, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run in the background after the response is sent. Wrapped in its
|
||||||
|
// own transaction so it always executes on the primary — avoiding any
|
||||||
|
// replica-lag issues while still allowing the HTTP response to return
|
||||||
|
// early.
|
||||||
|
rebuildClientAssociationsFromSiteResource(
|
||||||
|
newSiteResource!,
|
||||||
|
primaryDb
|
||||||
|
).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: newSiteResource,
|
data: newSiteResource,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, newts, sites } from "@server/db";
|
import { db, newts, primaryDb, sites } from "@server/db";
|
||||||
import { siteResources } from "@server/db";
|
import { siteResources } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -63,16 +63,23 @@ export async function deleteSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
// Delete the site resource
|
// Delete the site resource
|
||||||
const [removedSiteResource] = await trx
|
const [removedSiteResource] = await db
|
||||||
.delete(siteResources)
|
.delete(siteResources)
|
||||||
.where(eq(siteResources.siteResourceId, siteResourceId))
|
.where(eq(siteResources.siteResourceId, siteResourceId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
// Run in the background after the response is sent. Wrapped in its
|
||||||
|
// own transaction so it always executes on the primary — avoiding any
|
||||||
|
// replica-lag issues while still allowing the HTTP response to return
|
||||||
|
// early.
|
||||||
|
rebuildClientAssociationsFromSiteResource(
|
||||||
removedSiteResource,
|
removedSiteResource,
|
||||||
trx
|
primaryDb
|
||||||
|
).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
|
||||||
|
err
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({
|
|||||||
const updateSiteResourceSchema = z
|
const updateSiteResourceSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
siteIds: z.array(z.int()),
|
siteIds: z.array(z.int()).optional(),
|
||||||
siteId: z.int().positive().optional(),
|
siteId: z.int().positive().optional(),
|
||||||
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
niceId: z
|
niceId: z
|
||||||
@@ -104,6 +104,17 @@ const updateSiteResourceSchema = z
|
|||||||
data.alias.trim() !== "";
|
data.alias.trim() !== "";
|
||||||
|
|
||||||
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
||||||
|
} else if (data.mode === "cidr" && data.destination) {
|
||||||
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
|
const isValidCIDR = z
|
||||||
|
.union([z.cidrv4(), z.cidrv6()])
|
||||||
|
.safeParse(data.destination).success;
|
||||||
|
return isValidCIDR;
|
||||||
|
} else if (data.mode === "http") {
|
||||||
|
// we have to have a domainId defined
|
||||||
|
if (!data.domainId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -112,21 +123,6 @@ const updateSiteResourceSchema = z
|
|||||||
"Destination must be a valid IP address or valid domain AND alias is required"
|
"Destination must be a valid IP address or valid domain AND alias is required"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.mode === "cidr" && data.destination) {
|
|
||||||
// Check if it's a valid CIDR (v4 or v6)
|
|
||||||
const isValidCIDR = z
|
|
||||||
.union([z.cidrv4(), z.cidrv6()])
|
|
||||||
.safeParse(data.destination).success;
|
|
||||||
return isValidCIDR;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode !== "http") return true;
|
if (data.mode !== "http") return true;
|
||||||
@@ -143,6 +139,17 @@ const updateSiteResourceSchema = z
|
|||||||
message:
|
message:
|
||||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
return (
|
||||||
|
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||||
|
data.siteId !== undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "At least one of siteIds or siteId must be provided"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
||||||
@@ -197,7 +204,7 @@ export async function updateSiteResource(
|
|||||||
const { siteResourceId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
siteIds: siteIdsInput, // because it can change
|
siteIds: siteIdsInput = [], // because it can change
|
||||||
siteId,
|
siteId,
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
@@ -420,9 +427,6 @@ export async function updateSiteResource(
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// wait some time to allow for messages to be handled
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
||||||
|
|
||||||
const sshPamSet =
|
const sshPamSet =
|
||||||
isLicensedSshPam &&
|
isLicensedSshPam &&
|
||||||
(authDaemonPort !== undefined ||
|
(authDaemonPort !== undefined ||
|
||||||
@@ -545,11 +549,6 @@ export async function updateSiteResource(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
|
||||||
updatedSiteResource,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Update the site resource
|
// Update the site resource
|
||||||
const sshPamSet =
|
const sshPamSet =
|
||||||
@@ -679,7 +678,24 @@ export async function updateSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Updated site resource ${siteResourceId}`);
|
logger.info(`Updated site resource ${siteResourceId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background: wait for removal messages to propagate, then rebuild
|
||||||
|
// associations for the re-created resource. Own transaction ensures
|
||||||
|
// execution on the primary against fully committed state.
|
||||||
|
(async () => {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
if (!updatedSiteResource) {
|
||||||
|
throw new Error("No updated resource found after update");
|
||||||
|
}
|
||||||
|
if (sitesChanged) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
updatedSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
await handleMessagingForUpdatedSiteResource(
|
await handleMessagingForUpdatedSiteResource(
|
||||||
existingSiteResource,
|
existingSiteResource,
|
||||||
updatedSiteResource,
|
updatedSiteResource,
|
||||||
@@ -689,7 +705,12 @@ export async function updateSiteResource(
|
|||||||
})),
|
})),
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
})().catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
`Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
fireHealthCheckHealthyAlert,
|
fireHealthCheckHealthyAlert,
|
||||||
fireHealthCheckUnhealthyAlert,
|
fireHealthCheckUnhealthyAlert,
|
||||||
fireHealthCheckUnknownAlert
|
fireHealthCheckUnknownAlert
|
||||||
} from "#dynamic/lib/alerts";
|
} from "@server/lib/alerts";
|
||||||
|
|
||||||
const createTargetParamsSchema = z.strictObject({
|
const createTargetParamsSchema = z.strictObject({
|
||||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
|||||||