Compare commits

..

57 Commits

Author SHA1 Message Date
dependabot[bot]
efb5322d7f Bump postcss from 8.5.8 to 8.5.10
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-30 04:34:06 +00: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
Owen Schwartz
79541ec7b8 Merge pull request #2936 from fosrl/dev
1.18.1 patch over
2026-04-29 16:43:06 -07:00
Owen
81197f8a86 Update the database if the wildcard changes 2026-04-29 16:42:10 -07:00
miloschwartz
dcfc7822f4 hide cert in public resources col on oss 2026-04-29 16:03:59 -07:00
Owen Schwartz
269bd9aa0f Merge pull request #2934 from fosrl/dev
1.18.1-s.1
2026-04-29 15:18:28 -07:00
Owen
0a0817b860 Restrict alerting 2026-04-29 15:15:53 -07:00
Owen Schwartz
b7a903ab32 Merge pull request #2933 from fosrl/dev
1.18.1
2026-04-29 15:00:29 -07:00
Owen Schwartz
ab60438aa7 Merge pull request #2917 from fosrl/crowdin_dev
New Crowdin updates
2026-04-29 14:55:53 -07:00
Owen Schwartz
b9f3f90de6 New translations en-us.json (Spanish)
[ci skip]
2026-04-29 14:54:32 -07:00
Owen Schwartz
b53cc397be New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-04-29 14:54:30 -07:00
Owen Schwartz
994fb456c2 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-04-29 14:54:29 -07:00
Owen Schwartz
b36927c7a0 New translations en-us.json (Turkish)
[ci skip]
2026-04-29 14:54:27 -07:00
Owen Schwartz
1c57473b6d New translations en-us.json (Russian)
[ci skip]
2026-04-29 14:54:25 -07:00
Owen Schwartz
c02c3eaa4a New translations en-us.json (Portuguese)
[ci skip]
2026-04-29 14:54:23 -07:00
Owen Schwartz
3c265ee577 New translations en-us.json (Polish)
[ci skip]
2026-04-29 14:54:22 -07:00
Owen Schwartz
98dfd05f06 New translations en-us.json (Dutch)
[ci skip]
2026-04-29 14:54:20 -07:00
Owen Schwartz
faa2e97530 New translations en-us.json (Korean)
[ci skip]
2026-04-29 14:54:18 -07:00
Owen Schwartz
175f10a51d New translations en-us.json (Italian)
[ci skip]
2026-04-29 14:54:16 -07:00
Owen Schwartz
6284930fce New translations en-us.json (German)
[ci skip]
2026-04-29 14:54:15 -07:00
Owen Schwartz
6c93aca444 New translations en-us.json (Czech)
[ci skip]
2026-04-29 14:54:13 -07:00
Owen Schwartz
d83318cbfc New translations en-us.json (Bulgarian)
[ci skip]
2026-04-29 14:54:11 -07:00
Owen Schwartz
143f362a48 New translations en-us.json (French)
[ci skip]
2026-04-29 14:54:09 -07:00
miloschwartz
698cd868a8 show cert status in public reosurces table 2026-04-29 14:47:34 -07:00
Owen
a55842ffff Scrape certs from ALL resolvers 2026-04-29 14:29:15 -07:00
Owen
2ffe254879 Dont include site resources on the cloud 2026-04-29 14:08:42 -07:00
miloschwartz
e173f59d89 visual improvements 2026-04-29 13:44:35 -07:00
miloschwartz
d3870f4920 cert status in priv resources table first pass 2026-04-29 13:05:26 -07:00
miloschwartz
227501d8f8 fix rounded buttons in target input 2026-04-29 12:39:08 -07:00
miloschwartz
a16f805709 fix style for unknown status 2026-04-29 12:36:47 -07:00
miloschwartz
a029b107ae dont show site online status for local sites 2026-04-29 12:35:08 -07:00
miloschwartz
f03389a9a0 fix cert styling 2026-04-29 12:18:52 -07:00
Owen
78fff6bfde Filter to only allow newt sites 2026-04-29 12:18:28 -07:00
Owen
bc585c24fc Calculate actual resource status
Fixes #2930
2026-04-29 12:07:32 -07:00
miloschwartz
0f6c66dc67 use localfont and updated mona sans closes #2924 2026-04-29 11:58:06 -07:00
Owen
6be150bafe Handle possible not null for tcp, udp, and icmp
Fixes #2929
2026-04-29 11:42:18 -07:00
Owen
1eac7741a5 Show the certs elsewhere when required 2026-04-29 11:34:10 -07:00
Owen
b8ca0499af Dont show the cert box oss and dont check license 2026-04-29 11:28:30 -07:00
Owen
b39a2bcfb1 Quiet logs 2026-04-29 11:25:43 -07:00
Owen
d45b727dca Dont show cert status because not saved yet 2026-04-29 11:06:14 -07:00
Owen
5c31d35e28 Handle sans in the acme.json 2026-04-29 10:59:49 -07:00
Owen
8c645315f3 Handle when siteIds is not provided 2026-04-29 10:59:36 -07:00
Milo Schwartz
ab6377e086 Merge pull request #2923 from fosrl/miloschwartz-patch-2
Update README.md
2026-04-28 23:03:31 -07:00
Milo Schwartz
8685cf4208 Update README.md 2026-04-29 02:03:18 -04:00
Owen Schwartz
26fe1259da Merge pull request #2922 from fosrl/dev
1.18.0-s.2
2026-04-28 22:28:35 -07:00
Owen
3bcbeb24f3 Query the right column 2026-04-28 22:27:35 -07:00
Owen
1d0a92c83e Its in the transaction so we wait 2026-04-28 22:22:06 -07:00
Owen
a44100c2bd Handle deleting client and orphaning resources 2026-04-28 22:19:22 -07:00
miloschwartz
2203ebf723 show user idp in devices 2026-04-28 21:27:11 -07:00
Owen Schwartz
70958185bd Merge pull request #2921 from fosrl/dev
1.18.0-s.1
2026-04-28 21:03:36 -07:00
Owen
7e374baee9 Update if the ssl toggle changes 2026-04-28 20:45:20 -07:00
Owen
4cf6ca1d55 Force tcp and udp ports when http mode 2026-04-28 20:27:27 -07:00
Owen Schwartz
8ed9adbfae New translations en-us.json (German)
[ci skip]
2026-04-28 16:21:16 -07:00
71 changed files with 1276 additions and 498 deletions

View File

@@ -414,28 +414,18 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- 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
- name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each image by digest using keyless (OIDC) and key-based signing,
# then verify both the public key signature and the keyless OIDC signature.
- name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
# Signatures are stored in the registry alongside the image.
env:
TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true"
run: |
set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release
IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then
@@ -463,95 +453,47 @@ jobs:
)
fi
# Sign each image variant for both registries
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Wrap the entire tag processing in error handling
(
set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
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}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
) || TAG_FAILED=true
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true
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
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
fi
done
# Report summary
echo ""
echo "=========================================="
echo "Sign and Verify Summary"
echo "Sign Summary"
echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag"
done
echo ""
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
else
echo "✓ All images signed and verified successfully!"
echo "✓ All images signed successfully!"
fi
shell: bash

View File

@@ -41,7 +41,7 @@
</strong>
</p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
## Installation

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Създаване на админ акаунт",
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
"certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
"loading": "Зареждане",
"loadingAnalytics": "Зареждане на анализи",
"restart": "Рестарт",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Vytvořit účet správce",
"setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.",
"certificateStatus": "Certifikát",
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
"loading": "Načítání",
"loadingAnalytics": "Načítání analytiky",
"restart": "Restartovat",
@@ -3167,7 +3168,7 @@
"publicIpEndpoint": "Koncový bod",
"lastTriggeredAt": "Poslední spouštěč",
"reject": "Odmítnout",
"uptimeDaysAgo": "{count} days ago",
"uptimeDaysAgo": "Před {count} dny",
"uptimeToday": "Dnes",
"uptimeNoDataAvailable": "Dostupná žádná data",
"uptimeSuffix": "doba dostupnosti",

View File

@@ -1432,7 +1432,7 @@
"alertingTriggerHcToggle": "Gesundheits-Check-Status ändern",
"alertingTriggerResourceHealthy": "Ressource gesund",
"alertingTriggerResourceUnhealthy": "Ressource ungesund",
"alertingTriggerResourceDegraded": "Resource degraded",
"alertingTriggerResourceDegraded": "Ressource verschlechtert",
"alertingSearchHealthChecks": "Gesundheits-Checks suchen…",
"alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.",
"alertingTriggerResourceToggle": "Ressourcenstatus ändern",
@@ -1597,6 +1597,7 @@
"createAdminAccount": "Admin-Konto erstellen",
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
"certificateStatus": "Zertifikat",
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
"loading": "Laden",
"loadingAnalytics": "Analytik wird geladen",
"restart": "Neustart",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Crear cuenta de administrador",
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
"certificateStatus": "Certificado",
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
"loading": "Cargando",
"loadingAnalytics": "Cargando analíticas",
"restart": "Reiniciar",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Créer un compte administrateur",
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
"certificateStatus": "Certificat",
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
"loading": "Chargement",
"loadingAnalytics": "Chargement de l'analyse",
"restart": "Redémarrer",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Crea Account Admin",
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
"certificateStatus": "Certificato",
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
"loading": "Caricamento",
"loadingAnalytics": "Caricamento Delle Analisi",
"restart": "Riavvia",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "관리자 계정 생성",
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
"certificateStatus": "인증서",
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
"loading": "로딩 중",
"loadingAnalytics": "분석 로딩 중",
"restart": "재시작",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Opprett administratorkonto",
"setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.",
"certificateStatus": "Sertifikat",
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
"loading": "Laster inn",
"loadingAnalytics": "Laster inn analyser",
"restart": "Start på nytt",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Maak een beheeraccount aan",
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
"certificateStatus": "Certificaat",
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
"loading": "Bezig met laden",
"loadingAnalytics": "Laden van Analytics",
"restart": "Herstarten",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Utwórz konto administratora",
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
"certificateStatus": "Certyfikat",
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
"loading": "Ładowanie",
"loadingAnalytics": "Ładowanie Analityki",
"restart": "Uruchom ponownie",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Criar Conta de Administrador",
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
"certificateStatus": "Certificado",
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
"loading": "Carregando",
"loadingAnalytics": "Carregando Analytics",
"restart": "Reiniciar",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Создать учётную запись администратора",
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
"certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
"loading": "Загрузка",
"loadingAnalytics": "Загрузка аналитики",
"restart": "Перезагрузка",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Yönetici Hesabı Oluştur",
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
"certificateStatus": "Sertifika",
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
"loading": "Yükleniyor",
"loadingAnalytics": "Analiz Yükleniyor",
"restart": "Yeniden Başlat",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"certificateStatus": "证书",
"certificateStatusAutoRefreshHint": "状态自动刷新。",
"loading": "加载中",
"loadingAnalytics": "加载分析",
"restart": "重启",

67
package-lock.json generated
View File

@@ -141,7 +141,7 @@
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"postcss": "8.5.8",
"postcss": "8.5.10",
"prettier": "3.8.1",
"react-email": "5.2.10",
"tailwindcss": "4.2.2",
@@ -1058,7 +1058,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -2354,7 +2353,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2377,7 +2375,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2400,7 +2397,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2417,7 +2413,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2434,7 +2429,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2451,7 +2445,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2468,7 +2461,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2485,7 +2477,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2502,7 +2493,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2519,7 +2509,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2536,7 +2525,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2553,7 +2541,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2576,7 +2563,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2599,7 +2585,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2622,7 +2607,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2645,7 +2629,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2668,7 +2651,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2691,7 +2673,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2714,7 +2695,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -2734,7 +2714,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2754,7 +2733,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2774,7 +2752,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3034,7 +3011,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -6981,7 +6957,6 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -8442,7 +8417,6 @@
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -8558,7 +8532,6 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -8906,7 +8879,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -9002,7 +8974,6 @@
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -9030,7 +9001,6 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -9056,7 +9026,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9067,7 +9036,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -9154,7 +9122,8 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -9228,7 +9197,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -9702,7 +9670,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10152,7 +10119,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -10224,7 +10190,6 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -10353,7 +10318,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -11260,7 +11224,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -11701,6 +11664,7 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -12335,7 +12299,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -12421,7 +12384,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -12558,7 +12520,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -12952,7 +12913,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -15370,6 +15330,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -15380,6 +15341,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -15468,7 +15430,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
@@ -16428,7 +16389,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -16580,9 +16540,9 @@
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@@ -16936,7 +16896,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16968,7 +16927,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -17261,7 +17219,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -18723,8 +18680,7 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -19199,7 +19155,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19627,7 +19582,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -19834,7 +19788,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -164,7 +164,7 @@
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"postcss": "8.5.8",
"postcss": "8.5.10",
"prettier": "3.8.1",
"react-email": "5.2.10",
"tailwindcss": "4.2.2",

View File

@@ -122,8 +122,6 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode",

View File

@@ -566,6 +566,17 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
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 Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -604,3 +615,12 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
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,
users
} 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", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -569,6 +572,19 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", {
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 Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -601,3 +617,10 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
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 tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
};

View File

@@ -125,12 +125,12 @@ export async function updateClientResources(
const existingSiteIds = existingResource?.networkId
? await trx
.select({ siteId: sites.siteId })
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId))
: [];
let allSites: { siteId: number }[] = [];
const allSites: { siteId: number }[] = [];
if (resourceData.site) {
let siteSingle;
const resourceSiteId = resourceData.site;
@@ -215,9 +215,17 @@ export async function updateClientResources(
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"],
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
@@ -397,9 +405,17 @@ export async function updateClientResources(
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
aliasAddress: aliasAddress,
disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"],
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null

View File

@@ -250,10 +250,31 @@ function extractFirstCert(pemBundle: string): string | null {
return match ? match[0] : null;
}
async function syncAcmeCerts(
acmeJsonPath: string,
resolver: string
): Promise<void> {
/**
* Determine whether an ACME cert entry represents a wildcard cert by checking
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
* Traefik) store the bare apex in `main` and only put the wildcard form in
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
*/
function detectWildcard(
main: string,
sans: string[] | undefined
): { wildcard: boolean; wildcardSan: string | null } {
if (main.startsWith("*.")) {
return { wildcard: true, wildcardSan: null };
}
if (Array.isArray(sans)) {
for (const san of sans) {
if (typeof san !== "string") continue;
if (san === `*.${main}` || san.startsWith("*.")) {
return { wildcard: true, wildcardSan: san };
}
}
}
return { wildcard: false, wildcardSan: null };
}
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
let raw: string;
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
@@ -270,23 +291,41 @@ async function syncAcmeCerts(
return;
}
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
const resolvers = Object.keys(acmeJson || {});
if (resolvers.length === 0) {
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
return;
}
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
const wildcard = domain.startsWith("*.");
// Collect certificates from every resolver. If the same domain appears in
// multiple resolvers, the last one wins (resolvers iterated in object order).
const allCerts: AcmeCert[] = [];
for (const resolver of resolvers) {
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
continue;
}
logger.debug(
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
);
for (const cert of resolverData.Certificates) {
allCerts.push(cert);
}
}
if (!domain) {
for (const cert of allCerts) {
const domain = cert?.domain?.main;
if (!domain || typeof domain !== "string") {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue;
}
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
@@ -294,10 +333,17 @@ async function syncAcmeCerts(
continue;
}
const certPem = Buffer.from(cert.certificate, "base64").toString(
"utf8"
);
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
let certPem: string;
let keyPem: string;
try {
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
);
continue;
}
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
@@ -306,6 +352,39 @@ async function syncAcmeCerts(
continue;
}
// Validate that the decoded data actually parses as a real X.509 cert
// before we touch the database. This prevents importing partially-written
// or corrupted entries from acme.json.
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
// Sanity-check the private key parses too
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
);
continue;
}
// Check if cert already exists in DB
const existing = await db
.select()
@@ -326,10 +405,11 @@ async function syncAcmeCerts(
existing[0].certFile,
config.getRawConfig().server.secret!
);
if (storedCertPem === certPem) {
logger.debug(
`acmeCertSync: cert for ${domain} is unchanged, skipping`
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
// logger.debug(
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
// );
continue;
}
// Cert has changed; capture old values so we can send a correct
@@ -355,18 +435,16 @@ async function syncAcmeCerts(
}
}
// Parse cert expiry from the first cert in the PEM bundle
// Parse cert expiry from the validated X.509 certificate
let expiresAt: number | null = null;
const firstCertPem = extractFirstCert(certPem);
if (firstCertPem) {
try {
const x509 = new crypto.X509Certificate(firstCertPem);
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
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(
@@ -468,20 +546,19 @@ export function initAcmeCertSync(): void {
const acmeJsonPath =
privateConfigData.acme?.acme_json_path ??
"config/letsencrypt/acme.json";
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
logger.debug(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
);
// Run immediately on init, then on the configured interval
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during initial sync: ${err}`);
});
setInterval(() => {
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);

View File

@@ -102,7 +102,6 @@ export const privateConfigSchema = z.object({
.string()
.optional()
.default("config/letsencrypt/acme.json"),
resolver: z.string().optional().default("letsencrypt"),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),

View File

@@ -277,37 +277,37 @@ export async function getTraefikConfig(
});
});
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
mode: "http" | "host" | "cidr";
}[] = [];
if (build == "enterprise") {
// we dont want to do this on the cloud
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
);
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
eq(sites.exitNodeId, exitNodeId),
inArray(sites.type, siteTypes)
)
);
}
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {

View File

@@ -30,8 +30,10 @@ import {
userOrgRoles,
siteProvisioningKeyOrg,
siteProvisioningKeys,
alertRules,
targetHealthCheck
} 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
@@ -318,6 +320,14 @@ async function disableFeature(
await disableSiteProvisioningKeys(orgId);
break;
case TierFeature.AlertingRules:
await disableAlertingRules(orgId);
break;
case TierFeature.StandaloneHealthChecks:
await disableStandaloneHealthChecks(orgId);
break;
default:
logger.warn(
`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> {
const rows = await db
.select({
siteProvisioningKeyId:
siteProvisioningKeyOrg.siteProvisioningKeyId
siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId
})
.from(siteProvisioningKeyOrg)
.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}`);
}
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> {
// Get all IDP IDs for this org through the idpOrg join table
const orgIdps = await db

View File

@@ -174,6 +174,19 @@ export async function handleSubscriptionCreated(
// 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") {
logger.debug(
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`

View File

@@ -165,7 +165,6 @@ authenticated.get(
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyValidLicense,
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),

View File

@@ -67,24 +67,20 @@ if (build == "saas") {
verifyApiKeyIsRoot,
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(
"/idp/:idpId",
verifyApiKeyIsRoot,

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, olms, users } from "@server/db";
import { db, idp, idpOidcConfig, olms, users } from "@server/db";
import { clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -236,6 +236,9 @@ export type GetClientResponse = NonNullable<
lastSeen: number | null;
} | null;
posture: PostureData | null;
userType: string | null;
idpName: string | null;
idpVariant: string | null;
};
registry.registerPath({
@@ -337,6 +340,30 @@ export async function getClient(
: maskPostureDataWithPlaceholder(rawPosture)
: null;
let userType: string | null = null;
let idpName: string | null = null;
let idpVariant: string | null = null;
if (client.clients.userId) {
const [idpRow] = await db
.select({
userType: users.type,
idpName: idp.name,
idpVariant: idpOidcConfig.variant
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(users.userId, client.clients.userId))
.limit(1);
if (idpRow) {
userType = idpRow.userType;
idpName = idpRow.idpName;
idpVariant = idpRow.idpVariant;
}
}
const data: GetClientResponse = {
...client.clients,
name: clientName,
@@ -347,7 +374,10 @@ export async function getClient(
userName: client.user?.name ?? null,
userUsername: client.user?.username ?? null,
fingerprint: fingerprintData,
posture: postureData
posture: postureData,
userType,
idpName,
idpVariant
};
return response<GetClientResponse>(res, {

View File

@@ -3,6 +3,8 @@ import {
clients,
currentFingerprint,
db,
idp,
idpOidcConfig,
olms,
orgs,
roleClients,
@@ -165,6 +167,9 @@ function queryUserDevicesBase() {
userId: clients.userId,
username: users.username,
userEmail: users.email,
userType: users.type,
idpName: idp.name,
idpVariant: idpOidcConfig.variant,
niceId: clients.niceId,
agent: olms.agent,
approvalState: clients.approvalState,
@@ -184,6 +189,8 @@ function queryUserDevicesBase() {
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
}

View File

@@ -152,7 +152,7 @@ export type ResourceWithTargets = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
online?: boolean; // undefined for local sites
}>;
};
@@ -383,12 +383,8 @@ export async function listResources(
.select({ resourceId: targets.resourceId })
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
);
conditions.push(
inArray(resources.resourceId, resourcesWithSite)
);
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite));
}
const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -426,7 +422,8 @@ export async function listResources(
hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online
siteOnline: sites.online,
siteType: sites.type
})
.from(targets)
.where(inArray(targets.resourceId, resourceIdList))
@@ -481,18 +478,19 @@ export async function listResources(
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
online?: boolean;
}
>();
for (const t of raw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: Boolean(t.siteOnline)
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -77,17 +77,20 @@ export async function deleteSite(
.where(eq(siteNetworks.siteId, siteId));
// loop through them
for (const network of await networks) {
const [siteResource] = await trx
.select()
.from(siteResources)
.where(eq(siteResources.networkId, network.networkId));
if (siteResource) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
const updatedSiteResources = await trx
.select()
.from(siteResources)
.where(
inArray(
siteResources.networkId,
networks.map((n) => n.networkId)
)
);
for (const siteResource of updatedSiteResources) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
// get the newt on the site by querying the newt table for siteId

View File

@@ -31,7 +31,9 @@ let staleNewtVersion: string | null = null;
async function getLatestNewtVersion(): Promise<string | null> {
try {
const cachedVersion = await cache.get<string>("cache:latestNewtVersion");
const cachedVersion = await cache.get<string>(
"cache:latestNewtVersion"
);
if (cachedVersion) {
return cachedVersion;
}
@@ -226,7 +228,10 @@ function querySitesBase() {
);
}
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean;
};
@@ -338,7 +343,9 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites")
querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
);
const siteListQuery = baseQuery
@@ -397,9 +404,13 @@ export async function listSites(
);
}
const sitesPayload = sitesWithUpdates.map((site) =>
site.type === "local" ? { ...site, online: undefined } : site
);
return response<ListSitesResponse>(res, {
data: {
sites: sitesWithUpdates,
sites: sitesPayload,
pagination: {
total: totalCount,
pageSize,

View File

@@ -46,7 +46,8 @@ const createSiteResourceSchema = z
mode: z.enum(["host", "cidr", "http"]),
ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()),
siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
@@ -132,6 +133,17 @@ const createSiteResourceSchema = z
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
)
.refine(
(data) => {
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
);
},
{
message: "At least one of siteIds or siteId must be provided"
}
);
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -187,7 +199,8 @@ export async function createSiteResource(
const {
name,
niceId,
siteIds,
siteIds: siteIdsInput = [],
siteId,
mode,
scheme,
// proxyPort,
@@ -208,6 +221,12 @@ export async function createSiteResource(
subdomain
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
@@ -389,9 +408,10 @@ export async function createSiteResource(
enabled,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp,
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString: mode == "http" ? "" : udpPortRangeString,
disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain
@@ -496,7 +516,13 @@ export async function createSiteResource(
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
if (ssl && mode === "http" && domainId && fullDomain && build != "oss") {
if (
ssl &&
mode === "http" &&
domainId &&
fullDomain &&
build != "oss"
) {
await createCertificate(domainId, fullDomain, db);
}

View File

@@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
*/
function aggCol<T>(column: any) {
if (DB_TYPE === "sqlite") {
// json_group_array will include NULLs for left-joined missing rows;
// we filter them out in transformSiteResourceRow keeping arrays aligned.
return sql<T>`json_group_array(${column})`;
}
return sql<T>`array_agg(${column})`;
return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
}
/**
@@ -112,16 +114,36 @@ function transformSiteResourceRow(row: any) {
if (DB_TYPE !== "sqlite") {
return row;
}
const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[];
const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[];
const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[];
const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[];
const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[];
// When a site resource has no associated sites (left join produced no
// matches), the aggregated arrays will contain a single NULL entry. Strip
// those out, keeping the parallel arrays aligned by siteId presence.
const siteIds: number[] = [];
const siteNames: string[] = [];
const siteNiceIds: string[] = [];
const siteAddresses: (string | null)[] = [];
const siteOnlines: boolean[] = [];
for (let i = 0; i < siteIdsRaw.length; i++) {
if (siteIdsRaw[i] == null) continue;
siteIds.push(siteIdsRaw[i] as number);
siteNames.push((siteNamesRaw[i] ?? "") as string);
siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string);
siteAddresses.push(siteAddressesRaw[i] ?? null);
siteOnlines.push(siteOnlinesRaw[i] === 1);
}
return {
...row,
siteNames: JSON.parse(row.siteNames) as string[],
siteNiceIds: JSON.parse(row.siteNiceIds) as string[],
siteIds: JSON.parse(row.siteIds) as number[],
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[],
// SQLite stores booleans as 0/1 integers
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
(v) => v === 1
) as boolean[]
siteNames,
siteNiceIds,
siteIds,
siteAddresses,
siteOnlines
};
}
@@ -158,11 +180,11 @@ function querySiteResourcesBase() {
siteOnlines: aggCol<boolean[]>(sites.online)
})
.from(siteResources)
.innerJoin(
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
}
@@ -215,6 +237,8 @@ export async function listAllSiteResourcesByOrg(
const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
// Keep inner joins here: filtering by a specific site implies the
// resource must have at least one matching site.
const resourcesForSite = db
.select({ id: siteResources.siteResourceId })
.from(siteResources)

View File

@@ -43,7 +43,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
siteIds: z.array(z.int()),
siteIds: z.array(z.int()).optional(),
siteId: z.int().positive().optional(),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z
.string()
@@ -142,6 +143,17 @@ const updateSiteResourceSchema = z
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
)
.refine(
(data) => {
return (
(data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined
);
},
{
message: "At least one of siteIds or siteId must be provided"
}
);
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -196,7 +208,8 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
siteIds, // because it can change
siteIds: siteIdsInput = [], // because it can change
siteId,
niceId,
mode,
scheme,
@@ -217,6 +230,12 @@ export async function updateSiteResource(
subdomain
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
// Check if site resource exists
const [existingSiteResource] = await db
.select()
@@ -440,9 +459,12 @@ export async function updateSiteResource(
destinationPort,
enabled,
alias: alias ? alias.trim() : null,
tcpPortRangeString,
udpPortRangeString,
disableIcmp,
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString:
mode == "http" ? "" : udpPortRangeString,
disableIcmp:
disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain,
@@ -734,6 +756,9 @@ export async function handleMessagingForUpdatedSiteResource(
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
@@ -749,6 +774,7 @@ export async function handleMessagingForUpdatedSiteResource(
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
@@ -765,9 +791,10 @@ export async function handleMessagingForUpdatedSiteResource(
);
}
// Only update targets on newt if destination changed
// Only update targets on newt if these items change
if (
destinationChanged ||
sslChanged || // we need to push a new cert if the ssl changed
portRangesChanged ||
fullDomainChanged || // if the domain changes we need to update the certs and stuff
destinationPortChanged

View File

@@ -545,6 +545,72 @@ export default async function migration() {
throw e;
}
// Recompute resource health by aggregating across the resource's targets'
// target health checks, then update the resources.health column to match.
try {
const resourceTargetHealthQuery = await db.execute(
sql`SELECT
r."resourceId" AS "resourceId",
thc."hcHealth" AS "hcHealth"
FROM "resources" r
LEFT JOIN "targets" t ON t."resourceId" = r."resourceId"
LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"`
);
const resourceTargetHealthRows =
resourceTargetHealthQuery.rows as {
resourceId: number;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{ hasHealthy: boolean; hasUnhealthy: boolean; hasUnknown: boolean }
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
let updatedResourceCount = 0;
for (const [resourceId, flags] of resourceHealthMap.entries()) {
let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown";
if (flags.hasHealthy && flags.hasUnhealthy) {
aggregated = "degraded";
} else if (flags.hasHealthy) {
aggregated = "healthy";
} else if (flags.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
await db.execute(sql`
UPDATE "resources"
SET "health" = ${aggregated}
WHERE "resourceId" = ${resourceId}
`);
updatedResourceCount++;
}
console.log(
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
);
} catch (e) {
console.error(
"Error while recomputing resource health from target health checks:",
e
);
throw e;
}
// Seed statusHistory for all existing health checks
try {
const healthChecksQuery = await db.execute(

View File

@@ -255,7 +255,7 @@ export default async function migration() {
).run();
db.prepare(
`
INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources';
INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", COALESCE("tcpPortRangeString", '*'), COALESCE("udpPortRangeString", '*'), COALESCE("disableIcmp", 0), "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources';
`
).run();
db.prepare(
@@ -509,6 +509,70 @@ export default async function migration() {
`Seeded statusHistory for ${allResources.length} resource(s)`
);
// Recompute resource health by aggregating across the resource's
// targets' target health checks, then update resources.health.
const resourceTargetHealthRows = db
.prepare(
`SELECT
r."resourceId" AS "resourceId",
thc."hcHealth" AS "hcHealth"
FROM 'resources' r
LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId"
LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"`
)
.all() as {
resourceId: number;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{
hasHealthy: boolean;
hasUnhealthy: boolean;
hasUnknown: boolean;
}
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
const updateResourceHealth = db.prepare(
`UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?`
);
const recomputeResourceHealth = db.transaction(() => {
for (const [resourceId, flags] of resourceHealthMap.entries()) {
let aggregated:
| "healthy"
| "unhealthy"
| "degraded"
| "unknown";
if (flags.hasHealthy && flags.hasUnhealthy) {
aggregated = "degraded";
} else if (flags.hasHealthy) {
aggregated = "healthy";
} else if (flags.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
updateResourceHealth.run(aggregated, resourceId);
}
});
recomputeResourceHealth();
console.log(
`Recomputed health for ${resourceHealthMap.size} resource(s) based on target health checks`
);
// Seed statusHistory for all existing health checks
const allHealthChecks = db
.prepare(

View File

@@ -96,6 +96,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
userId: client.userId,
username: client.username,
userEmail: client.userEmail,
userType: client.userType ?? null,
idpName: client.idpName ?? null,
idpVariant: client.idpVariant ?? null,
niceId: client.niceId,
agent: client.agent,
archived: Boolean(client.archived),

View File

@@ -120,6 +120,7 @@ export default async function ProxyResourcesPage(
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,

View File

@@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
import { Inter, Mona_Sans } from "next/font/google";
import localFont from "next/font/local";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -32,12 +32,30 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic";
const inter = Inter({
subsets: ["latin"]
});
const monaSans = Mona_Sans({
subsets: ["latin"]
const monaSans = localFont({
src: [
{
path: "../fonts/mona-sans/MonaSans-Regular.woff2",
weight: "400",
style: "normal"
},
{
path: "../fonts/mona-sans/MonaSans-Medium.woff2",
weight: "500",
style: "normal"
},
{
path: "../fonts/mona-sans/MonaSans-SemiBold.woff2",
weight: "600",
style: "normal"
},
{
path: "../fonts/mona-sans/MonaSans-Bold.woff2",
weight: "700",
style: "normal"
}
],
display: "swap"
});
const fontClassName = monaSans.className;

View File

@@ -399,11 +399,10 @@ function AuthPageSettings({
</div>
)}
{env.flags.usePangolinDns &&
(build === "enterprise" ||
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
{build !== "oss" && (build === "enterprise" ||
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (

View File

@@ -1,43 +1,38 @@
"use client";
import { Button } from "@/components/ui/button";
import { Loader2, RotateCw } from "lucide-react";
import { FileBadge, RotateCw } from "lucide-react";
import { useCertificate } from "@app/hooks/useCertificate";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import { useTranslations } from "next-intl";
type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
export type CertificateStatusContentProps = {
cert: GetCertificateResponse | null;
certLoading: boolean;
certError: string | null;
refreshing: boolean;
refreshCert: () => Promise<void>;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
};
export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
/** Presentation-only certificate row (shared hook state possible via props). */
export function CertificateStatusContent({
cert,
certLoading,
certError,
refreshing,
refreshCert,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
}: CertificateStatusProps) {
onRefresh
}: CertificateStatusContentProps) {
const t = useTranslations();
const { cert, certLoading, certError, refreshing, refreshCert } =
useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
const labelClass =
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-none";
const valueClass = "inline-flex items-center gap-2 text-sm leading-none";
const handleRefresh = async () => {
await refreshCert();
@@ -74,13 +69,13 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
<span className={labelClass}>
{t("certificateStatus")}:
</span>
)}
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Loader2
className="h-3.5 w-3.5 shrink-0 animate-spin"
<span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 animate-pulse text-muted-foreground"
aria-hidden
/>
{t("loading")}
@@ -93,11 +88,17 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
<span className={labelClass}>
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-red-500">{certError}</span>
<span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 text-red-500"
aria-hidden
/>
{certError}
</span>
</div>
);
}
@@ -106,11 +107,15 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
<span className={labelClass}>
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
<span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
{t("none", { defaultValue: "None" })}
</span>
</div>
@@ -123,50 +128,102 @@ export default function CertificateStatus({
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
<span className={labelClass}>{t("certificateStatus")}:</span>
)}
{isPending ? (
{isPending && !disableRestartButton ? (
<Button
variant="ghost"
className={`h-auto p-0 text-sm ${getStatusColor(cert.status)}`}
className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-none inline-flex items-center self-center"
onClick={handleRefresh}
disabled={refreshing || disableRestartButton}
disabled={refreshing}
title={t("restartCertificate", {
defaultValue: "Restart Certificate"
})}
>
<span className="inline-flex items-center gap-1">
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
<span className="inline-flex items-center gap-2 leading-none">
<FileBadge
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
aria-hidden
/>
{cert.status.charAt(0).toUpperCase() +
cert.status.slice(1)}
<RotateCw
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/>
</span>
</Button>
) : (
<span className={`text-sm ${getStatusColor(cert.status)}`}>
<span className="inline-flex items-center gap-1">
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
{shouldShowRefreshButton(cert.status, cert.updatedAt) && (
<Button
size="icon"
variant="ghost"
className="p-0 w-3 h-auto align-middle"
onClick={handleRefresh}
disabled={refreshing || disableRestartButton}
title={t("restartCertificate", {
defaultValue: "Restart Certificate"
})}
>
<RotateCw
className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
)}
</span>
<span className={valueClass}>
<FileBadge
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
aria-hidden
/>
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
{shouldShowRefreshButton(cert.status, cert.updatedAt) &&
!disableRestartButton ? (
<Button
size="icon"
variant="ghost"
className="inline-flex h-auto min-h-0 w-3 shrink-0 items-center justify-center self-center p-0"
onClick={handleRefresh}
disabled={refreshing}
title={t("restartCertificate", {
defaultValue: "Restart Certificate"
})}
>
<RotateCw
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
) : null}
</span>
)}
</div>
);
}
type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
};
export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
}: CertificateStatusProps) {
const hook = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
return (
<CertificateStatusContent
cert={hook.cert}
certLoading={hook.certLoading}
certError={hook.certError}
refreshing={hook.refreshing}
refreshCert={hook.refreshCert}
showLabel={showLabel}
className={className}
onRefresh={onRefresh}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useTranslations } from "next-intl";
@@ -36,7 +37,24 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
{userDisplayName ? t("user") : t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{userDisplayName || client.niceId}
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName || client.niceId}</span>
{userDisplayName &&
(client.userType ?? "internal") !==
"internal" && (
<IdpTypeBadge
type={client.userType ?? "oidc"}
name={
client.idpName?.trim()
? client.idpName
: t("idpNameInternal")
}
variant={
client.idpVariant ?? undefined
}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
<InfoSection>

View File

@@ -37,11 +37,8 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useEffect, useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
@@ -54,6 +51,8 @@ import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -206,7 +205,11 @@ export default function ClientResourcesTable({
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return <span>-</span>;
return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
}
if (siteNames.length === 1) {
@@ -439,13 +442,34 @@ export default function ClientResourcesTable({
);
}
if (resourceRow.mode === "http") {
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;

View File

@@ -485,6 +485,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
onSelectSite={(site) => {
setSelectedSite(site);
}}
filterTypes={["newt"]}
/>
</PopoverContent>
</Popover>

View File

@@ -55,6 +55,7 @@ import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus";
import { build } from "@server/build";
// --- Helpers (shared) ---
@@ -156,7 +157,7 @@ export type InternalResourceData = {
const tagSchema = z.object({ id: z.string(), text: z.string() });
function buildSelectedSitesForResource(
resource: InternalResourceData,
resource: InternalResourceData
): Selectedsite[] {
return resource.siteIds.map((siteId, idx) => ({
name: resource.siteNames[idx] ?? "",
@@ -609,9 +610,7 @@ export function InternalResourceForm({
users: [],
clients: []
});
setSelectedSites(
buildSelectedSitesForResource(resource)
);
setSelectedSites(buildSelectedSitesForResource(resource));
setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString)
);
@@ -800,7 +799,9 @@ export function InternalResourceForm({
);
field.onChange(
sites.map(
(s) =>
(
s
) =>
s.siteId
)
);
@@ -822,15 +823,21 @@ export function InternalResourceForm({
[
{
value: "host",
label: t(modeHostKey)
label: t(
modeHostKey
)
},
{
value: "cidr",
label: t(modeCidrKey)
label: t(
modeCidrKey
)
},
{
value: "http",
label: t(modeHttpKey)
label: t(
modeHttpKey
)
}
];
return (
@@ -839,7 +846,9 @@ export function InternalResourceForm({
{t(modeLabelKey)}
</FormLabel>
<OptionSelect<InternalResourceMode>
options={modeOptions}
options={
modeOptions
}
value={field.value}
onChange={
field.onChange
@@ -899,7 +908,9 @@ export function InternalResourceForm({
field.value ??
"http"
}
disabled={httpSectionDisabled}
disabled={
httpSectionDisabled
}
>
<FormControl>
<SelectTrigger className="w-full">
@@ -940,7 +951,10 @@ export function InternalResourceForm({
<Input
{...field}
className="w-full"
disabled={isHttpMode && httpSectionDisabled}
disabled={
isHttpMode &&
httpSectionDisabled
}
/>
</FormControl>
<FormMessage />
@@ -996,7 +1010,9 @@ export function InternalResourceForm({
field.value ??
""
}
disabled={httpSectionDisabled}
disabled={
httpSectionDisabled
}
onChange={(e) => {
const raw =
e.target
@@ -1031,7 +1047,9 @@ export function InternalResourceForm({
</div>
{isHttpMode && (
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
<PaidFeaturesAlert
tiers={tierMatrix.httpPrivateResources}
/>
)}
{isHttpMode ? (
@@ -1044,55 +1062,61 @@ export function InternalResourceForm({
{t(httpConfigurationDescriptionKey)}
</div>
</div>
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
<DomainPicker
key={
variant === "edit" && siteResourceId
? `http-domain-${siteResourceId}`
: "http-domain-create"
<div
className={
httpSectionDisabled
? "pointer-events-none opacity-50"
: undefined
}
orgId={orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
>
<DomainPicker
key={
variant === "edit" && siteResourceId
? `http-domain-${siteResourceId}`
: "http-domain-create"
}
orgId={orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
null
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
null
res.domainId
);
form.setValue(
"httpConfigFullDomain",
null
res.fullDomain
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
}}
/>
</div>
<div className="flex items-start justify-between gap-4">
<FormField
@@ -1103,7 +1127,9 @@ export function InternalResourceForm({
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
label={t(
enableSslLabelKey
)}
description={t(
enableSslDescriptionKey
)}
@@ -1111,7 +1137,9 @@ export function InternalResourceForm({
onCheckedChange={
field.onChange
}
disabled={httpSectionDisabled}
disabled={
httpSectionDisabled
}
/>
</FormControl>
</FormItem>
@@ -1120,15 +1148,22 @@ export function InternalResourceForm({
{variant === "edit" &&
resource?.domainId &&
httpConfigFullDomain &&
httpConfigDomainId ===
resource.domainId &&
httpConfigFullDomain ===
resource.fullDomain &&
build != "oss" &&
form.watch("ssl") && (
<div className="flex items-center gap-1 pt-1">
<span className="text-sm font-medium text-muted-foreground">
<div className="flex items-center gap-2 pt-1">
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={httpConfigFullDomain}
fullDomain={
httpConfigFullDomain
}
autoFetch={true}
showLabel={false}
polling={true}

View File

@@ -64,6 +64,8 @@ import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table";
import UptimeMiniBar from "./UptimeMiniBar";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type TargetHealth = {
targetId: number;
@@ -86,6 +88,8 @@ export type ResourceRow = {
proxyPort: number | null;
enabled: boolean;
domainId?: string;
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
fullDomain?: string | null;
ssl: boolean;
targetHost?: string;
targetPort?: number;
@@ -266,7 +270,7 @@ export default function ProxyResourcesTable({
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "healthy" &&
t("resourcesTableHealthy")}
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "unhealthy" &&
@@ -488,7 +492,12 @@ export default function ProxyResourcesTable({
),
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} healthStatus={resourceRow.health} />;
return (
<TargetStatusCell
targets={resourceRow.targets}
healthStatus={resourceRow.health}
/>
);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.health;
@@ -520,24 +529,52 @@ export default function ProxyResourcesTable({
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
if (!resourceRow.http) {
return (
<div className="flex items-center gap-2 min-w-0">
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
) : !resourceRow.domainId ? (
</div>
);
}
if (!resourceRow.domainId) {
return (
<div className="flex items-center gap-2 min-w-0">
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
</div>
);
}
const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl &&
certHostname != null &&
certHostname !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{showHttpsCertIndicator ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={certHostname}
/>
) : null}
<div className="">
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { CertificateStatusContent } from "@app/components/CertificateStatus";
import {
Popover,
PopoverAnchor,
PopoverContent
} from "@app/components/ui/popover";
import { useCertificate } from "@app/hooks/useCertificate";
import { cn } from "@app/lib/cn";
import { FileBadge } from "lucide-react";
import { useTranslations } from "next-intl";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode
} from "react";
type ResourceAccessCertIndicatorProps = {
orgId: string;
domainId: string;
fullDomain: string;
};
function getStatusColor(status: string) {
switch (status) {
case "valid":
return "text-green-500";
case "pending":
case "requested":
return "text-yellow-500";
case "expired":
case "failed":
return "text-red-500";
default:
return "text-muted-foreground";
}
}
/** Compact cert icon + hover popover with full certificate status (shared by proxy and client resource tables). */
export function ResourceAccessCertIndicator({
orgId,
domainId,
fullDomain
}: ResourceAccessCertIndicatorProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const certificate = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch: true,
polling: open,
pollingInterval: 5000
});
const { cert, certLoading, certError, refreshing, fetchCert } = certificate;
useEffect(() => {
if (!open) return;
void fetchCert(false);
}, [open, fetchCert]);
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current != null) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = setTimeout(() => setOpen(false), 280);
}, [clearCloseTimer]);
const handleEnterOpen = useCallback(() => {
clearCloseTimer();
setOpen(true);
}, [clearCloseTimer]);
useEffect(() => {
return () => clearCloseTimer();
}, [clearCloseTimer]);
let triggerBody: ReactNode;
if (certLoading) {
triggerBody = (
<div
className={cn(
"h-4 w-4 shrink-0 rounded-[2px] animate-pulse",
"bg-neutral-200 dark:bg-neutral-700"
)}
aria-busy="true"
aria-label={t("loading")}
/>
);
} else if (refreshing) {
triggerBody = (
<FileBadge
className={cn(
"h-4 w-4 shrink-0 animate-spin",
cert ? getStatusColor(cert.status) : "text-muted-foreground"
)}
aria-hidden
/>
);
} else if (certError) {
triggerBody = (
<FileBadge className="h-4 w-4 shrink-0 text-red-500" aria-hidden />
);
} else if (cert) {
triggerBody = (
<FileBadge
className={cn("h-4 w-4", getStatusColor(cert.status))}
aria-hidden
/>
);
} else {
triggerBody = (
<FileBadge
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center shrink-0 rounded-[2px] outline-offset-2",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring",
certError && "text-red-500"
)}
onMouseEnter={handleEnterOpen}
onMouseLeave={scheduleClose}
onClick={(e) => {
e.preventDefault();
setOpen((v) => !v);
}}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={t("certificateStatus")}
>
{triggerBody}
</button>
</PopoverAnchor>
<PopoverContent
className="w-72 p-4"
align="start"
side="bottom"
sideOffset={6}
onMouseEnter={clearCloseTimer}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-3">
<CertificateStatusContent
cert={certificate.cert}
certLoading={certificate.certLoading}
certError={certificate.certError}
refreshing={certificate.refreshing}
refreshCert={certificate.refreshCert}
showLabel
/>
<p className="text-sm text-muted-foreground">
{t("certificateStatusAutoRefreshHint")}
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,7 +1,15 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ShieldCheck, ShieldOff, Eye, EyeOff, CheckCircle2, XCircle, Clock } from "lucide-react";
import {
ShieldCheck,
ShieldOff,
Eye,
EyeOff,
CheckCircle2,
XCircle,
Clock
} from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
@@ -13,7 +21,7 @@ import {
import { useTranslations } from "next-intl";
import CertificateStatus from "@app/components/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { build } from "@server/build";
type ResourceInfoBoxType = {};
@@ -28,7 +36,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections cols={resource.http ? 6 : 5}>
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
@@ -61,12 +69,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ||
authInfo.headerAuth ? (
<div className="flex items-start space-x-2">
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4 flex-shrink-0" />
<div className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("notProtected")}</span>
</div>
)}
@@ -137,7 +145,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* Certificate Status Column */}
{resource.http &&
resource.domainId &&
resource.fullDomain && (
resource.fullDomain &&
build != "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
@@ -177,8 +186,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<span>{t("resourcesTableUnhealthy")}</span>
</div>
)}
{(!resource.health || resource.health === "unknown") && (
<div className="flex items-center space-x-2 text-muted-foreground">
{(!resource.health ||
resource.health === "unknown") && (
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 flex-shrink-0" />
<span>{t("resourcesTableUnknown")}</span>
</div>

View File

@@ -16,10 +16,10 @@ export type ResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
online?: boolean | null;
};
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline" | "unknown";
function aggregateSitesStatus(
resourceSites: ResourceSiteRow[]
@@ -27,8 +27,17 @@ function aggregateSitesStatus(
if (resourceSites.length === 0) {
return "allOffline";
}
const onlineCount = resourceSites.filter((rs) => rs.online).length;
if (onlineCount === resourceSites.length) return "allOnline";
const knownStatuses = resourceSites
.map((rs) => rs.online)
.filter((status): status is boolean => typeof status === "boolean");
if (knownStatuses.length === 0) {
return "unknown";
}
const onlineCount = knownStatuses.filter(Boolean).length;
if (onlineCount === knownStatuses.length) return "allOnline";
if (onlineCount > 0) return "partial";
return "allOffline";
}
@@ -40,8 +49,10 @@ function aggregateStatusDotClass(status: AggregateSitesStatus): string {
case "partial":
return "bg-yellow-500";
case "allOffline":
default:
return "bg-neutral-500";
case "unknown":
default:
return "border border-muted-foreground/50 bg-transparent";
}
}
@@ -84,6 +95,7 @@ export function ResourceSitesStatusCell({
<DropdownMenuContent align="start" className="min-w-56">
{resourceSites.map((site) => {
const isOnline = site.online;
const hasKnownStatus = typeof isOnline === "boolean";
return (
<DropdownMenuItem key={site.siteId} asChild>
<Link
@@ -94,9 +106,11 @@ export function ResourceSitesStatusCell({
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isOnline
? "bg-green-500"
: "bg-neutral-500"
!hasKnownStatus
? "border border-muted-foreground/50 bg-transparent"
: isOnline
? "bg-green-500"
: "bg-neutral-500"
)}
/>
<span className="truncate">
@@ -106,12 +120,16 @@ export function ResourceSitesStatusCell({
<span
className={cn(
"shrink-0 capitalize",
isOnline
hasKnownStatus && isOnline
? "text-green-600"
: "text-muted-foreground"
)}
>
{isOnline ? t("online") : t("offline")}
{!hasKnownStatus
? t("resourcesTableUnknown")
: isOnline
? t("online")
: t("offline")}
</span>
</Link>
</DropdownMenuItem>

View File

@@ -60,7 +60,7 @@ export type SiteRow = {
type: "newt" | "wireguard" | "local";
newtVersion?: string;
newtUpdateAvailable?: boolean;
online: boolean;
online?: boolean | null;
address?: string;
exitNodeName?: string;
exitNodeEndpoint?: string;

View File

@@ -35,6 +35,7 @@ import { useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import ClientDownloadBanner from "./ClientDownloadBanner";
import { ColumnFilterButton } from "./ColumnFilterButton";
import IdpTypeBadge from "./IdpTypeBadge";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
@@ -52,6 +53,9 @@ export type ClientRow = {
userId: string | null;
username: string | null;
userEmail: string | null;
userType: string | null;
idpName: string | null;
idpVariant: string | null;
niceId: string;
agent: string | null;
approvalState: "approved" | "pending" | "denied" | null;
@@ -370,17 +374,30 @@ export default function UserDevicesTable({
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
<div className="flex items-center gap-2">
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
{(r.userType ?? "internal") !== "internal" && (
<IdpTypeBadge
type={r.userType ?? "oidc"}
name={
r.idpName?.trim()
? r.idpName
: t("idpNameInternal")
}
variant={r.idpVariant ?? undefined}
/>
)}
</div>
) : (
"-"
);

View File

@@ -111,11 +111,13 @@ export function MultiSitesSelector({
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
</CommandItem>
))}

View File

@@ -104,7 +104,7 @@ export function ResourceTargetAddressItem({
role="combobox"
className={cn(
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
"rounded-l-md rounded-r-xs",
"",
!proxyTarget.siteId && "text-muted-foreground"
)}
>
@@ -142,7 +142,7 @@ export function ResourceTargetAddressItem({
})
}
>
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs">
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
{proxyTarget.method || "http"}
</SelectTrigger>
<SelectContent>

View File

@@ -124,11 +124,13 @@ export function SitesSelector({
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
</CommandItem>
))}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -20,7 +20,7 @@ type UseCertificateReturn = {
certLoading: boolean;
certError: string | null;
refreshing: boolean;
fetchCert: () => Promise<void>;
fetchCert: (showLoading?: boolean) => Promise<void>;
refreshCert: () => Promise<void>;
clearCert: () => void;
};
@@ -102,15 +102,33 @@ export function useCertificate({
}
}, [autoFetch, orgId, domainId, fullDomain, fetchCert]);
// Polling effect
useEffect(() => {
if (!polling || !orgId || !domainId || !fullDomain) return;
const interval = setInterval(() => {
fetchCert(false); // Don't show loading for polling
}, pollingInterval);
const POLL_JITTER_MS = 1000;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout>;
return () => clearInterval(interval);
const scheduleNext = () => {
const jitter = (Math.random() * 2 - 1) * POLL_JITTER_MS;
const delayMs = Math.max(
1000,
Math.round(pollingInterval + jitter)
);
timeoutId = setTimeout(() => {
if (cancelled) return;
void fetchCert(false);
scheduleNext();
}, delayMs);
};
scheduleNext();
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]);
return {