Compare commits

..

112 Commits

Author SHA1 Message Date
Owen Schwartz
cf596d980f Merge pull request #2971 from fosrl/dev
1.18.2
2026-05-02 20:59:51 -07:00
Owen Schwartz
70f619b726 Merge pull request #2970 from fosrl/crowdin_dev
New Crowdin updates
2026-05-02 20:59:16 -07:00
Owen Schwartz
7743e3890b New translations en-us.json (Spanish)
[ci skip]
2026-05-02 20:57:57 -07:00
Owen Schwartz
d8df250555 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-02 20:57:55 -07:00
Owen Schwartz
45c9f217c6 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-02 20:57:54 -07:00
Owen Schwartz
8371692cc5 New translations en-us.json (Turkish)
[ci skip]
2026-05-02 20:57:52 -07:00
Owen Schwartz
5377dc7a1c New translations en-us.json (Russian)
[ci skip]
2026-05-02 20:57:51 -07:00
Owen Schwartz
02649468e0 New translations en-us.json (Portuguese)
[ci skip]
2026-05-02 20:57:49 -07:00
Owen Schwartz
c5ef00fb0e New translations en-us.json (Polish)
[ci skip]
2026-05-02 20:57:48 -07:00
Owen Schwartz
6f4325e9a0 New translations en-us.json (Dutch)
[ci skip]
2026-05-02 20:57:45 -07:00
Owen Schwartz
a2a031dfe7 New translations en-us.json (Korean)
[ci skip]
2026-05-02 20:57:44 -07:00
Owen Schwartz
e34a4c82eb New translations en-us.json (Italian)
[ci skip]
2026-05-02 20:57:42 -07:00
Owen Schwartz
52fd7df727 New translations en-us.json (German)
[ci skip]
2026-05-02 20:57:41 -07:00
Owen Schwartz
d5f08437d7 New translations en-us.json (Czech)
[ci skip]
2026-05-02 20:57:39 -07:00
Owen Schwartz
9ee07ba343 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-02 20:57:38 -07:00
Owen Schwartz
4baaa5fc14 New translations en-us.json (French)
[ci skip]
2026-05-02 20:57:36 -07:00
Owen
61de100630 Fix imports 2026-05-02 20:46:52 -07:00
miloschwartz
3694f43ae8 dont early return on multi org 2026-05-02 20:38:14 -07:00
Owen
279211142d Bump version 2026-05-02 13:48:25 -07:00
Owen
b8822b4d25 Fix CE not processing alert status
Fixes #2968
2026-05-02 13:38:05 -07:00
Owen
e1afbc226c Allow configuring the webhook body 2026-05-02 13:26:54 -07:00
miloschwartz
96c450fd08 update private resources screenshot 2026-05-02 13:19:00 -07:00
Owen Schwartz
587e4d104b New translations en-us.json (Spanish)
[ci skip]
2026-05-02 13:16:28 -07:00
Owen Schwartz
368c5c374f New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-02 13:16:26 -07:00
Owen Schwartz
7675b6409c New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-02 13:16:24 -07:00
Owen Schwartz
d31da1a41e New translations en-us.json (Turkish)
[ci skip]
2026-05-02 13:16:23 -07:00
Owen Schwartz
49e259e259 New translations en-us.json (Russian)
[ci skip]
2026-05-02 13:16:21 -07:00
Owen Schwartz
f4684c1858 New translations en-us.json (Portuguese)
[ci skip]
2026-05-02 13:16:19 -07:00
Owen Schwartz
6e223bb363 New translations en-us.json (Polish)
[ci skip]
2026-05-02 13:16:18 -07:00
Owen Schwartz
22e7038b2c New translations en-us.json (Dutch)
[ci skip]
2026-05-02 13:16:16 -07:00
Owen Schwartz
76ba4c1fdf New translations en-us.json (Korean)
[ci skip]
2026-05-02 13:16:14 -07:00
Owen Schwartz
7f25d94a83 New translations en-us.json (Italian)
[ci skip]
2026-05-02 13:16:13 -07:00
Owen Schwartz
769ba27e3a New translations en-us.json (German)
[ci skip]
2026-05-02 13:16:11 -07:00
Owen Schwartz
a188552ba0 New translations en-us.json (Czech)
[ci skip]
2026-05-02 13:16:09 -07:00
Owen Schwartz
208132082e New translations en-us.json (Bulgarian)
[ci skip]
2026-05-02 13:16:08 -07:00
Owen Schwartz
fcd5789221 New translations en-us.json (French)
[ci skip]
2026-05-02 13:16:06 -07:00
miloschwartz
c6a8b09cff log in page improvements 2026-05-02 12:46:39 -07:00
miloschwartz
380ff381fc fix credenza scroll extra spacing above footer 2026-05-02 12:19:00 -07:00
Owen Schwartz
5eb3951f00 New translations en-us.json (Spanish)
[ci skip]
2026-05-02 12:13:08 -07:00
Owen Schwartz
c30e94da98 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-02 12:13:06 -07:00
Owen Schwartz
6ca24d51a1 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-02 12:13:05 -07:00
Owen Schwartz
13f512aed6 New translations en-us.json (Turkish)
[ci skip]
2026-05-02 12:13:03 -07:00
Owen Schwartz
2bdbc9d688 New translations en-us.json (Russian)
[ci skip]
2026-05-02 12:13:02 -07:00
Owen Schwartz
8e2f30d8de New translations en-us.json (Portuguese)
[ci skip]
2026-05-02 12:13:00 -07:00
Owen Schwartz
a84e1cc9e0 New translations en-us.json (Polish)
[ci skip]
2026-05-02 12:12:58 -07:00
Owen Schwartz
6b28f0c81e New translations en-us.json (Dutch)
[ci skip]
2026-05-02 12:12:56 -07:00
Owen Schwartz
d28d3ba6ea New translations en-us.json (Korean)
[ci skip]
2026-05-02 12:12:55 -07:00
Owen Schwartz
6efaf9f40d New translations en-us.json (Italian)
[ci skip]
2026-05-02 12:12:53 -07:00
Owen Schwartz
5379b32959 New translations en-us.json (German)
[ci skip]
2026-05-02 12:12:51 -07:00
Owen Schwartz
9bb936a40d New translations en-us.json (Czech)
[ci skip]
2026-05-02 12:12:50 -07:00
Owen Schwartz
960fe760f1 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-02 12:12:48 -07:00
Owen Schwartz
2f2105a085 New translations en-us.json (French)
[ci skip]
2026-05-02 12:12:46 -07:00
miloschwartz
de92a28435 update mac models 2026-05-02 12:09:55 -07:00
Owen
d8c3484ed5 Have to import from private 2026-05-02 12:00:51 -07:00
Owen
726e000154 Show remote nodes update in table 2026-05-02 11:55:01 -07:00
Owen Schwartz
9df46f7014 Merge pull request #2966 from fosrl/dev
Try to pull domains from host regex
2026-05-01 20:54:09 -07:00
Owen
908f0d54e2 Try to pull domains from host regex 2026-05-01 20:53:39 -07:00
Milo Schwartz
f0010ea12a Merge pull request #2965 from fosrl/dev
add new screenshots
2026-05-01 17:35:29 -07:00
miloschwartz
cab8be1a9a add new screenshots 2026-05-01 17:34:05 -07:00
Owen Schwartz
0a9dab7cca Merge pull request #2964 from fosrl/dev
Update translations
2026-05-01 17:02:41 -07:00
Owen Schwartz
889ab1f8a8 Merge pull request #2963 from fosrl/crowdin_dev
New Crowdin updates
2026-05-01 17:02:10 -07:00
Owen Schwartz
a9019cfb23 New translations en-us.json (Spanish)
[ci skip]
2026-05-01 17:00:49 -07:00
Owen Schwartz
441d4bce6e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-01 17:00:47 -07:00
Owen Schwartz
dd1e681a9c New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-01 17:00:45 -07:00
Owen Schwartz
a882619eaf New translations en-us.json (Turkish)
[ci skip]
2026-05-01 17:00:43 -07:00
Owen Schwartz
f43baaaf1f New translations en-us.json (Russian)
[ci skip]
2026-05-01 17:00:41 -07:00
Owen Schwartz
c3dc0bd015 New translations en-us.json (Portuguese)
[ci skip]
2026-05-01 17:00:39 -07:00
Owen Schwartz
1fd2a0fae2 New translations en-us.json (Polish)
[ci skip]
2026-05-01 17:00:37 -07:00
Owen Schwartz
8ba5b43569 New translations en-us.json (Dutch)
[ci skip]
2026-05-01 17:00:35 -07:00
Owen Schwartz
6deefcd003 New translations en-us.json (Korean)
[ci skip]
2026-05-01 17:00:33 -07:00
Owen Schwartz
4d6cea5fcd New translations en-us.json (Italian)
[ci skip]
2026-05-01 17:00:31 -07:00
Owen Schwartz
f175ac774f New translations en-us.json (German)
[ci skip]
2026-05-01 17:00:29 -07:00
Owen Schwartz
0fe2b24f6b New translations en-us.json (Czech)
[ci skip]
2026-05-01 17:00:27 -07:00
Owen Schwartz
6ad06e6faf New translations en-us.json (Bulgarian)
[ci skip]
2026-05-01 17:00:25 -07:00
Owen Schwartz
d47faeced1 New translations en-us.json (French)
[ci skip]
2026-05-01 17:00:23 -07:00
Owen Schwartz
498f586eeb New translations en-us.json (Spanish)
[ci skip]
2026-05-01 16:57:38 -07:00
Owen Schwartz
e94fc6bc65 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-01 16:57:37 -07:00
Owen Schwartz
0a1fe1b725 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-01 16:57:35 -07:00
Owen Schwartz
eb40b04b43 New translations en-us.json (Turkish)
[ci skip]
2026-05-01 16:57:33 -07:00
Owen Schwartz
6685afdcf9 New translations en-us.json (Russian)
[ci skip]
2026-05-01 16:57:32 -07:00
Owen Schwartz
49232e32bf New translations en-us.json (Portuguese)
[ci skip]
2026-05-01 16:57:30 -07:00
Owen Schwartz
aec0aed211 New translations en-us.json (Polish)
[ci skip]
2026-05-01 16:57:28 -07:00
Owen Schwartz
d43b3176f5 New translations en-us.json (Dutch)
[ci skip]
2026-05-01 16:57:26 -07:00
Owen Schwartz
190074ea0c New translations en-us.json (Korean)
[ci skip]
2026-05-01 16:57:24 -07:00
Owen Schwartz
c5a7719239 New translations en-us.json (Italian)
[ci skip]
2026-05-01 16:57:22 -07:00
Owen Schwartz
5eac131d2e New translations en-us.json (German)
[ci skip]
2026-05-01 16:57:21 -07:00
Owen Schwartz
0bc3276ee2 New translations en-us.json (Czech)
[ci skip]
2026-05-01 16:57:18 -07:00
Owen Schwartz
5073507b90 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-01 16:57:16 -07:00
Owen Schwartz
805e6f856a New translations en-us.json (French)
[ci skip]
2026-05-01 16:57:14 -07:00
Owen Schwartz
412a9b5294 Merge pull request #2962 from fosrl/dev
1.18.1-s.6
2026-05-01 16:54:37 -07:00
Owen
fbf95c5363 Start creating ns one level down 2026-05-01 16:51:42 -07:00
Owen
b907850344 Add missing heading 2026-05-01 16:51:42 -07:00
miloschwartz
22116373e3 increase target site selector width 2026-05-01 16:33:40 -07:00
miloschwartz
9757c3d8b6 show newt version on site 2026-05-01 16:26:45 -07:00
miloschwartz
f8b85d4b4e fix sidebar product updates spacing 2026-05-01 16:14:06 -07:00
Owen
4651f19c53 Support acme_json_path as a directory of acme file
Fixes #2961
2026-05-01 16:06:37 -07:00
Owen
4524bdc094 Add http cert syncing for use with the controller 2026-05-01 15:42:38 -07:00
Owen Schwartz
741850880e Merge pull request #2959 from fosrl/dev
1.18.1-s.4
2026-05-01 15:05:59 -07:00
Owen
53e096f7cb Allow deleting account with trial 2026-05-01 15:01:48 -07:00
Owen
3dfd7e8a43 Update limits 2026-05-01 11:47:14 -07:00
Owen
db6e60d0a3 Adjust language 2026-05-01 10:48:09 -07:00
Owen
54d2d689c1 Run messaging for delete in the background as well 2026-04-30 14:38:03 -07:00
Owen Schwartz
bb5853827b Merge pull request #2948 from fosrl/dev
1.18.1-s.3
2026-04-30 14:11:16 -07:00
Owen
68f5512732 Handle messaging in the background; dont time out 2026-04-30 14:00:32 -07:00
Owen
416e124c02 Rotate the secret on the new things using it 2026-04-30 11:53:55 -07:00
Owen
d3e4d8cda8 Fix pr blueprints not picking up site 2026-04-30 11:39:37 -07:00
Owen
81972dbb73 Add name to migration
Fixes #2943
2026-04-30 10:56:12 -07:00
Owen Schwartz
b715786a1e Merge pull request #2939 from fosrl/dev
1.18.1-s.2
2026-04-29 21:33:03 -07:00
Owen
ae24eb2d2c Disable the alerts and hc when downgrading 2026-04-29 21:31:02 -07:00
Owen
20fc59dcda Delete trial when upgrading 2026-04-29 21:25:58 -07:00
Owen
93b09de425 Adjust cloud api endpoints 2026-04-29 21:04:11 -07:00
Owen
bacc130453 Clean up sign and verify 2026-04-29 17:14:22 -07:00
86 changed files with 2439 additions and 1493 deletions

View File

@@ -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
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
# Wrap the entire tag processing in error handling for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
( echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
set -e TAG_FAILED=false
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}" (
cosign sign --recursive "${REF}" set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${GHCR_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (key) --recursive ${REF}" echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" cosign sign --recursive "${REF}"
) || TAG_FAILED=true
# Retry wrapper for verification to handle registry propagation delays if [ "$TAG_FAILED" = "true" ]; then
retry_verify() { echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
local cmd="$1" FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
local attempts=6 else
local delay=5 echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
local i=1 SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
until eval "$cmd"; do fi
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
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
fi
done
done done
# 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

View File

@@ -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.`
); );

View File

@@ -763,6 +763,7 @@
"newtEndpoint": "Крайна точка", "newtEndpoint": "Крайна точка",
"newtId": "Идентификационен номер", "newtId": "Идентификационен номер",
"newtSecretKey": "Секретен ключ", "newtSecretKey": "Секретен ключ",
"newtVersion": "Версия",
"architecture": "Архитектура", "architecture": "Архитектура",
"sites": "Сайтове", "sites": "Сайтове",
"siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.", "siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.",
@@ -1666,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието", "pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието",
"newtUpdateAvailable": "Ново обновление", "newtUpdateAvailable": "Ново обновление",
"newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.", "newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.",
"pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.",
"domainPickerEnterDomain": "Домейн", "domainPickerEnterDomain": "Домейн",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.", "domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.",
@@ -2353,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите", "orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите",
"orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.",
"orgAuthSignInWithPangolin": "Впишете се с Pangolin", "orgAuthSignInWithPangolin": "Впишете се с Pangolin",
"orgAuthSignInToOrg": "Влезте в организация", "orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)",
"orgAuthSelectOrgTitle": "Вход в организация.", "orgAuthSelectOrgTitle": "Вход в организация.",
"orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.", "orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.",
"orgAuthOrgIdPlaceholder": "вашата-организация", "orgAuthOrgIdPlaceholder": "вашата-организация",
@@ -3201,5 +3203,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.", "domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.",
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
"domainPickerWildcardCertWarningLink": "Научете повече", "domainPickerWildcardCertWarningLink": "Научете повече",
"health": "Здраве" "health": "Здраве",
"domainPendingErrorTitle": "Проблем при проверка"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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í.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,7 @@
"newtEndpoint": "엔드포인트", "newtEndpoint": "엔드포인트",
"newtId": "ID", "newtId": "ID",
"newtSecretKey": "비밀", "newtSecretKey": "비밀",
"newtVersion": "버전",
"architecture": "아키텍처", "architecture": "아키텍처",
"sites": "사이트", "sites": "사이트",
"siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.", "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.",
@@ -1666,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기", "pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기",
"newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailable": "업데이트 가능",
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"domainPickerEnterDomain": "도메인", "domainPickerEnterDomain": "도메인",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.", "domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.",
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
"domainPickerWildcardCertWarningLink": "자세히 알아보기", "domainPickerWildcardCertWarningLink": "자세히 알아보기",
"health": "건강" "health": "건강",
"domainPendingErrorTitle": "확인 문제"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3168,7 +3170,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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ą"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,7 @@
"newtEndpoint": "Endpoint", "newtEndpoint": "Endpoint",
"newtId": "ID", "newtId": "ID",
"newtSecretKey": "Секретный ключ", "newtSecretKey": "Секретный ключ",
"newtVersion": "Версия",
"architecture": "Архитектура", "architecture": "Архитектура",
"sites": "Сайты", "sites": "Сайты",
"siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.", "siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.",
@@ -1666,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску", "pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
"newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailable": "Доступно обновление",
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"domainPickerEnterDomain": "Домен", "domainPickerEnterDomain": "Домен",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
@@ -2353,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
"orgAuthSignInWithPangolin": "Войти через Pangolin", "orgAuthSignInWithPangolin": "Войти через Pangolin",
"orgAuthSignInToOrg": "Войти в организацию", "orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)",
"orgAuthSelectOrgTitle": "Вход в организацию", "orgAuthSelectOrgTitle": "Вход в организацию",
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
"orgAuthOrgIdPlaceholder": "ваша-организация", "orgAuthOrgIdPlaceholder": "ваша-организация",
@@ -3201,5 +3203,6 @@
"domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.", "domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.",
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
"domainPickerWildcardCertWarningLink": "Узнать больше", "domainPickerWildcardCertWarningLink": "Узнать больше",
"health": "Состояние" "health": "Состояние",
"domainPendingErrorTitle": "Проблема с подтверждением"
} }

View File

@@ -763,6 +763,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.",
@@ -1666,6 +1667,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.",
@@ -2353,7 +2355,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",
@@ -3201,5 +3203,6 @@
"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"
} }

View File

@@ -763,6 +763,7 @@
"newtEndpoint": "Endpoint", "newtEndpoint": "Endpoint",
"newtId": "ID", "newtId": "ID",
"newtSecretKey": "密钥", "newtSecretKey": "密钥",
"newtVersion": "版本",
"architecture": "架构", "architecture": "架构",
"sites": "站点", "sites": "站点",
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。", "siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
@@ -1666,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "查看发布说明", "pangolinUpdateAvailableReleaseNotes": "查看发布说明",
"newtUpdateAvailable": "更新可用", "newtUpdateAvailable": "更新可用",
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
"pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
"domainPickerEnterDomain": "域名", "domainPickerEnterDomain": "域名",
"domainPickerPlaceholder": "example.com", "domainPickerPlaceholder": "example.com",
"domainPickerDescription": "输入资源的完整域名以查看可用选项。", "domainPickerDescription": "输入资源的完整域名以查看可用选项。",
@@ -2353,7 +2355,7 @@
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续", "orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
"orgAuthSignInWithPangolin": "使用 Pangolin 登录", "orgAuthSignInWithPangolin": "使用 Pangolin 登录",
"orgAuthSignInToOrg": "登录到组织", "orgAuthSignInToOrg": "组织身份提供商 (SSO)",
"orgAuthSelectOrgTitle": "组织登录", "orgAuthSelectOrgTitle": "组织登录",
"orgAuthSelectOrgDescription": "输入您的组织ID以继续", "orgAuthSelectOrgDescription": "输入您的组织ID以继续",
"orgAuthOrgIdPlaceholder": "您的组织", "orgAuthOrgIdPlaceholder": "您的组织",
@@ -3201,5 +3203,6 @@
"domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。", "domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。",
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
"domainPickerWildcardCertWarningLink": "了解更多", "domainPickerWildcardCertWarningLink": "了解更多",
"health": "健康" "health": "健康",
"domainPendingErrorTitle": "验证问题"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 KiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 516 KiB

View File

@@ -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",

View File

@@ -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"
} }

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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> {
return; 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( 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> {
return; 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( 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> {
return; 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
);
}
}
} }

View File

@@ -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
);
}
}

View File

@@ -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
);
}
} }

View File

@@ -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";

View File

@@ -0,0 +1,5 @@
import { AlertContext } from "@server/routers/alertRule/types";
export async function processAlerts(context: AlertContext): Promise<void> {
return;
}

View File

@@ -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 };
} }

View File

@@ -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]: {

View File

@@ -131,41 +131,22 @@ export async function updateClientResources(
: []; : [];
const allSites: { siteId: number }[] = []; const allSites: { siteId: number }[] = [];
if (resourceData.site) { if (resourceData.site) {
let siteSingle; // Look up site by niceId
const resourceSiteId = resourceData.site; const [siteSingle] = await trx
.select({ siteId: sites.siteId })
if (resourceSiteId) { .from(sites)
// Look up site by niceId .where(
[siteSingle] = await trx and(
.select({ siteId: sites.siteId }) eq(sites.niceId, resourceData.site),
.from(sites) eq(sites.orgId, orgId)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
) )
.limit(1); )
} else if (siteId) { .limit(1);
// Use the provided siteId directly, but verify it belongs to the org if (siteSingle) {
[siteSingle] = await trx allSites.push(siteSingle);
.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);
} }
if (resourceData.sites) { if (resourceData.sites) {
@@ -180,15 +161,31 @@ export async function updateClientResources(
) )
) )
.limit(1); .limit(1);
if (!site) { if (site) {
throw new Error( allSites.push(site);
`Site not found: ${siteId} in org ${orgId}`
);
} }
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:
| { subdomain: string | null; domainId: string } | { subdomain: string | null; domainId: string }

View File

@@ -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

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.18.0"; export const APP_VERSION = "1.18.2";
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);

View File

@@ -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}`);
}
}
} }
} }
} }

View File

@@ -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,
@@ -274,12 +275,244 @@ function detectWildcard(
return { wildcard: false, wildcardSan: null }; 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 {
response = await fetch(endpoint);
} catch (err) {
logger.debug(
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
);
return;
}
if (!response.ok) {
logger.debug(
`acmeCertSync: HTTP endpoint returned status ${response.status}`
);
return;
}
let httpCerts: HttpCert[];
try {
httpCerts = await response.json();
} catch (err) {
logger.debug(
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
);
return;
}
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
logger.debug(
`acmeCertSync: no certificates returned from HTTP endpoint`
);
return;
}
for (const cert of httpCerts) {
const domain = cert?.certName;
if (!domain || typeof domain !== "string") {
logger.debug(
`acmeCertSync: skipping HTTP cert with missing certName`
);
continue;
}
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
.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) {
continue;
}
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);
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);
}
}
}
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() && entry.name === "acme.json") {
results.push(fullPath);
}
}
return results;
}
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> { async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
let raw: string; let raw: string;
try { try {
raw = fs.readFileSync(acmeJsonPath, "utf8"); raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) { } catch (err) {
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`); logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
return; return;
} }
@@ -287,7 +520,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
try { try {
acmeJson = JSON.parse(raw); acmeJson = JSON.parse(raw);
} catch (err) { } catch (err) {
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`); logger.warn(
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
);
return; return;
} }
@@ -389,11 +624,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
const existing = await db const existing = await db
.select() .select()
.from(certificates) .from(certificates)
.where( .where(and(eq(certificates.domain, domain)))
and(
eq(certificates.domain, domain)
)
)
.limit(1); .limit(1);
let oldCertPem: string | null = null; let oldCertPem: string | null = null;
@@ -408,7 +639,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
const wildcardUnchanged = existing[0].wildcard === wildcard; const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) { if (storedCertPem === certPem && wildcardUnchanged) {
// logger.debug( // logger.debug(
// `acmeCertSync: cert for ${domain} is unchanged, skipping` // `acmeCertSync: cert for ${domain} is unchanged, skipping`
// ); // );
continue; continue;
} }
@@ -547,19 +778,62 @@ export function initAcmeCertSync(): void {
privateConfigData.acme?.acme_json_path ?? privateConfigData.acme?.acme_json_path ??
"config/letsencrypt/acme.json"; "config/letsencrypt/acme.json";
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}" across all resolvers 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`
);
}
const runSync = () => {
if (httpEndpoint) {
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;
}
if (stat.isDirectory()) {
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}`);
});
}
}
};
// Run immediately on init, then on the configured interval // Run immediately on init, then on the configured interval
syncAcmeCerts(acmeJsonPath).catch((err) => { runSync();
logger.error(`acmeCertSync: error during initial sync: ${err}`);
});
setInterval(() => { setInterval(runSync, intervalMs);
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
} }

View File

@@ -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
);
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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";

View File

@@ -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,52 @@ 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}}, and
* {{data}} placeholders, mirroring the logic in HttpLogDestination.
*
* {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
* strings inside data values are not re-expanded.
*/
function renderTemplate(template: string, ctx: TemplateContext): string {
const rendered = template
.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
.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);
}

View File

@@ -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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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 };
} }

View File

@@ -21,173 +21,172 @@ 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
app: z .object({
.object({ app: z
region: z.string().optional().default("default"), .object({
base_domain: z.string().optional(), region: z.string().optional().default("default"),
identity_provider_mode: z.enum(["global", "org"]).optional() base_domain: z.string().optional(),
}) identity_provider_mode: z.enum(["global", "org"]).optional()
.optional() })
.default({ .optional()
region: "default" .default({
}), region: "default"
server: z }),
.object({ server: z
reo_client_id: z .object({
.string() reo_client_id: z
.optional() .string()
.transform(getEnvOrYaml("REO_CLIENT_ID")), .optional()
fossorial_api: z .transform(getEnvOrYaml("REO_CLIENT_ID")),
.string() fossorial_api: z
.optional() .string()
.default("https://api.fossorial.io"), .optional()
fossorial_api_key: z .default("https://api.fossorial.io"),
.string() fossorial_api_key: z
.optional() .string()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY")) .optional()
}) .transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
.optional() })
.prefault({}), .optional()
redis: z .prefault({}),
.object({ redis: z
host: z.string(), .object({
port: portSchema, host: z.string(),
password: z port: portSchema,
.string() password: z
.optional() .string()
.transform(getEnvOrYaml("REDIS_PASSWORD")), .optional()
db: z.int().nonnegative().optional().default(0), .transform(getEnvOrYaml("REDIS_PASSWORD")),
replicas: z db: z.int().nonnegative().optional().default(0),
.array( replicas: z
z.object({ .array(
host: z.string(), z.object({
port: portSchema, host: z.string(),
password: z.string().optional(), port: portSchema,
db: z.int().nonnegative().optional().default(0) password: z.string().optional(),
db: z.int().nonnegative().optional().default(0)
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z.boolean().optional().default(true)
}) })
) .optional()
.optional(), })
tls: z .optional(),
.object({ gerbil: z
rejectUnauthorized: z .object({
.boolean() local_exit_node_reachable_at: z
.optional() .string()
.default(true) .optional()
}) .default("http://gerbil:3004")
.optional() })
}) .optional()
.optional(), .prefault({}),
gerbil: z flags: z
.object({ .object({
local_exit_node_reachable_at: z enable_redis: z.boolean().optional().default(false),
.string() use_pangolin_dns: z.boolean().optional().default(false),
.optional() use_org_only_idp: z.boolean().optional(),
.default("http://gerbil:3004") enable_acme_cert_sync: z.boolean().optional().default(true)
}) })
.optional() .optional()
.prefault({}), .prefault({}),
flags: z acme: z
.object({ .object({
enable_redis: z.boolean().optional().default(false), acme_json_path: z
use_pangolin_dns: z.boolean().optional().default(false), .string()
use_org_only_idp: z.boolean().optional(), .optional()
enable_acme_cert_sync: z.boolean().optional().default(true) .default("config/letsencrypt/acme.json"),
}) acme_http_endpoint: z.string().optional(),
.optional() sync_interval_ms: z.number().optional().default(5000)
.prefault({}), })
acme: z .optional(),
.object({ branding: z
acme_json_path: z .object({
.string() app_name: z.string().optional(),
.optional() background_image_path: z.string().optional(),
.default("config/letsencrypt/acme.json"), colors: z
sync_interval_ms: z.number().optional().default(5000) .object({
}) light: colorsSchema.optional(),
.optional(), dark: colorsSchema.optional()
branding: z
.object({
app_name: z.string().optional(),
background_image_path: z.string().optional(),
colors: z
.object({
light: colorsSchema.optional(),
dark: colorsSchema.optional()
})
.optional(),
logo: z
.object({
light_path: z.string().optional(),
dark_path: z.string().optional(),
auth_page: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional(),
navbar: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional()
})
.optional(),
footer: z
.array(
z.object({
text: z.string(),
href: z.string().optional()
}) })
) .optional(),
.optional(), logo: z
hide_auth_layout_footer: z.boolean().optional().default(false), .object({
login_page: z light_path: z.string().optional(),
.object({ dark_path: z.string().optional(),
subtitle_text: z.string().optional() auth_page: z
}) .object({
.optional(), width: z.number().optional(),
signup_page: z height: z.number().optional()
.object({ })
subtitle_text: z.string().optional() .optional(),
}) navbar: z
.optional(), .object({
resource_auth_page: z width: z.number().optional(),
.object({ height: z.number().optional()
show_logo: z.boolean().optional(), })
hide_powered_by: z.boolean().optional(), .optional()
title_text: z.string().optional(), })
subtitle_text: z.string().optional() .optional(),
}) footer: z
.optional(), .array(
emails: z z.object({
.object({ text: z.string(),
signature: z.string().optional(), href: z.string().optional()
colors: z
.object({
primary: z.string().optional()
}) })
.optional() )
}) .optional(),
.optional() hide_auth_layout_footer: z.boolean().optional().default(false),
}) login_page: z
.optional(), .object({
stripe: z subtitle_text: z.string().optional()
.object({ })
secret_key: z .optional(),
.string() signup_page: z
.optional() .object({
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")), subtitle_text: z.string().optional()
webhook_secret: z })
.string() .optional(),
.optional() resource_auth_page: z
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), .object({
// s3Bucket: z.string(), show_logo: z.boolean().optional(),
// s3Region: z.string().default("us-east-1"), hide_powered_by: z.boolean().optional(),
// localFilePath: z.string().optional() title_text: z.string().optional(),
}) subtitle_text: z.string().optional()
.optional() })
}) .optional(),
emails: z
.object({
signature: z.string().optional(),
colors: z
.object({
primary: z.string().optional()
})
.optional()
})
.optional()
})
.optional(),
stripe: z
.object({
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET"))
// s3Bucket: z.string(),
// s3Region: z.string().default("us-east-1"),
// localFilePath: z.string().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;

View File

@@ -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)
) )
) )

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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

View File

@@ -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.`

View File

@@ -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,16 @@ export async function createCertificate(
domainToWrite = parts.slice(1).join("."); domainToWrite = parts.slice(1).join(".");
domainToWrite = `*.${domainToWrite}`; domainToWrite = `*.${domainToWrite}`;
} }
} else if (domainRecord.type == "ns") {
// first if we have a * in the domain for this case we dont want to include it because it will mess with the cert generator so remove it
if (domain.startsWith("*.")) {
domain = domain.slice(2);
}
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

View File

@@ -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()

View File

@@ -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()

View File

@@ -67,24 +67,20 @@ if (build == "saas") {
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
certificates.syncCertToNewts certificates.syncCertToNewts
); );
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
org.sendTrialNotification
);
} }
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
logActionAudit(ActionsEnum.sendUsageNotification),
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
logActionAudit(ActionsEnum.sendTrialNotification),
org.sendTrialNotification
);
authenticated.delete( authenticated.delete(
"/idp/:idpId", "/idp/:idpId",
verifyApiKeyIsRoot, verifyApiKeyIsRoot,

View File

@@ -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,

View File

@@ -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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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,

View File

@@ -336,31 +336,22 @@ 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) { const subscribed = await isSubscribed(
// for some reason there is more than one org org.orgId,
logger.error( tierMatrix.autoProvisioning
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
); );
return next( if (!subscribed) {
createHttpError( // filter out the org
HttpCode.INTERNAL_SERVER_ERROR, allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
"Multiple organizations linked to this IdP. Please contact support."
)
);
}
const subscribed = await isSubscribed( // return next(
allOrgs[0].orgId, // createHttpError(
tierMatrix.autoProvisioning // HttpCode.FORBIDDEN,
); // "This organization's current plan does not support this feature."
if (!subscribed) { // )
return next( // );
createHttpError( }
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);

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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, {

View File

@@ -496,11 +496,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) {
@@ -526,6 +521,22 @@ 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.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
newSiteResource!,
trx
);
}).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,

View File

@@ -63,17 +63,26 @@ export async function deleteSiteResource(
); );
} }
await db.transaction(async (trx) => { // Delete the site resource
// Delete the site resource const [removedSiteResource] = await db
const [removedSiteResource] = await trx .delete(siteResources)
.delete(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId))
.where(eq(siteResources.siteResourceId, siteResourceId)) .returning();
.returning();
// 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.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
removedSiteResource, removedSiteResource,
trx trx
); );
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err
);
}); });
logger.info(`Deleted site resource ${siteResourceId}`); logger.info(`Deleted site resource ${siteResourceId}`);

View File

@@ -431,9 +431,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 ||
@@ -556,11 +553,6 @@ export async function updateSiteResource(
})) }))
); );
} }
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
} else { } else {
// Update the site resource // Update the site resource
const sshPamSet = const sshPamSet =
@@ -690,7 +682,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,
@@ -700,7 +709,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, {

View File

@@ -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())

View File

@@ -6,7 +6,7 @@ import logger from "@server/logger";
import { import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert fireHealthCheckUnhealthyAlert
} from "#dynamic/lib/alerts"; } from "@server/lib/alerts";
interface TargetHealthStatus { interface TargetHealthStatus {
status: string; status: string;

View File

@@ -10,7 +10,11 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; import { addTargets } from "../newt/targets";
import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnknownAlert,
fireHealthCheckUnhealthyAlert
} from "@server/lib/alerts";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -169,7 +173,7 @@ export async function updateTarget(
let updatedTarget: any; let updatedTarget: any;
let updatedHc: any; let updatedHc: any;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
[updatedTarget] = await trx [updatedTarget] = await trx
.update(targets) .update(targets)
.set({ .set({
siteId: parsedBody.data.siteId, siteId: parsedBody.data.siteId,
@@ -181,8 +185,12 @@ export async function updateTarget(
path: parsedBody.data.path, path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType, pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority, priority: parsedBody.data.priority,
rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, rewritePath: pathMatchTypeRemoved
rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType ? null
: parsedBody.data.rewritePath,
rewritePathType: pathMatchTypeRemoved
? null
: parsedBody.data.rewritePathType
}) })
.where(eq(targets.targetId, targetId)) .where(eq(targets.targetId, targetId))
.returning(); .returning();
@@ -213,7 +221,8 @@ export async function updateTarget(
// If hcEnabled is being turned on (was false, now true), set to "unhealthy" // If hcEnabled is being turned on (was false, now true), set to "unhealthy"
// so the target must pass a health check before being considered healthy. // so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn = const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; parsedBody.data.hcEnabled === true &&
existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if ( if (
@@ -253,7 +262,10 @@ export async function updateTarget(
.where(eq(targetHealthCheck.targetId, targetId)) .where(eq(targetHealthCheck.targetId, targetId))
.returning(); .returning();
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { if (
updatedHc.hcHealth === "unhealthy" &&
existingHc.hcHealth !== "unhealthy"
) {
logger.debug( logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert` `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert`
); );
@@ -266,7 +278,10 @@ export async function updateTarget(
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
trx trx
); );
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { } else if (
updatedHc.hcHealth === "unknown" &&
existingHc.hcHealth !== "unknown"
) {
logger.debug( logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert` `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert`
); );
@@ -280,7 +295,10 @@ export async function updateTarget(
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
trx trx
); );
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { } else if (
updatedHc.hcHealth === "healthy" &&
existingHc.hcHealth !== "healthy"
) {
logger.debug( logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert` `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
); );

View File

@@ -16,6 +16,9 @@ export default async function migration() {
thc."targetId", thc."targetId",
t."siteId", t."siteId",
s."orgId", s."orgId",
r."name" AS "resourceName",
t."ip",
t."port",
thc."hcEnabled", thc."hcEnabled",
thc."hcPath", thc."hcPath",
thc."hcScheme", thc."hcScheme",
@@ -33,13 +36,17 @@ export default async function migration() {
thc."hcTlsServerName" thc."hcTlsServerName"
FROM "targetHealthCheck" thc FROM "targetHealthCheck" thc
JOIN "targets" t ON thc."targetId" = t."targetId" JOIN "targets" t ON thc."targetId" = t."targetId"
JOIN "sites" s ON t."siteId" = s."siteId"` JOIN "sites" s ON t."siteId" = s."siteId"
JOIN "resources" r ON t."resourceId" = r."resourceId"`
); );
const existingHealthChecks = healthChecksQuery.rows as { const existingHealthChecks = healthChecksQuery.rows as {
targetHealthCheckId: number; targetHealthCheckId: number;
targetId: number; targetId: number;
siteId: number; siteId: number;
orgId: string; orgId: string;
resourceName: string;
ip: string;
port: number;
hcEnabled: boolean; hcEnabled: boolean;
hcPath: string | null; hcPath: string | null;
hcScheme: string | null; hcScheme: string | null;
@@ -385,6 +392,7 @@ export default async function migration() {
"targetId", "targetId",
"orgId", "orgId",
"siteId", "siteId",
"name",
"hcEnabled", "hcEnabled",
"hcPath", "hcPath",
"hcScheme", "hcScheme",
@@ -405,6 +413,7 @@ export default async function migration() {
${hc.targetId}, ${hc.targetId},
${hc.orgId}, ${hc.orgId},
${hc.siteId}, ${hc.siteId},
${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`},
${hc.hcEnabled}, ${hc.hcEnabled},
${hc.hcPath}, ${hc.hcPath},
${hc.hcScheme}, ${hc.hcScheme},

View File

@@ -22,6 +22,9 @@ export default async function migration() {
thc."targetId", thc."targetId",
t."siteId", t."siteId",
s."orgId", s."orgId",
r."name" AS "resourceName",
t."ip",
t."port",
thc."hcEnabled", thc."hcEnabled",
thc."hcPath", thc."hcPath",
thc."hcScheme", thc."hcScheme",
@@ -39,13 +42,17 @@ export default async function migration() {
thc."hcTlsServerName" thc."hcTlsServerName"
FROM 'targetHealthCheck' thc FROM 'targetHealthCheck' thc
JOIN 'targets' t ON thc."targetId" = t."targetId" JOIN 'targets' t ON thc."targetId" = t."targetId"
JOIN 'sites' s ON t."siteId" = s."siteId"` JOIN 'sites' s ON t."siteId" = s."siteId"
JOIN 'resources' r ON t."resourceId" = r."resourceId"`
) )
.all() as { .all() as {
targetHealthCheckId: number; targetHealthCheckId: number;
targetId: number; targetId: number;
siteId: number; siteId: number;
orgId: string; orgId: string;
resourceName: string;
ip: string;
port: number;
hcEnabled: number; hcEnabled: number;
hcPath: string | null; hcPath: string | null;
hcScheme: string | null; hcScheme: string | null;
@@ -392,6 +399,7 @@ export default async function migration() {
"targetId", "targetId",
"orgId", "orgId",
"siteId", "siteId",
"name",
"hcEnabled", "hcEnabled",
"hcPath", "hcPath",
"hcScheme", "hcScheme",
@@ -407,7 +415,7 @@ export default async function migration() {
"hcStatus", "hcStatus",
"hcHealth", "hcHealth",
"hcTlsServerName" "hcTlsServerName"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
const insertAll = db.transaction(() => { const insertAll = db.transaction(() => {
@@ -417,6 +425,7 @@ export default async function migration() {
hc.targetId, hc.targetId,
hc.orgId, hc.orgId,
hc.siteId, hc.siteId,
`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`,
hc.hcEnabled, hc.hcEnabled,
hc.hcPath, hc.hcPath,
hc.hcScheme, hc.hcScheme,

View File

@@ -45,6 +45,7 @@ export default async function RemoteExitNodesPage(
type: node.type, type: node.type,
dateCreated: node.dateCreated, dateCreated: node.dateCreated,
version: node.version || undefined, version: node.version || undefined,
updateAvailable: node.updateAvailable,
orgId: params.orgId orgId: params.orgId
}; };
} }

View File

@@ -160,6 +160,18 @@ export default async function Page(props: {
redirect={redirectUrl} redirect={redirectUrl}
forceLogin={forceLogin} forceLogin={forceLogin}
defaultUser={defaultUser} defaultUser={defaultUser}
orgSignIn={
!isInvite &&
(build === "saas" ||
env.app.identityProviderMode === "org")
? {
href: `/auth/org${buildQueryString(searchParams)}`,
linkText: t("orgAuthSignInToOrg"),
descriptionText:
t("needToSignInToOrg")
}
: undefined
}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -195,7 +207,8 @@ export default async function Page(props: {
</p> </p>
)} )}
{!isInvite && {!useSmartLogin &&
!isInvite &&
(build === "saas" || env.app.identityProviderMode === "org") ? ( (build === "saas" || env.app.identityProviderMode === "org") ? (
<OrgSignInLink <OrgSignInLink
href={`/auth/org${buildQueryString(searchParams)}`} href={`/auth/org${buildQueryString(searchParams)}`}

View File

@@ -145,21 +145,19 @@ const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => {
}; };
const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// return (
// <div className={cn("px-4 md:px-0 mb-4", className)} {...props}>
// {children}
// </div>
// );
return ( return (
<div <div
className={cn( className={cn(
"min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overflow-x-hidden px-0", "relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
className className
)} )}
{...props} {...props}
> >
{children} <div className="space-y-4">{children}</div>
<div
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
aria-hidden
/>
</div> </div>
); );
}; };
@@ -172,7 +170,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaFooter <CredenzaFooter
className={cn( className={cn(
"mt-8 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:mt-0 md:-mb-4 md:gap-0", "-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:-mb-4 md:gap-0",
className className
)} )}
{...props} {...props}

View File

@@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup";
export type RemoteExitNodeRow = { export type RemoteExitNodeRow = {
id: string; id: string;
@@ -33,6 +34,7 @@ export type RemoteExitNodeRow = {
online: boolean; online: boolean;
dateCreated: string; dateCreated: string;
version?: string; version?: string;
updateAvailable?: boolean;
}; };
type ExitNodesTableProps = { type ExitNodesTableProps = {
@@ -233,13 +235,18 @@ export default function ExitNodesTable({
const originalRow = row.original; const originalRow = row.original;
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{originalRow.version && originalRow.version ? ( {originalRow.version ? (
<Badge variant="secondary"> <Badge variant="secondary">
{"v" + originalRow.version} {"v" + originalRow.version}
</Badge> </Badge>
) : ( ) : (
"-" "-"
)} )}
{originalRow.updateAvailable && (
<InfoPopup
info={t("pangolinNodeUpdateAvailableInfo")}
/>
)}
</div> </div>
); );
} }

View File

@@ -5,11 +5,15 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import { Building2 } from "lucide-react";
type OrgSignInLinkProps = { type OrgSignInLinkProps = {
href: string; href: string;
linkText: string; linkText: string;
descriptionText: string; descriptionText: string;
primaryActionVariant?: "link" | "button";
className?: string;
}; };
const STORAGE_KEY_CLICKED = "orgSignInLinkClicked"; const STORAGE_KEY_CLICKED = "orgSignInLinkClicked";
@@ -18,7 +22,9 @@ const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged";
export default function OrgSignInLink({ export default function OrgSignInLink({
href, href,
linkText, linkText,
descriptionText descriptionText,
primaryActionVariant = "link",
className
}: OrgSignInLinkProps) { }: OrgSignInLinkProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@@ -93,14 +99,32 @@ export default function OrgSignInLink({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<div className="text-sm text-center text-muted-foreground mt-8 flex flex-col items-center"> <div
<span>{descriptionText}</span> className={cn(
<button "",
onClick={handleClick} primaryActionVariant === "button" && "gap-3",
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer" className
> )}
{linkText} >
</button> {primaryActionVariant === "button" ? (
<Button
type="button"
variant="outline"
className="w-full inline-flex items-center gap-2"
onClick={handleClick}
>
<Building2 className="size-4 shrink-0" aria-hidden />
<span>{linkText}</span>
</Button>
) : (
<button
type="button"
onClick={handleClick}
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer"
>
{linkText}
</button>
)}
</div> </div>
</> </>
); );

View File

@@ -81,10 +81,10 @@ export default function ProductUpdates({
const showNewVersionPopup = Boolean( const showNewVersionPopup = Boolean(
latestVersion && latestVersion &&
valid(latestVersion) && valid(latestVersion) &&
valid(currentVersion) && valid(currentVersion) &&
ignoredVersionUpdate !== latestVersion && ignoredVersionUpdate !== latestVersion &&
gt(latestVersion, currentVersion) gt(latestVersion, currentVersion)
); );
const filteredUpdates = data.updates.filter( const filteredUpdates = data.updates.filter(
@@ -103,40 +103,51 @@ export default function ProductUpdates({
)} )}
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{filteredUpdates.length > 1 && ( {filteredUpdates.length > 0 && (
<small <div className="mt-3 flex flex-col gap-2">
className={cn( {filteredUpdates.length > 1 && (
"text-xs text-muted-foreground flex items-center gap-1 mt-2", <small
showMoreUpdatesText className={cn(
? "animate-in fade-in duration-300" "text-xs text-muted-foreground flex items-center gap-1",
: "opacity-0" showMoreUpdatesText
? "animate-in fade-in duration-300"
: "opacity-0"
)}
>
<BellIcon className="flex-none size-3" />
<span>
{showNewVersionPopup
? t("productUpdateMoreInfo", {
noOfUpdates:
filteredUpdates.length
})
: t("productUpdateInfo", {
noOfUpdates:
filteredUpdates.length
})}
</span>
</small>
)} )}
> <ProductUpdatesListPopup
<BellIcon className="flex-none size-3" /> updates={filteredUpdates}
<span> show={filteredUpdates.length > 0}
{showNewVersionPopup onDimissAll={() =>
? t("productUpdateMoreInfo", { setProductUpdatesRead([
noOfUpdates: filteredUpdates.length ...productUpdatesRead,
}) ...filteredUpdates.map(
: t("productUpdateInfo", { (update) => update.id
noOfUpdates: filteredUpdates.length )
})} ])
</span> }
</small> onDimiss={(id) =>
setProductUpdatesRead([
...productUpdatesRead,
id
])
}
/>
</div>
)} )}
<ProductUpdatesListPopup
updates={filteredUpdates}
show={filteredUpdates.length > 0}
onDimissAll={() =>
setProductUpdatesRead([
...productUpdatesRead,
...filteredUpdates.map((update) => update.id)
])
}
onDimiss={(id) =>
setProductUpdatesRead([...productUpdatesRead, id])
}
/>
</div> </div>
<NewVersionAvailable <NewVersionAvailable

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { useSiteContext } from "@app/hooks/useSiteContext"; import { useSiteContext } from "@app/hooks/useSiteContext";
import { import {
InfoSection, InfoSection,
@@ -9,77 +9,137 @@ import {
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type SiteInfoCardProps = {}; type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) { function formatPublicEndpoint(endpoint: string) {
const { site, updateSite } = useSiteContext(); return endpoint.includes(":")
const t = useTranslations(); ? endpoint.substring(0, endpoint.lastIndexOf(":"))
const { env } = useEnvContext(); : endpoint;
}
const getConnectionTypeString = (type: string) => { export default function SiteInfoCard({}: SiteInfoCardProps) {
if (type === "newt") { const { site } = useSiteContext();
return "Newt"; const t = useTranslations();
} else if (type === "wireguard") {
return "WireGuard"; const identifierSection = (
} else if (type === "local") { <InfoSection>
return t("local"); <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
} else { <InfoSectionContent>{site.niceId}</InfoSectionContent>
return t("unknown"); </InfoSection>
} );
};
const statusSection = (
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
);
const endpointSection = site.endpoint ? (
<InfoSection>
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicEndpoint(site.endpoint)}
</InfoSectionContent>
</InfoSection>
) : null;
if (site.type === "newt") {
return (
<Alert>
<AlertDescription>
<InfoSections cols={site.endpoint ? 5 : 4}>
{identifierSection}
{statusSection}
<InfoSection>
<InfoSectionTitle>
{t("connectionType")}
</InfoSectionTitle>
<InfoSectionContent>Newt</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("newtVersion")}
</InfoSectionTitle>
<InfoSectionContent>
{site.newtVersion
? `v${site.newtVersion}`
: "-"}
</InfoSectionContent>
</InfoSection>
{endpointSection}
</InfoSections>
</AlertDescription>
</Alert>
);
}
if (site.type === "wireguard") {
return (
<Alert>
<AlertDescription>
<InfoSections cols={site.endpoint ? 4 : 3}>
{identifierSection}
{statusSection}
<InfoSection>
<InfoSectionTitle>
{t("connectionType")}
</InfoSectionTitle>
<InfoSectionContent>WireGuard</InfoSectionContent>
</InfoSection>
{endpointSection}
</InfoSections>
</AlertDescription>
</Alert>
);
}
if (site.type === "local") {
return (
<Alert>
<AlertDescription>
<InfoSections cols={site.endpoint ? 3 : 2}>
{identifierSection}
<InfoSection>
<InfoSectionTitle>
{t("connectionType")}
</InfoSectionTitle>
<InfoSectionContent>
{t("local")}
</InfoSectionContent>
</InfoSection>
{endpointSection}
</InfoSections>
</AlertDescription>
</Alert>
);
}
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
<InfoSections cols={site.endpoint ? 4 : 3}> <InfoSections cols={site.endpoint ? 3 : 2}>
<InfoSection> {identifierSection}
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>{site.niceId}</InfoSectionContent>
</InfoSection>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
)}
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("connectionType")} {t("connectionType")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>{t("unknown")}</InfoSectionContent>
{getConnectionTypeString(site.type)}
</InfoSectionContent>
</InfoSection> </InfoSection>
{site.endpoint && ( {endpointSection}
<InfoSection>
<InfoSectionTitle>
{t("publicIpEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
{site.endpoint.includes(":")
? site.endpoint.substring(0, site.endpoint.lastIndexOf(":"))
: site.endpoint}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections> </InfoSections>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View File

@@ -15,15 +15,18 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useUserLookup } from "@app/hooks/useUserLookup"; import { useUserLookup } from "@app/hooks/useUserLookup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { LookupUserResponse } from "@server/routers/auth/lookupUser"; import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import LoginPasswordForm from "@app/components/LoginPasswordForm"; import LoginPasswordForm from "@app/components/LoginPasswordForm";
import LoginOrgSelector from "@app/components/LoginOrgSelector"; import LoginOrgSelector from "@app/components/LoginOrgSelector";
import UserProfileCard from "@app/components/UserProfileCard"; import UserProfileCard from "@app/components/UserProfileCard";
import { ArrowLeft } from "lucide-react";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink";
const identifierSchema = z.object({ const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required") identifier: z.string().min(1, "Username or email is required")
@@ -39,10 +42,17 @@ const isValidEmail = (str: string): boolean => {
} }
}; };
type OrgSignInConfig = {
href: string;
linkText: string;
descriptionText: string;
};
type SmartLoginFormProps = { type SmartLoginFormProps = {
redirect?: string; redirect?: string;
forceLogin?: boolean; forceLogin?: boolean;
defaultUser?: string; defaultUser?: string;
orgSignIn?: OrgSignInConfig;
}; };
type ViewState = type ViewState =
@@ -58,12 +68,31 @@ type ViewState =
lookupResult: LookupUserResponse; lookupResult: LookupUserResponse;
}; };
function buildResetPasswordHref(
dashboardUrl: string,
identifier: string,
redirectParam?: string
) {
const trimmed = identifier.trim();
const params = new URLSearchParams();
if (isValidEmail(trimmed)) {
params.set("email", trimmed);
}
if (redirectParam) {
params.set("redirect", redirectParam);
}
const qs = params.toString();
return `${dashboardUrl}/auth/reset-password${qs ? `?${qs}` : ""}`;
}
export default function SmartLoginForm({ export default function SmartLoginForm({
redirect, redirect,
forceLogin, forceLogin,
defaultUser defaultUser,
orgSignIn
}: SmartLoginFormProps) { }: SmartLoginFormProps) {
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext();
const { lookup, loading, error } = useUserLookup(); const { lookup, loading, error } = useUserLookup();
const t = useTranslations(); const t = useTranslations();
const [viewState, setViewState] = useState<ViewState>({ type: "initial" }); const [viewState, setViewState] = useState<ViewState>({ type: "initial" });
@@ -78,6 +107,13 @@ export default function SmartLoginForm({
} }
}); });
const watchedIdentifier = form.watch("identifier");
const resetPasswordHref = buildResetPasswordHref(
env.app.dashboardUrl,
watchedIdentifier,
redirect
);
const hasAutoLookedUp = useRef(false); const hasAutoLookedUp = useRef(false);
useEffect(() => { useEffect(() => {
if (defaultUser?.trim() && !hasAutoLookedUp.current) { if (defaultUser?.trim() && !hasAutoLookedUp.current) {
@@ -209,6 +245,15 @@ export default function SmartLoginForm({
)} )}
/> />
<div className="text-center">
<Link
href={resetPasswordHref}
className="text-sm text-muted-foreground"
>
{t("passwordForgot")}
</Link>
</div>
{(error || securityKeyError) && ( {(error || securityKeyError) && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription> <AlertDescription>
@@ -219,7 +264,7 @@ export default function SmartLoginForm({
</form> </form>
</Form> </Form>
<div className="space-y-2"> <div className="space-y-4">
<Button <Button
type="submit" type="submit"
form="form" form="form"
@@ -236,6 +281,28 @@ export default function SmartLoginForm({
onError={setSecurityKeyError} onError={setSecurityKeyError}
disabled={loading} disabled={loading}
/> />
{orgSignIn && (
<>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
{t("idpContinue")}
</span>
</div>
</div>
<OrgSignInLink
href={orgSignIn.href}
linkText={orgSignIn.linkText}
descriptionText={orgSignIn.descriptionText}
primaryActionVariant="button"
className="mt-0"
/>
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -12,12 +12,15 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { import {
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -962,6 +965,69 @@ function WebhookActionFields({
/> />
</div> </div>
<WebhookHeadersField index={index} control={control} form={form} /> <WebhookHeadersField index={index} control={control} form={form} />
{/* Body Template */}
<div className="space-y-3">
<div>
<label className="font-medium text-sm block">
{t("httpDestBodyTemplateTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestBodyTemplateDescription")}
</p>
</div>
<FormField
control={control}
name={`actions.${index}.useBodyTemplate`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-3">
<FormControl>
<Switch
id={`body-template-${index}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<Label
htmlFor={`body-template-${index}`}
className="cursor-pointer"
>
{t("httpDestEnableBodyTemplate")}
</Label>
</div>
</FormItem>
)}
/>
{useWatch({
control,
name: `actions.${index}.useBodyTemplate`
}) && (
<FormField
control={control}
name={`actions.${index}.bodyTemplate`}
render={({ field }) => (
<FormItem>
<FormLabel>
{t("httpDestBodyTemplateLabel")}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={
'{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "status": "{{status}}",\n "data": {{data}}\n}'
}
className="font-mono text-xs min-h-45 resize-y"
/>
</FormControl>
<FormDescription>
{t("httpDestBodyTemplateHint")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div> </div>
); );
} }

View File

@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
customHeaderName: customHeaderName:
"", "",
customHeaderValue: customHeaderValue:
"" "",
useBodyTemplate: false,
bodyTemplate: ""
}); });
} }
}} }}

View File

@@ -113,10 +113,10 @@ export function ResourceTargetAddressItem({
? selectedSite?.name ? selectedSite?.name
: t("siteSelect")} : t("siteSelect")}
</span> </span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-45"> <PopoverContent className="p-0">
<SitesSelector <SitesSelector
orgId={orgId} orgId={orgId}
selectedSite={selectedSite} selectedSite={selectedSite}
@@ -225,7 +225,6 @@ export function ResourceTargetAddressItem({
} }
}} }}
/> />
</div> </div>
</div> </div>
); );

View File

@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
basicCredentials: string; basicCredentials: string;
customHeaderName: string; customHeaderName: string;
customHeaderValue: string; customHeaderValue: string;
useBodyTemplate: boolean;
bodyTemplate: string;
}; };
export type AlertRuleFormValues = { export type AlertRuleFormValues = {
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
customHeaderValue?: string; customHeaderValue?: string;
headers?: { key: string; value: string }[]; headers?: { key: string; value: string }[];
method?: string; method?: string;
useBodyTemplate?: boolean;
bodyTemplate?: string;
} | null; } | null;
}[]; }[];
}; };
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
bearerToken: z.string(), bearerToken: z.string(),
basicCredentials: z.string(), basicCredentials: z.string(),
customHeaderName: z.string(), customHeaderName: z.string(),
customHeaderValue: z.string() customHeaderValue: z.string(),
useBodyTemplate: z.boolean().default(false),
bodyTemplate: z.string().default("")
}) })
]) ])
) )
@@ -415,7 +421,9 @@ export function apiResponseToFormValues(
bearerToken: cfg?.bearerToken ?? "", bearerToken: cfg?.bearerToken ?? "",
basicCredentials: cfg?.basicCredentials ?? "", basicCredentials: cfg?.basicCredentials ?? "",
customHeaderName: cfg?.customHeaderName ?? "", customHeaderName: cfg?.customHeaderName ?? "",
customHeaderValue: cfg?.customHeaderValue ?? "" customHeaderValue: cfg?.customHeaderValue ?? "",
useBodyTemplate: cfg?.useBodyTemplate ?? false,
bodyTemplate: cfg?.bodyTemplate ?? ""
}); });
} }
@@ -479,7 +487,11 @@ export function formValuesToApiPayload(
customHeaderName: action.customHeaderName || undefined, customHeaderName: action.customHeaderName || undefined,
customHeaderValue: action.customHeaderValue || undefined, customHeaderValue: action.customHeaderValue || undefined,
headers: action.headers.filter((h) => h.key.trim()), headers: action.headers.filter((h) => h.key.trim()),
method: action.method method: action.method,
useBodyTemplate: action.useBodyTemplate || undefined,
bodyTemplate: action.useBodyTemplate
? action.bodyTemplate || undefined
: undefined
}) })
}); });
} }