diff --git a/README.md b/README.md index a4bfb9fe8..562b35d40 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@

-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 diff --git a/cli/commands/rotateServerSecret.ts b/cli/commands/rotateServerSecret.ts index d3828f0e5..afac262b2 100644 --- a/cli/commands/rotateServerSecret.ts +++ b/cli/commands/rotateServerSecret.ts @@ -1,5 +1,5 @@ import { CommandModule } from "yargs"; -import { db, idpOidcConfig, licenseKey } from "@server/db"; +import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db"; import { encrypt, decrypt } from "@server/lib/crypto"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq } from "drizzle-orm"; @@ -129,9 +129,15 @@ export const rotateServerSecret: CommandModule< console.log("\nReading encrypted data from database..."); const idpConfigs = await db.select().from(idpOidcConfig); const licenseKeys = await db.select().from(licenseKey); + const certs = await db.select().from(certificates); + const streamingDestinations = await db.select().from(eventStreamingDestinations); + const webhookActions = await db.select().from(alertWebhookActions); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${licenseKeys.length} license key(s)`); + console.log(`Found ${certs.length} certificate(s)`); + console.log(`Found ${streamingDestinations.length} event streaming destination(s)`); + console.log(`Found ${webhookActions.length} alert webhook action(s)`); // Prepare all decrypted and re-encrypted values console.log("\nDecrypting and re-encrypting values..."); @@ -149,8 +155,27 @@ export const rotateServerSecret: CommandModule< encryptedInstanceId: string; }; + type CertUpdate = { + certId: number; + encryptedCertFile: string | null; + encryptedKeyFile: string | null; + }; + + type StreamingDestinationUpdate = { + destinationId: number; + encryptedConfig: string; + }; + + type WebhookActionUpdate = { + webhookActionId: number; + encryptedConfig: string; + }; + const idpUpdates: IdpUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = []; + const certUpdates: CertUpdate[] = []; + const streamingDestinationUpdates: StreamingDestinationUpdate[] = []; + const webhookActionUpdates: WebhookActionUpdate[] = []; // Process idpOidcConfig entries for (const idpConfig of idpConfigs) { @@ -217,6 +242,70 @@ export const rotateServerSecret: CommandModule< } } + // Process certificate entries + for (const cert of certs) { + try { + const encryptedCertFile = cert.certFile + ? encrypt(decrypt(cert.certFile, oldSecret), newSecret) + : null; + const encryptedKeyFile = cert.keyFile + ? encrypt(decrypt(cert.keyFile, oldSecret), newSecret) + : null; + + certUpdates.push({ + certId: cert.certId, + encryptedCertFile, + encryptedKeyFile + }); + } catch (error) { + console.error( + `Error processing certificate ${cert.certId} (${cert.domain}):`, + error + ); + throw error; + } + } + + // Process eventStreamingDestinations entries + for (const dest of streamingDestinations) { + try { + const decryptedConfig = decrypt(dest.config, oldSecret); + const encryptedConfig = encrypt(decryptedConfig, newSecret); + + streamingDestinationUpdates.push({ + destinationId: dest.destinationId, + encryptedConfig + }); + } catch (error) { + console.error( + `Error processing event streaming destination ${dest.destinationId}:`, + error + ); + throw error; + } + } + + // Process alertWebhookActions entries + for (const webhook of webhookActions) { + try { + if (webhook.config == null) continue; + + const decryptedConfig = decrypt(webhook.config, oldSecret); + const encryptedConfig = encrypt(decryptedConfig, newSecret); + + webhookActionUpdates.push({ + webhookActionId: webhook.webhookActionId, + encryptedConfig + }); + } catch (error) { + console.error( + `Error processing alert webhook action ${webhook.webhookActionId}:`, + error + ); + throw error; + } + } + // Perform all database updates in a single transaction console.log("\nUpdating database in transaction..."); await db.transaction(async (trx) => { @@ -250,10 +339,50 @@ export const rotateServerSecret: CommandModule< instanceId: update.encryptedInstanceId }); } + + // Update certificate entries + for (const update of certUpdates) { + await trx + .update(certificates) + .set({ + certFile: update.encryptedCertFile, + keyFile: update.encryptedKeyFile + }) + .where(eq(certificates.certId, update.certId)); + } + + // Update event streaming destination entries + for (const update of streamingDestinationUpdates) { + await trx + .update(eventStreamingDestinations) + .set({ config: update.encryptedConfig }) + .where( + eq( + eventStreamingDestinations.destinationId, + update.destinationId + ) + ); + } + + // Update alert webhook action entries + for (const update of webhookActionUpdates) { + await trx + .update(alertWebhookActions) + .set({ config: update.encryptedConfig }) + .where( + eq( + alertWebhookActions.webhookActionId, + update.webhookActionId + ) + ); + } }); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); + console.log(`Rotated ${certUpdates.length} certificate(s)`); + console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`); + console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`); // Update config file with new secret console.log("\nUpdating config file..."); @@ -270,6 +399,9 @@ export const rotateServerSecret: CommandModule< console.log(`\nSummary:`); console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); console.log(` - License keys: ${licenseKeyUpdates.length}`); + console.log(` - Certificates: ${certUpdates.length}`); + console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`); + console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`); console.log( `\n IMPORTANT: Restart the server for the new secret to take effect.` ); diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 0b743adbc..c3dd75de2 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -763,6 +763,7 @@ "newtEndpoint": "Крайна точка", "newtId": "Идентификационен номер", "newtSecretKey": "Секретен ключ", + "newtVersion": "Версия", "architecture": "Архитектура", "sites": "Сайтове", "siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието", "newtUpdateAvailable": "Ново обновление", "newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.", + "pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.", "domainPickerEnterDomain": "Домейн", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите", "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthSignInWithPangolin": "Впишете се с Pangolin", - "orgAuthSignInToOrg": "Влезте в организация", + "orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)", "orgAuthSelectOrgTitle": "Вход в организация.", "orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.", "orgAuthOrgIdPlaceholder": "вашата-организация", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.", "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", "domainPickerWildcardCertWarningLink": "Научете повече", - "health": "Здраве" + "health": "Здраве", + "domainPendingErrorTitle": "Проблем при проверка" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 309d52a90..00ee73906 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Tajný klíč", + "newtVersion": "Verze", "architecture": "Architektura", "sites": "Stránky", "siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání", "newtUpdateAvailable": "Dostupná aktualizace", "newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", + "pangolinNodeUpdateAvailableInfo": "Je k dispozici nová verze uzlu Pangolin. Pro nejlepší zážitek aktualizujte na nejnovější verzi.", "domainPickerEnterDomain": "Doména", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity", "orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.", "orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu", - "orgAuthSignInToOrg": "Přihlásit se do organizace", + "orgAuthSignInToOrg": "Poskytovatel identity organizace (SSO)", "orgAuthSelectOrgTitle": "Přihlášení do organizace", "orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování", "orgAuthOrgIdPlaceholder": "vaše-organizace", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Zástupné poddomény nejsou povoleny.", "domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.", "domainPickerWildcardCertWarningLink": "Zjistit více", - "health": "Zdraví" + "health": "Zdraví", + "domainPendingErrorTitle": "Problém s ověřením" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 74247798f..b4411fac4 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpunkt", "newtId": "ID", "newtSecretKey": "Geheimnis", + "newtVersion": "Version", "architecture": "Architektur", "sites": "Standorte", "siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen", "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", + "pangolinNodeUpdateAvailableInfo": "Eine neue Version von Pangolin Node ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "domainPickerEnterDomain": "Domäne", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren", "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.", "orgAuthSignInWithPangolin": "Mit Pangolin anmelden", - "orgAuthSignInToOrg": "Bei einer Organisation anmelden", + "orgAuthSignInToOrg": "Organisations-Identitätsanbieter (SSO)", "orgAuthSelectOrgTitle": "Organisations-Anmeldung", "orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren", "orgAuthOrgIdPlaceholder": "Ihre Organisation", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Wildcard-Subdomains sind nicht erlaubt.", "domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.", "domainPickerWildcardCertWarningLink": "Mehr erfahren", - "health": "Gesundheit" + "health": "Gesundheit", + "domainPendingErrorTitle": "Verifizierungsproblem" } diff --git a/messages/en-US.json b/messages/en-US.json index eb4d3ae3c..ee4ef143d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -25,6 +25,10 @@ "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", + "billingTrialBannerTitle": "Free Trial Active", + "billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.", + "billingTrialBannerUpgrade": "Upgrade Now", + "billingTrialBadge": "Free Trial", "trialActive": "Free Trial Active", "trialExpired": "Trial Expired", "trialHasEnded": "Your trial has ended.", @@ -763,6 +767,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secret", + "newtVersion": "Version", "architecture": "Architecture", "sites": "Sites", "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.", @@ -1666,6 +1671,7 @@ "pangolinUpdateAvailableReleaseNotes": "View Release Notes", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "pangolinNodeUpdateAvailableInfo": "A new version of Pangolin Node is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", @@ -2353,7 +2359,7 @@ "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", - "orgAuthSignInToOrg": "Sign in to an organization", + "orgAuthSignInToOrg": "Organization Identity Provider (SSO)", "orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthOrgIdPlaceholder": "your-organization", @@ -3201,5 +3207,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarningLink": "Learn more", - "health": "Health" + "health": "Health", + "domainPendingErrorTitle": "Verification Issue" } diff --git a/messages/es-ES.json b/messages/es-ES.json index ea5e33b25..63219984f 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secreto", + "newtVersion": "Versión", "architecture": "Arquitectura", "sites": "Sitios", "siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento", "newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", + "pangolinNodeUpdateAvailableInfo": "Hay una nueva versión de Pangolin Node disponible. Actualice a la última versión para la mejor experiencia.", "domainPickerEnterDomain": "Dominio", "domainPickerPlaceholder": "miapp.ejemplo.com", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar", "orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.", "orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin", - "orgAuthSignInToOrg": "Iniciar sesión en una organización", + "orgAuthSignInToOrg": "Proveedor de identidad de la organización (SSO)", "orgAuthSelectOrgTitle": "Inicio de sesión de organización", "orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar", "orgAuthOrgIdPlaceholder": "tu-organización", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "No se permiten subdominios comodín.", "domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.", "domainPickerWildcardCertWarningLink": "Más información", - "health": "Salud" + "health": "Salud", + "domainPendingErrorTitle": "Problema de verificación" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 57825eff0..c24789456 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secrète", + "newtVersion": "Version", "architecture": "Architecture", "sites": "Nœuds", "siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication", "newtUpdateAvailable": "Mise à jour disponible", "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", + "pangolinNodeUpdateAvailableInfo": "Une nouvelle version de Pangolin Node est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "domainPickerEnterDomain": "Domaine", "domainPickerPlaceholder": "monapp.exemple.com", "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer", "orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.", "orgAuthSignInWithPangolin": "Se connecter avec Pangolin", - "orgAuthSignInToOrg": "Se connecter à une organisation", + "orgAuthSignInToOrg": "Fournisseur d'identité d'organisation (SSO)", "orgAuthSelectOrgTitle": "Connexion à l'organisation", "orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer", "orgAuthOrgIdPlaceholder": "votre-organisation", @@ -3202,5 +3204,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Les sous-domaines Joker ne sont pas autorisés.", "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", "domainPickerWildcardCertWarningLink": "En savoir plus", - "health": "Santé" + "health": "Santé", + "domainPendingErrorTitle": "Problème de vérification" } diff --git a/messages/it-IT.json b/messages/it-IT.json index 9e810c259..f51bd5845 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Segreto", + "newtVersion": "Versione", "architecture": "Architettura", "sites": "Siti", "siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio", "newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", + "pangolinNodeUpdateAvailableInfo": "È disponibile una nuova versione di Pangolin Node. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "domainPickerEnterDomain": "Dominio", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare", "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.", "orgAuthSignInWithPangolin": "Accedi con Pangolino", - "orgAuthSignInToOrg": "Accedi a un'organizzazione", + "orgAuthSignInToOrg": "Provider di identità dell'organizzazione (SSO)", "orgAuthSelectOrgTitle": "Accesso Organizzazione", "orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare", "orgAuthOrgIdPlaceholder": "la-tua-organizzazione", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "I sottodomini wildcard non sono permessi.", "domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.", "domainPickerWildcardCertWarningLink": "Scopri di più", - "health": "Salute" + "health": "Salute", + "domainPendingErrorTitle": "Problema di Verifica" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index e98fc65fa..57316ea1e 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -763,6 +763,7 @@ "newtEndpoint": "엔드포인트", "newtId": "ID", "newtSecretKey": "비밀", + "newtVersion": "버전", "architecture": "아키텍처", "sites": "사이트", "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기", "newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "domainPickerEnterDomain": "도메인", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.", "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", "orgAuthSignInWithPangolin": "Pangolin으로 로그인", - "orgAuthSignInToOrg": "조직에 로그인", + "orgAuthSignInToOrg": "조직 아이덴티티 제공자 (SSO)", "orgAuthSelectOrgTitle": "조직 로그인", "orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.", "orgAuthOrgIdPlaceholder": "your-organization", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.", "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", "domainPickerWildcardCertWarningLink": "자세히 알아보기", - "health": "건강" + "health": "건강", + "domainPendingErrorTitle": "확인 문제" } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index d6c674801..fb02be1a8 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Sikkerhetsnøkkel", + "newtVersion": "Versjon", "architecture": "Arkitektur", "sites": "Områder", "siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater", "newtUpdateAvailable": "Oppdatering tilgjengelig", "newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", + "pangolinNodeUpdateAvailableInfo": "En ny versjon av Pangolin Node er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "domainPickerEnterDomain": "Domene", "domainPickerPlaceholder": "minapp.eksempel.no", "domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette", "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.", "orgAuthSignInWithPangolin": "Logg inn med Pangolin", - "orgAuthSignInToOrg": "Logg inn på en organisasjon", + "orgAuthSignInToOrg": "Organisasjonens identitetsleverandør (SSO)", "orgAuthSelectOrgTitle": "Organisasjonsinnlogging", "orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette", "orgAuthOrgIdPlaceholder": "din-organisasjon", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Jokertegnsubdomener er ikke tillatt.", "domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.", "domainPickerWildcardCertWarningLink": "Lær mer", - "health": "Helse" + "health": "Helse", + "domainPendingErrorTitle": "Verifiseringsproblem" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 09096c424..0060daa90 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Geheim", + "newtVersion": "Versie", "architecture": "Architectuur", "sites": "Sites", "siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken", "newtUpdateAvailable": "Update beschikbaar", "newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", + "pangolinNodeUpdateAvailableInfo": "Er is een nieuwe versie van Pangolin Node beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "domainPickerEnterDomain": "Domein", "domainPickerPlaceholder": "mijnapp.voorbeeld.nl", "domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan", "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.", "orgAuthSignInWithPangolin": "Log in met Pangolin", - "orgAuthSignInToOrg": "Log in bij een organisatie", + "orgAuthSignInToOrg": "Organisatie Identiteitsprovider (SSO)", "orgAuthSelectOrgTitle": "Organisatie Inloggen", "orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan", "orgAuthOrgIdPlaceholder": "jouw-organisatie", @@ -3168,7 +3170,7 @@ "publicIpEndpoint": "Eindpunt", "lastTriggeredAt": "Laatste Trigger", "reject": "Afwijzen", - "uptimeDaysAgo": "{count} days ago", + "uptimeDaysAgo": "{count} dagen geleden", "uptimeToday": "Vandaag", "uptimeNoDataAvailable": "Geen gegevens beschikbaar", "uptimeSuffix": "werktijd", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Wildcard-subdomeinen zijn niet toegestaan.", "domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.", "domainPickerWildcardCertWarningLink": "Meer informatie", - "health": "Gezondheid" + "health": "Gezondheid", + "domainPendingErrorTitle": "Verificatieprobleem" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 38bbea59a..2fd09d2e4 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Sekret", + "newtVersion": "Wersja", "architecture": "Architektura", "sites": "Witryny", "siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu", "newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", + "pangolinNodeUpdateAvailableInfo": "Nowa wersja Pangolin Node jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "domainPickerEnterDomain": "Domena", "domainPickerPlaceholder": "mojapp.example.com", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować", "orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.", "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin", - "orgAuthSignInToOrg": "Zaloguj się do organizacji", + "orgAuthSignInToOrg": "Dostawca tożsamości organizacji (SSO)", "orgAuthSelectOrgTitle": "Logowanie do organizacji", "orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować", "orgAuthOrgIdPlaceholder": "twoja-organizacja", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Uniwersalne subdomeny nie są dozwolone.", "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", "domainPickerWildcardCertWarningLink": "Dowiedz się więcej", - "health": "Zdrowie" + "health": "Zdrowie", + "domainPendingErrorTitle": "Problem z weryfikacją" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 2cd442720..7444606c7 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Chave Secreta", + "newtVersion": "Versão", "architecture": "Arquitetura", "sites": "sites", "siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Ver notas de versão", "newtUpdateAvailable": "Nova Atualização Disponível", "newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.", + "pangolinNodeUpdateAvailableInfo": "Uma nova versão do Pangolin Node está disponível. Atualize para a versão mais recente para uma melhor experiência.", "domainPickerEnterDomain": "Domínio", "domainPickerPlaceholder": "myapp.exemplo.com", "domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar", "orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.", "orgAuthSignInWithPangolin": "Entrar com o Pangolin", - "orgAuthSignInToOrg": "Fazer login em uma organização", + "orgAuthSignInToOrg": "Provedor de Identidade da Organização (SSO)", "orgAuthSelectOrgTitle": "Entrada da Organização", "orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar", "orgAuthOrgIdPlaceholder": "sua-organização", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Subdomínios curinga não são permitidos.", "domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.", "domainPickerWildcardCertWarningLink": "Saiba mais", - "health": "Saúde" + "health": "Saúde", + "domainPendingErrorTitle": "Problema de Verificação" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 4899a3f97..d41e8555d 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Секретный ключ", + "newtVersion": "Версия", "architecture": "Архитектура", "sites": "Сайты", "siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску", "newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", + "pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "domainPickerEnterDomain": "Домен", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthSignInWithPangolin": "Войти через Pangolin", - "orgAuthSignInToOrg": "Войти в организацию", + "orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)", "orgAuthSelectOrgTitle": "Вход в организацию", "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", "orgAuthOrgIdPlaceholder": "ваша-организация", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.", "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", "domainPickerWildcardCertWarningLink": "Узнать больше", - "health": "Состояние" + "health": "Состояние", + "domainPendingErrorTitle": "Проблема с подтверждением" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 4d36aebba..0364d2953 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -763,6 +763,7 @@ "newtEndpoint": "Uç Nokta", "newtId": "Kimlik", "newtSecretKey": "Gizli", + "newtVersion": "Sürüm", "architecture": "Mimari", "sites": "Siteler", "siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle", "newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", + "pangolinNodeUpdateAvailableInfo": "Pangolin Node'un yeni bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "domainPickerEnterDomain": "Alan Adı", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin", "orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.", "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap", - "orgAuthSignInToOrg": "Bir kuruluşa giriş yapın", + "orgAuthSignInToOrg": "Kuruluş Kimlik Sağlayıcısı (SSO)", "orgAuthSelectOrgTitle": "Kuruluş Giriş", "orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin", "orgAuthOrgIdPlaceholder": "kuruluşunuz", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "Genel alt alanlara izin verilmiyor.", "domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.", "domainPickerWildcardCertWarningLink": "Daha fazla bilgi", - "health": "Sağlık" + "health": "Sağlık", + "domainPendingErrorTitle": "Doğrulama Sorunu" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index aa7ad9ed0..6b7531b18 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -763,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "密钥", + "newtVersion": "版本", "architecture": "架构", "sites": "站点", "siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。", @@ -1666,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "查看发布说明", "newtUpdateAvailable": "更新可用", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", + "pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。", "domainPickerEnterDomain": "域名", "domainPickerPlaceholder": "example.com", "domainPickerDescription": "输入资源的完整域名以查看可用选项。", @@ -2353,7 +2355,7 @@ "orgAuthChooseIdpDescription": "选择您的身份提供商以继续", "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthSignInWithPangolin": "使用 Pangolin 登录", - "orgAuthSignInToOrg": "登录到组织", + "orgAuthSignInToOrg": "组织身份提供商 (SSO)", "orgAuthSelectOrgTitle": "组织登录", "orgAuthSelectOrgDescription": "输入您的组织ID以继续", "orgAuthOrgIdPlaceholder": "您的组织", @@ -3201,5 +3203,6 @@ "domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。", "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", "domainPickerWildcardCertWarningLink": "了解更多", - "health": "健康" + "health": "健康", + "domainPendingErrorTitle": "验证问题" } diff --git a/public/screenshots/hero.png b/public/screenshots/hero.png index 918dd755d..8d758b260 100644 Binary files a/public/screenshots/hero.png and b/public/screenshots/hero.png differ diff --git a/public/screenshots/private-resources.png b/public/screenshots/private-resources.png index 4a4b50d4e..55bf97d3b 100644 Binary files a/public/screenshots/private-resources.png and b/public/screenshots/private-resources.png differ diff --git a/public/screenshots/public-resources.png b/public/screenshots/public-resources.png index 918dd755d..8d758b260 100644 Binary files a/public/screenshots/public-resources.png and b/public/screenshots/public-resources.png differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png index f65707bce..ea8edf74f 100644 Binary files a/public/screenshots/sites.png and b/public/screenshots/sites.png differ diff --git a/public/screenshots/user-devices.png b/public/screenshots/user-devices.png index 7b407cd64..768a3bffe 100644 Binary files a/public/screenshots/user-devices.png and b/public/screenshots/user-devices.png differ diff --git a/public/screenshots/users.png b/public/screenshots/users.png index 69be0452f..d9b2b2987 100644 Binary files a/public/screenshots/users.png and b/public/screenshots/users.png differ diff --git a/server/db/mac_models.json b/server/db/mac_models.json index db473f3ae..6d9b837d5 100644 --- a/server/db/mac_models.json +++ b/server/db/mac_models.json @@ -1,94 +1,53 @@ { - "PowerMac4,4": "eMac", - "PowerMac6,4": "eMac", - "PowerBook2,1": "iBook", - "PowerBook2,2": "iBook", - "PowerBook4,1": "iBook", - "PowerBook4,2": "iBook", - "PowerBook4,3": "iBook", - "PowerBook6,3": "iBook", - "PowerBook6,5": "iBook", - "PowerBook6,7": "iBook", - "iMac,1": "iMac", - "PowerMac2,1": "iMac", - "PowerMac2,2": "iMac", - "PowerMac4,1": "iMac", - "PowerMac4,2": "iMac", - "PowerMac4,5": "iMac", - "PowerMac6,1": "iMac", - "PowerMac6,3*": "iMac", - "PowerMac6,3": "iMac", - "PowerMac8,1": "iMac", - "PowerMac8,2": "iMac", - "PowerMac12,1": "iMac", - "iMac4,1": "iMac", - "iMac4,2": "iMac", - "iMac5,2": "iMac", - "iMac5,1": "iMac", - "iMac6,1": "iMac", - "iMac7,1": "iMac", - "iMac8,1": "iMac", - "iMac9,1": "iMac", - "iMac10,1": "iMac", - "iMac11,1": "iMac", - "iMac11,2": "iMac", - "iMac11,3": "iMac", - "iMac12,1": "iMac", - "iMac12,2": "iMac", - "iMac13,1": "iMac", - "iMac13,2": "iMac", - "iMac14,1": "iMac", - "iMac14,3": "iMac", - "iMac14,2": "iMac", - "iMac14,4": "iMac", - "iMac15,1": "iMac", - "iMac16,1": "iMac", - "iMac16,2": "iMac", - "iMac17,1": "iMac", - "iMac18,1": "iMac", - "iMac18,2": "iMac", - "iMac18,3": "iMac", - "iMac19,2": "iMac", - "iMac19,1": "iMac", - "iMac20,1": "iMac", - "iMac20,2": "iMac", - "iMac21,2": "iMac", - "iMac21,1": "iMac", - "iMacPro1,1": "iMac Pro", - "PowerMac10,1": "Mac mini", - "PowerMac10,2": "Mac mini", - "Macmini1,1": "Mac mini", - "Macmini2,1": "Mac mini", - "Macmini3,1": "Mac mini", - "Macmini4,1": "Mac mini", - "Macmini5,1": "Mac mini", - "Macmini5,2": "Mac mini", - "Macmini5,3": "Mac mini", - "Macmini6,1": "Mac mini", - "Macmini6,2": "Mac mini", - "Macmini7,1": "Mac mini", - "Macmini8,1": "Mac mini", "ADP3,2": "Mac mini", - "Macmini9,1": "Mac mini", - "Mac14,3": "Mac mini", - "Mac14,12": "Mac mini", - "MacPro1,1*": "Mac Pro", - "MacPro2,1": "Mac Pro", - "MacPro3,1": "Mac Pro", - "MacPro4,1": "Mac Pro", - "MacPro5,1": "Mac Pro", - "MacPro6,1": "Mac Pro", - "MacPro7,1": "Mac Pro", - "N/A*": "Power Macintosh", - "PowerMac1,1": "Power Macintosh", - "PowerMac3,1": "Power Macintosh", - "PowerMac3,3": "Power Macintosh", - "PowerMac3,4": "Power Macintosh", - "PowerMac3,5": "Power Macintosh", - "PowerMac3,6": "Power Macintosh", "Mac13,1": "Mac Studio", "Mac13,2": "Mac Studio", + "Mac14,10": "MacBook Pro", + "Mac14,12": "Mac mini", + "Mac14,13": "Mac Studio", + "Mac14,14": "Mac Studio", + "Mac14,15": "MacBook Air", + "Mac14,2": "MacBook Air", + "Mac14,3": "Mac mini", + "Mac14,5": "MacBook Pro", + "Mac14,6": "MacBook Pro", + "Mac14,7": "MacBook Pro", + "Mac14,8": "Mac Pro", + "Mac14,9": "MacBook Pro", + "Mac15,10": "MacBook Pro", + "Mac15,11": "MacBook Pro", + "Mac15,12": "MacBook Air", + "Mac15,13": "MacBook Air", + "Mac15,14": "Mac Studio", + "Mac15,3": "MacBook Pro", + "Mac15,4": "iMac", + "Mac15,5": "iMac", + "Mac15,6": "MacBook Pro", + "Mac15,7": "MacBook Pro", + "Mac15,8": "MacBook Pro", + "Mac15,9": "MacBook Pro", + "Mac16,1": "MacBook Pro", + "Mac16,10": "Mac mini", + "Mac16,11": "Mac mini", + "Mac16,12": "MacBook Air", + "Mac16,13": "MacBook Air", + "Mac16,2": "iMac", + "Mac16,3": "iMac", + "Mac16,5": "MacBook Pro", + "Mac16,6": "MacBook Pro", + "Mac16,7": "MacBook Pro", + "Mac16,8": "MacBook Pro", + "Mac16,9": "Mac Studio", + "Mac17,2": "MacBook Pro", + "Mac17,3": "MacBook Air", + "Mac17,4": "MacBook Air", + "Mac17,5": "MacBook Neo", + "Mac17,6": "MacBook Pro", + "Mac17,7": "MacBook Pro", + "Mac17,8": "MacBook Pro", + "Mac17,9": "MacBook Pro", "MacBook1,1": "MacBook", + "MacBook10,1": "MacBook", "MacBook2,1": "MacBook", "MacBook3,1": "MacBook", "MacBook4,1": "MacBook", @@ -98,8 +57,8 @@ "MacBook7,1": "MacBook", "MacBook8,1": "MacBook", "MacBook9,1": "MacBook", - "MacBook10,1": "MacBook", "MacBookAir1,1": "MacBook Air", + "MacBookAir10,1": "MacBook Air", "MacBookAir2,1": "MacBook Air", "MacBookAir3,1": "MacBook Air", "MacBookAir3,2": "MacBook Air", @@ -114,88 +73,163 @@ "MacBookAir8,1": "MacBook Air", "MacBookAir8,2": "MacBook Air", "MacBookAir9,1": "MacBook Air", - "MacBookAir10,1": "MacBook Air", - "Mac14,2": "MacBook Air", "MacBookPro1,1": "MacBook Pro", "MacBookPro1,2": "MacBook Pro", - "MacBookPro2,2": "MacBook Pro", - "MacBookPro2,1": "MacBook Pro", - "MacBookPro3,1": "MacBook Pro", - "MacBookPro4,1": "MacBook Pro", - "MacBookPro5,1": "MacBook Pro", - "MacBookPro5,2": "MacBook Pro", - "MacBookPro5,5": "MacBook Pro", - "MacBookPro5,4": "MacBook Pro", - "MacBookPro5,3": "MacBook Pro", - "MacBookPro7,1": "MacBook Pro", - "MacBookPro6,2": "MacBook Pro", - "MacBookPro6,1": "MacBook Pro", - "MacBookPro8,1": "MacBook Pro", - "MacBookPro8,2": "MacBook Pro", - "MacBookPro8,3": "MacBook Pro", - "MacBookPro9,2": "MacBook Pro", - "MacBookPro9,1": "MacBook Pro", "MacBookPro10,1": "MacBook Pro", "MacBookPro10,2": "MacBook Pro", "MacBookPro11,1": "MacBook Pro", "MacBookPro11,2": "MacBook Pro", "MacBookPro11,3": "MacBook Pro", - "MacBookPro12,1": "MacBook Pro", "MacBookPro11,4": "MacBook Pro", "MacBookPro11,5": "MacBook Pro", + "MacBookPro12,1": "MacBook Pro", "MacBookPro13,1": "MacBook Pro", "MacBookPro13,2": "MacBook Pro", "MacBookPro13,3": "MacBook Pro", "MacBookPro14,1": "MacBook Pro", "MacBookPro14,2": "MacBook Pro", "MacBookPro14,3": "MacBook Pro", - "MacBookPro15,2": "MacBook Pro", "MacBookPro15,1": "MacBook Pro", + "MacBookPro15,2": "MacBook Pro", "MacBookPro15,3": "MacBook Pro", "MacBookPro15,4": "MacBook Pro", "MacBookPro16,1": "MacBook Pro", - "MacBookPro16,3": "MacBook Pro", "MacBookPro16,2": "MacBook Pro", + "MacBookPro16,3": "MacBook Pro", "MacBookPro16,4": "MacBook Pro", "MacBookPro17,1": "MacBook Pro", - "MacBookPro18,3": "MacBook Pro", - "MacBookPro18,4": "MacBook Pro", "MacBookPro18,1": "MacBook Pro", "MacBookPro18,2": "MacBook Pro", - "Mac14,7": "MacBook Pro", - "Mac14,9": "MacBook Pro", - "Mac14,5": "MacBook Pro", - "Mac14,10": "MacBook Pro", - "Mac14,6": "MacBook Pro", - "PowerMac1,2": "Power Macintosh", - "PowerMac5,1": "Power Macintosh", - "PowerMac7,2": "Power Macintosh", - "PowerMac7,3": "Power Macintosh", - "PowerMac9,1": "Power Macintosh", - "PowerMac11,2": "Power Macintosh", + "MacBookPro18,3": "MacBook Pro", + "MacBookPro18,4": "MacBook Pro", + "MacBookPro2,1": "MacBook Pro", + "MacBookPro2,2": "MacBook Pro", + "MacBookPro3,1": "MacBook Pro", + "MacBookPro4,1": "MacBook Pro", + "MacBookPro5,1": "MacBook Pro", + "MacBookPro5,2": "MacBook Pro", + "MacBookPro5,3": "MacBook Pro", + "MacBookPro5,4": "MacBook Pro", + "MacBookPro5,5": "MacBook Pro", + "MacBookPro6,1": "MacBook Pro", + "MacBookPro6,2": "MacBook Pro", + "MacBookPro7,1": "MacBook Pro", + "MacBookPro8,1": "MacBook Pro", + "MacBookPro8,2": "MacBook Pro", + "MacBookPro8,3": "MacBook Pro", + "MacBookPro9,1": "MacBook Pro", + "MacBookPro9,2": "MacBook Pro", + "MacPro1,1": "Mac Pro", + "MacPro2,1": "Mac Pro", + "MacPro3,1": "Mac Pro", + "MacPro4,1": "Mac Pro", + "MacPro5,1": "Mac Pro", + "MacPro6,1": "Mac Pro", + "MacPro7,1": "Mac Pro", + "Macmini1,1": "Mac mini", + "Macmini2,1": "Mac mini", + "Macmini3,1": "Mac mini", + "Macmini4,1": "Mac mini", + "Macmini5,1": "Mac mini", + "Macmini5,2": "Mac mini", + "Macmini5,3": "Mac mini", + "Macmini6,1": "Mac mini", + "Macmini6,2": "Mac mini", + "Macmini7,1": "Mac mini", + "Macmini8,1": "Mac mini", + "Macmini9,1": "Mac mini", "PowerBook1,1": "PowerBook", + "PowerBook2,1": "iBook", + "PowerBook2,2": "iBook", "PowerBook3,1": "PowerBook", "PowerBook3,2": "PowerBook", "PowerBook3,3": "PowerBook", "PowerBook3,4": "PowerBook", "PowerBook3,5": "PowerBook", - "PowerBook6,1": "PowerBook", + "PowerBook4,1": "iBook", + "PowerBook4,2": "iBook", + "PowerBook4,3": "iBook", "PowerBook5,1": "PowerBook", - "PowerBook6,2": "PowerBook", "PowerBook5,2": "PowerBook", "PowerBook5,3": "PowerBook", - "PowerBook6,4": "PowerBook", "PowerBook5,4": "PowerBook", "PowerBook5,5": "PowerBook", - "PowerBook6,8": "PowerBook", "PowerBook5,6": "PowerBook", "PowerBook5,7": "PowerBook", "PowerBook5,8": "PowerBook", "PowerBook5,9": "PowerBook", + "PowerBook6,1": "PowerBook", + "PowerBook6,2": "PowerBook", + "PowerBook6,3": "iBook", + "PowerBook6,4": "PowerBook", + "PowerBook6,5": "iBook", + "PowerBook6,7": "iBook", + "PowerBook6,8": "PowerBook", + "PowerMac1,1": "Power Macintosh", + "PowerMac1,2": "Power Macintosh", + "PowerMac10,1": "Mac mini", + "PowerMac10,2": "Mac mini", + "PowerMac11,2": "Power Macintosh", + "PowerMac12,1": "iMac", + "PowerMac2,1": "iMac", + "PowerMac2,2": "iMac", + "PowerMac3,1": "Mac Server", + "PowerMac3,3": "Power Macintosh", + "PowerMac3,4": "Power Macintosh", + "PowerMac3,5": "Power Macintosh", + "PowerMac3,6": "Power Macintosh", + "PowerMac4,1": "iMac", + "PowerMac4,2": "iMac", + "PowerMac4,4": "eMac", + "PowerMac4,5": "iMac", + "PowerMac5,1": "Power Macintosh", + "PowerMac6,1": "iMac", + "PowerMac6,3": "iMac", + "PowerMac6,4": "eMac", + "PowerMac7,2": "Power Macintosh", + "PowerMac7,3": "Power Macintosh", + "PowerMac8,1": "iMac", + "PowerMac8,2": "iMac", + "PowerMac9,1": "Power Macintosh", "RackMac1,1": "Xserve", "RackMac1,2": "Xserve", "RackMac3,1": "Xserve", "Xserve1,1": "Xserve", "Xserve2,1": "Xserve", - "Xserve3,1": "Xserve" -} \ No newline at end of file + "Xserve3,1": "Xserve", + "iMac,1": "iMac", + "iMac10,1": "iMac", + "iMac11,1": "iMac", + "iMac11,2": "iMac", + "iMac11,3": "iMac", + "iMac12,1": "iMac", + "iMac12,2": "iMac", + "iMac13,1": "iMac", + "iMac13,2": "iMac", + "iMac14,1": "iMac", + "iMac14,2": "iMac", + "iMac14,3": "iMac", + "iMac14,4": "iMac", + "iMac15,1": "iMac", + "iMac16,1": "iMac", + "iMac16,2": "iMac", + "iMac17,1": "iMac", + "iMac18,1": "iMac", + "iMac18,2": "iMac", + "iMac18,3": "iMac", + "iMac19,1": "iMac", + "iMac19,2": "iMac", + "iMac20,1": "iMac", + "iMac20,2": "iMac", + "iMac21,1": "iMac", + "iMac21,2": "iMac", + "iMac4,1": "iMac", + "iMac4,2": "iMac", + "iMac5,1": "iMac", + "iMac5,2": "iMac", + "iMac6,1": "iMac", + "iMac7,1": "iMac", + "iMac8,1": "iMac", + "iMac9,1": "iMac", + "iMacPro1,1": "iMac Pro" +} diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx index 7cd6d30ac..7c712e278 100644 --- a/server/emails/templates/NotifyTrialExpiring.tsx +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -64,7 +64,7 @@ export const NotifyTrialExpiring = ({ Some features and resources may now be - restricted or disconnected. To restore full + restricted. To restore full access and continue using all the features you had during your trial, please upgrade to a paid plan. @@ -85,7 +85,7 @@ export const NotifyTrialExpiring = ({ {orgName} will end on{" "} {trialEndsAt} {isLastDay - ? " — that's tomorrow!" + ? " - that's tomorrow!" : `, in ${daysRemaining} days`} . @@ -93,8 +93,7 @@ export const NotifyTrialExpiring = ({ After your trial ends, your account will be moved to the free plan and some - functionality may be restricted or your - sites may disconnect. + functionality may be restricted. diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index 00afa22f0..1b9ff40ae 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -1,27 +1,153 @@ -// stub +import logger from "@server/logger"; +import { processAlerts } from "#dynamic/lib/alerts"; +import { + db, + statusHistory, + targetHealthCheck, + targets, + resources, + Transaction, + logsDb +} from "@server/db"; +import { eq } from "drizzle-orm"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; +import { + fireResourceDegradedAlert, + fireResourceHealthyAlert, + fireResourceUnhealthyAlert, + fireResourceUnknownAlert +} from "./resourceEvents"; +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `health_check_healthy` alert for the given health check. + * + * Call this after a previously-failing health check has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ export async function fireHealthCheckHealthyAlert( orgId: string, healthCheckId: number, - healthCheckName?: string, + healthCheckName?: string | null, healthCheckTargetId?: number | null, extra?: Record, send: boolean = true, - trx?: unknown + trx: Transaction | typeof db = db ): Promise { - return; + try { + await logsDb.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "healthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("health_check", healthCheckId); + + await handleResource(orgId, healthCheckTargetId, send, trx); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "health_check_healthy", + orgId, + healthCheckId, + data: { + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "health_check_toggle", + orgId, + healthCheckId, + data: { + healthCheckId, + status: "healthy", + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } } +/** + * Fire a `health_check_unhealthy` alert for the given health check. + * + * Call this after a health check has been detected as failing so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ export async function fireHealthCheckUnhealthyAlert( orgId: string, healthCheckId: number, - healthCheckName?: string, + healthCheckName?: string | null, healthCheckTargetId?: number | null, extra?: Record, send: boolean = true, - trx?: unknown + trx: Transaction | typeof db = db ): Promise { - return; + try { + await logsDb.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("health_check", healthCheckId); + + await handleResource(orgId, healthCheckTargetId, send, trx); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "health_check_unhealthy", + orgId, + healthCheckId, + data: { + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "health_check_toggle", + orgId, + healthCheckId, + data: { + healthCheckId, + status: "unhealthy", + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } } export async function fireHealthCheckUnknownAlert( @@ -31,7 +157,137 @@ export async function fireHealthCheckUnknownAlert( healthCheckTargetId?: number | null, extra?: Record, send: boolean = true, - trx?: unknown + trx: Transaction | typeof db = db ): Promise { - return; + try { + await logsDb.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("health_check", healthCheckId); + + await handleResource(orgId, healthCheckTargetId, send, trx); + + if (!send) { + return; + } + } catch (err) { + logger.error( + `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +async function handleResource( + orgId: string, + healthCheckTargetId?: number | null, + send: boolean = true, + trx: Transaction | typeof db = db +) { + if (!healthCheckTargetId) { + return; + } + // we have targets lets get them + const [target] = await trx + .select() + .from(targets) + .where(eq(targets.targetId, healthCheckTargetId)) + .limit(1); + + if (!target) { + return; + } + + const [resource] = await trx + .select() + .from(resources) + .where(eq(resources.resourceId, target.resourceId)) + .limit(1); + + if (!resource) { + return; + } + + const otherTargets = await trx + .select({ hcHealth: targetHealthCheck.hcHealth }) + .from(targets) + .innerJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .where(eq(targets.resourceId, resource.resourceId)); + + let health = "healthy"; + const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); + const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); + const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); + + if (allUnknown) { + logger.debug( + `Marking resource ${resource.resourceId} as unknown because all health checks are disabled` + ); + health = "unknown"; + } else if (allHealthy) { + health = "healthy"; + } else if (allUnhealthy) { + logger.debug( + `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy` + ); + health = "unhealthy"; + } else { + logger.debug( + `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy` + ); + health = "degraded"; + } + + if (health != resource.health) { + // it changed + await trx + .update(resources) + .set({ health }) + .where(eq(resources.resourceId, resource.resourceId)); + + if (health === "unknown") { + await fireResourceUnknownAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } else if (health === "unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } else if (health === "healthy") { + await fireResourceHealthyAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } else if (health === "degraded") { + await fireResourceDegradedAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } + } } diff --git a/server/lib/alerts/events/resourceEvents.ts b/server/lib/alerts/events/resourceEvents.ts index e7a374b44..0d84328d2 100644 --- a/server/lib/alerts/events/resourceEvents.ts +++ b/server/lib/alerts/events/resourceEvents.ts @@ -1,26 +1,243 @@ +import logger from "@server/logger"; +import { processAlerts } from "#dynamic/lib/alerts"; +import { db, logsDb, statusHistory, Transaction } from "@server/db"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `resource_healthy` alert for the given resource. + * + * Call this after a previously-unhealthy resource has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ export async function fireResourceHealthyAlert( orgId: string, resourceId: number, resourceName?: string | null, extra?: Record, send: boolean = true, - trx?: unknown -): Promise {} + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "healthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_healthy", + orgId, + resourceId, + data: { + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "healthy", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} + +/** + * Fire a `resource_unhealthy` alert for the given resource. + * + * Call this after a resource has been detected as unhealthy so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ export async function fireResourceUnhealthyAlert( orgId: string, resourceId: number, resourceName?: string | null, extra?: Record, send: boolean = true, - trx?: unknown -): Promise {} + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); -export async function fireResourceToggleAlert( + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_unhealthy", + orgId, + resourceId, + data: { + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unhealthy", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} + +/** + * Fire a `resource_degraded` alert for the given resource. + * + * Call this after a resource has been detected as degraded so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceDegradedAlert( orgId: string, resourceId: number, resourceName?: string | null, extra?: Record, send: boolean = true, - trx?: unknown -): Promise {} + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "degraded", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_degraded", + orgId, + resourceId, + data: { + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "degraded", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} + +/** + * Fire a `resource_unknown` alert for the given resource. + * + * Call this when all health checks on a resource are disabled so that the + * resource status transitions to unknown. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceUnknownAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unknown", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} diff --git a/server/lib/alerts/events/siteEvents.ts b/server/lib/alerts/events/siteEvents.ts index 1e96951cc..e64ac72f7 100644 --- a/server/lib/alerts/events/siteEvents.ts +++ b/server/lib/alerts/events/siteEvents.ts @@ -1,21 +1,156 @@ -// stub +import logger from "@server/logger"; +import { processAlerts } from "#dynamic/lib/alerts"; +import { + db, + logsDb, + statusHistory, + targetHealthCheck, + Transaction +} from "@server/db"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; +import { and, eq, inArray } from "drizzle-orm"; +import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `site_online` alert for the given site. + * + * Call this after the site has been confirmed reachable / connected so that + * any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ export async function fireSiteOnlineAlert( orgId: string, siteId: number, siteName?: string, extra?: Record, - trx?: unknown + trx: Transaction | typeof db = db ): Promise { - return; + try { + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId: orgId, + status: "online", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("site", siteId); + + await processAlerts({ + eventType: "site_online", + orgId, + siteId, + data: { + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "site_toggle", + orgId, + siteId, + data: { + siteId, + status: "online", + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`, + err + ); + } } +/** + * Fire a `site_offline` alert for the given site. + * + * Call this after the site has been detected as unreachable / disconnected so + * that any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ export async function fireSiteOfflineAlert( orgId: string, siteId: number, siteName?: string, extra?: Record, - trx?: unknown + trx: Transaction | typeof db = db ): Promise { - return; -} \ No newline at end of file + try { + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("site", siteId); + + const unhealthyHealthChecks = await trx + .update(targetHealthCheck) + .set({ hcHealth: "unhealthy" }) + .where( + and( + eq(targetHealthCheck.orgId, orgId), + eq(targetHealthCheck.siteId, siteId), + eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled + ) + ) + .returning(); + + for (const healthCheck of unhealthyHealthChecks) { + logger.info( + `Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline` + ); + + await fireHealthCheckUnhealthyAlert( + healthCheck.orgId, + healthCheck.targetHealthCheckId, + healthCheck.name, + healthCheck.targetId, // for the resource if we have one + undefined, + true, + trx + ); + } + + await processAlerts({ + eventType: "site_offline", + orgId, + siteId, + data: { + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "site_toggle", + orgId, + siteId, + data: { + siteId, + status: "offline", + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} diff --git a/server/lib/alerts/index.ts b/server/lib/alerts/index.ts index 1a64d1cdd..324e4cf6a 100644 --- a/server/lib/alerts/index.ts +++ b/server/lib/alerts/index.ts @@ -1,3 +1,4 @@ export * from "./events/siteEvents"; export * from "./events/healthCheckEvents"; export * from "./events/resourceEvents"; +export * from "./processAlerts"; diff --git a/server/lib/alerts/processAlerts.ts b/server/lib/alerts/processAlerts.ts new file mode 100644 index 000000000..2d0fb7bfd --- /dev/null +++ b/server/lib/alerts/processAlerts.ts @@ -0,0 +1,5 @@ +import { AlertContext } from "@server/routers/alertRule/types"; + +export async function processAlerts(context: AlertContext): Promise { + return; +} diff --git a/server/lib/billing/getOrgTierData.ts b/server/lib/billing/getOrgTierData.ts index 75f125594..afe45d961 100644 --- a/server/lib/billing/getOrgTierData.ts +++ b/server/lib/billing/getOrgTierData.ts @@ -1,8 +1,9 @@ export async function getOrgTierData( orgId: string -): Promise<{ tier: string | null; active: boolean }> { +): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> { const tier = null; const active = false; + const isTrial = false; - return { tier, active }; + return { tier, active, isTrial }; } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index ae9a18ffe..e45ae637d 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = { export const tier2LimitSet: LimitSet = { [FeatureId.USERS]: { - value: 100, + value: 50, description: "Team limit" }, [FeatureId.SITES]: { @@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = { export const tier3LimitSet: LimitSet = { [FeatureId.USERS]: { - value: 500, + value: 250, description: "Business limit" }, [FeatureId.SITES]: { diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 1b2ec2ef7..21476b580 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -131,41 +131,22 @@ export async function updateClientResources( : []; const allSites: { siteId: number }[] = []; + if (resourceData.site) { - let siteSingle; - const resourceSiteId = resourceData.site; - - if (resourceSiteId) { - // Look up site by niceId - [siteSingle] = await trx - .select({ siteId: sites.siteId }) - .from(sites) - .where( - and( - eq(sites.niceId, resourceSiteId), - eq(sites.orgId, orgId) - ) + // Look up site by niceId + const [siteSingle] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, resourceData.site), + eq(sites.orgId, orgId) ) - .limit(1); - } else if (siteId) { - // Use the provided siteId directly, but verify it belongs to the org - [siteSingle] = await trx - .select({ siteId: sites.siteId }) - .from(sites) - .where( - and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) - ) - .limit(1); - } else { - throw new Error(`Target site is required`); + ) + .limit(1); + if (siteSingle) { + allSites.push(siteSingle); } - - if (!siteSingle) { - throw new Error( - `Site not found: ${resourceSiteId} in org ${orgId}` - ); - } - allSites.push(siteSingle); } if (resourceData.sites) { @@ -180,15 +161,31 @@ export async function updateClientResources( ) ) .limit(1); - if (!site) { - throw new Error( - `Site not found: ${siteId} in org ${orgId}` - ); + if (site) { + allSites.push(site); } - allSites.push(site); } } + if (siteId && allSites.length === 0) { + // only add if there are not provided sites + // Use the provided siteId directly, but verify it belongs to the org + const [siteSingle] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + if (siteSingle) { + allSites.push(siteSingle); + } + } + + if (allSites.length === 0) { + throw new Error( + `No valid sites found for private private resource ${resourceNiceId} in org ${orgId}` + ); + } + if (existingResource) { let domainInfo: | { subdomain: string | null; domainId: string } diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index ba93bc46a..34b352a42 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -34,7 +34,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; +import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { @@ -165,7 +165,8 @@ export async function updateProxyResources( hcStatus: healthcheckData?.status, hcHealth: "unknown", hcHealthyThreshold: healthcheckData?.["healthy-threshold"], - hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"] + hcUnhealthyThreshold: + healthcheckData?.["unhealthy-threshold"] }) .returning(); @@ -544,8 +545,10 @@ export async function updateProxyResources( healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, hcStatus: healthcheckData?.status, - hcHealthyThreshold: healthcheckData?.["healthy-threshold"], - hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"] + hcHealthyThreshold: + healthcheckData?.["healthy-threshold"], + hcUnhealthyThreshold: + healthcheckData?.["unhealthy-threshold"] }) .where( eq( @@ -1120,8 +1123,10 @@ function checkIfHealthcheckChanged( JSON.stringify(incoming.hcHeaders) ) return true; - if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true; - if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true; + if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) + return true; + if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) + return true; return false; } @@ -1184,7 +1189,11 @@ async function getDomainId( orgId: string, fullDomain: string, trx: Transaction -): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> { +): Promise<{ + subdomain: string | null; + domainId: string; + wildcard: boolean; +} | null> { const isWildcardFullDomain = fullDomain.startsWith("*."); const possibleDomains = await trx diff --git a/server/lib/consts.ts b/server/lib/consts.ts index d2218e874..3d290d93b 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.18.0"; +export const APP_VERSION = "1.18.2"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 9c33fedf7..64a263097 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -535,6 +535,24 @@ export class TraefikConfigManager { if (match && match[1]) { domains.add(match[1]); } + // Match HostRegexp(`^[^.]+\.parent.domain$`) generated for wildcard resources + const hostRegexpMatch = router.rule.match( + /HostRegexp\(`([^`]+)`\)/ + ); + if (hostRegexpMatch && hostRegexpMatch[1]) { + const innerRegex = hostRegexpMatch[1]; + // Pattern is always ^[^.]+\.PARENT_DOMAIN$ where dots are escaped as \. + const domainMatch = innerRegex.match( + /^\^\[\^\.\]\+\\\.(.+)\$$/ + ); + if (domainMatch && domainMatch[1]) { + const parentDomain = domainMatch[1].replace( + /\\\./g, + "." + ); + domains.add(`*.${parentDomain}`); + } + } } } } diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 06b427955..adf87eed8 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -12,6 +12,7 @@ */ import fs from "fs"; +import path from "path"; import crypto from "crypto"; import { certificates, @@ -274,12 +275,244 @@ function detectWildcard( return { wildcard: false, wildcardSan: null }; } +interface HttpCert { + wildcard: boolean; + altName: string; + certName: string; + commonName: string; + certFile: string; + keyFile: string; +} + +async function syncAcmeCertsFromHttp(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint); + } catch (err) { + logger.debug( + `acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}` + ); + return; + } + + if (!response.ok) { + logger.debug( + `acmeCertSync: HTTP endpoint returned status ${response.status}` + ); + return; + } + + let httpCerts: HttpCert[]; + try { + httpCerts = await response.json(); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse JSON from HTTP endpoint: ${err}` + ); + return; + } + + if (!Array.isArray(httpCerts) || httpCerts.length === 0) { + logger.debug( + `acmeCertSync: no certificates returned from HTTP endpoint` + ); + return; + } + + for (const cert of httpCerts) { + const domain = cert?.certName; + + if (!domain || typeof domain !== "string") { + logger.debug( + `acmeCertSync: skipping HTTP cert with missing certName` + ); + continue; + } + + const certPem = cert.certFile; + const keyPem = cert.keyFile; + + if (!certPem?.trim() || !keyPem?.trim()) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile` + ); + continue; + } + + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + + const wildcard = cert.wildcard ?? false; + + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + continue; + } + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } + } catch (err) { + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + const encryptedCert = encrypt( + certPem, + config.getRawConfig().server.secret! + ); + const encryptedKey = encrypt( + keyPem, + config.getRawConfig().server.secret! + ); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } + } +} + +function findAcmeJsonFiles(dirPath: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (err) { + logger.warn( + `acmeCertSync: could not read directory "${dirPath}": ${err}` + ); + return results; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + results.push(...findAcmeJsonFiles(fullPath)); + } else if (entry.isFile() && entry.name === "acme.json") { + results.push(fullPath); + } + } + return results; +} + async function syncAcmeCerts(acmeJsonPath: string): Promise { let raw: string; try { raw = fs.readFileSync(acmeJsonPath, "utf8"); } catch (err) { - logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`); + logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`); return; } @@ -287,7 +520,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { try { acmeJson = JSON.parse(raw); } catch (err) { - logger.debug(`acmeCertSync: could not parse acme.json: ${err}`); + logger.warn( + `acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}` + ); return; } @@ -389,11 +624,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { const existing = await db .select() .from(certificates) - .where( - and( - eq(certificates.domain, domain) - ) - ) + .where(and(eq(certificates.domain, domain))) .limit(1); let oldCertPem: string | null = null; @@ -408,7 +639,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { const wildcardUnchanged = existing[0].wildcard === wildcard; if (storedCertPem === certPem && wildcardUnchanged) { // logger.debug( - // `acmeCertSync: cert for ${domain} is unchanged, skipping` + // `acmeCertSync: cert for ${domain} is unchanged, skipping` // ); continue; } @@ -547,19 +778,62 @@ export function initAcmeCertSync(): void { privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; + const httpEndpoint = privateConfigData.acme?.acme_http_endpoint; logger.debug( `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` ); + if (httpEndpoint) { + logger.debug( + `acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms` + ); + } + + const runSync = () => { + if (httpEndpoint) { + syncAcmeCertsFromHttp(httpEndpoint).catch((err) => { + logger.error(`acmeCertSync: error during HTTP sync: ${err}`); + }); + } else { + // only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(acmeJsonPath); + } catch (err) { + logger.warn( + `acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}` + ); + return; + } + + if (stat.isDirectory()) { + const files = findAcmeJsonFiles(acmeJsonPath); + if (files.length === 0) { + logger.debug( + `acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"` + ); + return; + } + logger.debug( + `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"` + ); + for (const file of files) { + syncAcmeCerts(file).catch((err) => { + logger.error( + `acmeCertSync: error during sync of "${file}": ${err}` + ); + }); + } + } else { + syncAcmeCerts(acmeJsonPath).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + } + } + }; // Run immediately on init, then on the configured interval - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during initial sync: ${err}`); - }); + runSync(); - setInterval(() => { - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during sync: ${err}`); - }); - }, intervalMs); + setInterval(runSync, intervalMs); } diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts deleted file mode 100644 index ae9f1f05b..000000000 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import logger from "@server/logger"; -import { processAlerts } from "../processAlerts"; -import { - db, - statusHistory, - targetHealthCheck, - targets, - resources, - Transaction, - logsDb -} from "@server/db"; -import { eq } from "drizzle-orm"; -import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; -import { - fireResourceDegradedAlert, - fireResourceHealthyAlert, - fireResourceUnhealthyAlert, - fireResourceUnknownAlert -} from "./resourceEvents"; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Fire a `health_check_healthy` alert for the given health check. - * - * Call this after a previously-failing health check has recovered so that any - * matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireHealthCheckHealthyAlert( - orgId: string, - healthCheckId: number, - healthCheckName?: string | null, - healthCheckTargetId?: number | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "health_check", - entityId: healthCheckId, - orgId: orgId, - status: "healthy", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("health_check", healthCheckId); - - await handleResource(orgId, healthCheckTargetId, send, trx); - - if (!send) { - return; - } - - await processAlerts({ - eventType: "health_check_healthy", - orgId, - healthCheckId, - data: { - ...(healthCheckName != null ? { healthCheckName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "health_check_toggle", - orgId, - healthCheckId, - data: { - healthCheckId, - status: "healthy", - ...(healthCheckName != null ? { healthCheckName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, - err - ); - } -} - -/** - * Fire a `health_check_unhealthy` alert for the given health check. - * - * Call this after a health check has been detected as failing so that any - * matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireHealthCheckUnhealthyAlert( - orgId: string, - healthCheckId: number, - healthCheckName?: string | null, - healthCheckTargetId?: number | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "health_check", - entityId: healthCheckId, - orgId: orgId, - status: "unhealthy", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("health_check", healthCheckId); - - await handleResource(orgId, healthCheckTargetId, send, trx); - - if (!send) { - return; - } - - await processAlerts({ - eventType: "health_check_unhealthy", - orgId, - healthCheckId, - data: { - ...(healthCheckName != null ? { healthCheckName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "health_check_toggle", - orgId, - healthCheckId, - data: { - healthCheckId, - status: "unhealthy", - ...(healthCheckName != null ? { healthCheckName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, - err - ); - } -} - -export async function fireHealthCheckUnknownAlert( - orgId: string, - healthCheckId: number, - healthCheckName?: string | null, - healthCheckTargetId?: number | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "health_check", - entityId: healthCheckId, - orgId: orgId, - status: "unknown", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("health_check", healthCheckId); - - await handleResource(orgId, healthCheckTargetId, send, trx); - - if (!send) { - return; - } - } catch (err) { - logger.error( - `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, - err - ); - } -} - -async function handleResource( - orgId: string, - healthCheckTargetId?: number | null, - send: boolean = true, - trx: Transaction | typeof db = db -) { - if (!healthCheckTargetId) { - return; - } - // we have targets lets get them - const [target] = await trx - .select() - .from(targets) - .where(eq(targets.targetId, healthCheckTargetId)) - .limit(1); - - if (!target) { - return; - } - - const [resource] = await trx - .select() - .from(resources) - .where(eq(resources.resourceId, target.resourceId)) - .limit(1); - - if (!resource) { - return; - } - - const otherTargets = await trx - .select({ hcHealth: targetHealthCheck.hcHealth }) - .from(targets) - .innerJoin( - targetHealthCheck, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .where(eq(targets.resourceId, resource.resourceId)); - - let health = "healthy"; - const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); - const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); - const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); - - if (allUnknown) { - logger.debug( - `Marking resource ${resource.resourceId} as unknown because all health checks are disabled` - ); - health = "unknown"; - } else if (allHealthy) { - health = "healthy"; - } else if (allUnhealthy) { - logger.debug( - `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy` - ); - health = "unhealthy"; - } else { - logger.debug( - `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy` - ); - health = "degraded"; - } - - if (health != resource.health) { - // it changed - await trx - .update(resources) - .set({ health }) - .where(eq(resources.resourceId, resource.resourceId)); - - if (health === "unknown") { - await fireResourceUnknownAlert( - orgId, - resource.resourceId, - resource.name, - undefined, - send, - trx - ); - } else if (health === "unhealthy") { - await fireResourceUnhealthyAlert( - orgId, - resource.resourceId, - resource.name, - undefined, - send, - trx - ); - } else if (health === "healthy") { - await fireResourceHealthyAlert( - orgId, - resource.resourceId, - resource.name, - undefined, - send, - trx - ); - } else if (health === "degraded") { - await fireResourceDegradedAlert( - orgId, - resource.resourceId, - resource.name, - undefined, - send, - trx - ); - } - } -} diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts deleted file mode 100644 index 54b40b80d..000000000 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ /dev/null @@ -1,256 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import logger from "@server/logger"; -import { processAlerts } from "../processAlerts"; -import { db, logsDb, statusHistory, Transaction } from "@server/db"; -import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Fire a `resource_healthy` alert for the given resource. - * - * Call this after a previously-unhealthy resource has recovered so that any - * matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the resource. - * @param resourceId - Numeric primary key of the resource. - * @param resourceName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireResourceHealthyAlert( - orgId: string, - resourceId: number, - resourceName?: string | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "resource", - entityId: resourceId, - orgId: orgId, - status: "healthy", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("resource", resourceId); - - if (!send) { - return; - } - - await processAlerts({ - eventType: "resource_healthy", - orgId, - resourceId, - data: { - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "resource_toggle", - orgId, - resourceId, - data: { - resourceId, - status: "healthy", - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, - err - ); - } -} - -/** - * Fire a `resource_unhealthy` alert for the given resource. - * - * Call this after a resource has been detected as unhealthy so that any - * matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the resource. - * @param resourceId - Numeric primary key of the resource. - * @param resourceName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireResourceUnhealthyAlert( - orgId: string, - resourceId: number, - resourceName?: string | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "resource", - entityId: resourceId, - orgId: orgId, - status: "unhealthy", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("resource", resourceId); - - if (!send) { - return; - } - - await processAlerts({ - eventType: "resource_unhealthy", - orgId, - resourceId, - data: { - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "resource_toggle", - orgId, - resourceId, - data: { - resourceId, - status: "unhealthy", - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, - err - ); - } -} - -/** - * Fire a `resource_degraded` alert for the given resource. - * - * Call this after a resource has been detected as degraded so that any - * matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the resource. - * @param resourceId - Numeric primary key of the resource. - * @param resourceName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireResourceDegradedAlert( - orgId: string, - resourceId: number, - resourceName?: string | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "resource", - entityId: resourceId, - orgId: orgId, - status: "degraded", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("resource", resourceId); - - if (!send) { - return; - } - - await processAlerts({ - eventType: "resource_degraded", - orgId, - resourceId, - data: { - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "resource_toggle", - orgId, - resourceId, - data: { - resourceId, - status: "degraded", - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`, - err - ); - } -} - -/** - * Fire a `resource_unknown` alert for the given resource. - * - * Call this when all health checks on a resource are disabled so that the - * resource status transitions to unknown. - * - * @param orgId - Organisation that owns the resource. - * @param resourceId - Numeric primary key of the resource. - * @param resourceName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireResourceUnknownAlert( - orgId: string, - resourceId: number, - resourceName?: string | null, - extra?: Record, - send: boolean = true, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "resource", - entityId: resourceId, - orgId: orgId, - status: "unknown", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("resource", resourceId); - - if (!send) { - return; - } - - await processAlerts({ - eventType: "resource_toggle", - orgId, - resourceId, - data: { - resourceId, - status: "unknown", - ...(resourceName != null ? { resourceName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`, - err - ); - } -} diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts deleted file mode 100644 index e1871dc85..000000000 --- a/server/private/lib/alerts/events/siteEvents.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import logger from "@server/logger"; -import { processAlerts } from "../processAlerts"; -import { - db, - logsDb, - statusHistory, - targetHealthCheck, - Transaction -} from "@server/db"; -import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; -import { and, eq, inArray } from "drizzle-orm"; -import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Fire a `site_online` alert for the given site. - * - * Call this after the site has been confirmed reachable / connected so that - * any matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the site. - * @param siteId - Numeric primary key of the site. - * @param siteName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireSiteOnlineAlert( - orgId: string, - siteId: number, - siteName?: string, - extra?: Record, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "site", - entityId: siteId, - orgId: orgId, - status: "online", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("site", siteId); - - await processAlerts({ - eventType: "site_online", - orgId, - siteId, - data: { - ...(siteName != null ? { siteName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "site_toggle", - orgId, - siteId, - data: { - siteId, - status: "online", - ...(siteName != null ? { siteName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`, - err - ); - } -} - -/** - * Fire a `site_offline` alert for the given site. - * - * Call this after the site has been detected as unreachable / disconnected so - * that any matching `alertRules` can dispatch their email and webhook actions. - * - * @param orgId - Organisation that owns the site. - * @param siteId - Numeric primary key of the site. - * @param siteName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. - */ -export async function fireSiteOfflineAlert( - orgId: string, - siteId: number, - siteName?: string, - extra?: Record, - trx: Transaction | typeof db = db -): Promise { - try { - await logsDb.insert(statusHistory).values({ - entityType: "site", - entityId: siteId, - orgId: orgId, - status: "offline", - timestamp: Math.floor(Date.now() / 1000) - }); - await invalidateStatusHistoryCache("site", siteId); - - const unhealthyHealthChecks = await trx - .update(targetHealthCheck) - .set({ hcHealth: "unhealthy" }) - .where( - and( - eq(targetHealthCheck.orgId, orgId), - eq(targetHealthCheck.siteId, siteId), - eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled - ) - ) - .returning(); - - for (const healthCheck of unhealthyHealthChecks) { - logger.info( - `Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline` - ); - - await fireHealthCheckUnhealthyAlert( - healthCheck.orgId, - healthCheck.targetHealthCheckId, - healthCheck.name, - healthCheck.targetId, // for the resource if we have one - undefined, - true, - trx - ); - } - - await processAlerts({ - eventType: "site_offline", - orgId, - siteId, - data: { - ...(siteName != null ? { siteName } : {}), - ...extra - } - }); - await processAlerts({ - eventType: "site_toggle", - orgId, - siteId, - data: { - siteId, - status: "offline", - ...(siteName != null ? { siteName } : {}), - ...extra - } - }); - } catch (err) { - logger.error( - `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`, - err - ); - } -} diff --git a/server/private/lib/alerts/index.ts b/server/private/lib/alerts/index.ts index 7f34aea34..04b4763d0 100644 --- a/server/private/lib/alerts/index.ts +++ b/server/private/lib/alerts/index.ts @@ -14,6 +14,3 @@ export * from "./processAlerts"; export * from "./sendAlertWebhook"; export * from "./sendAlertEmail"; -export * from "./events/siteEvents"; -export * from "./events/healthCheckEvents"; -export * from "./events/resourceEvents"; diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 3975eb09f..dd5088a6c 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -42,17 +42,23 @@ export async function sendAlertWebhook( webhookConfig: WebhookAlertConfig, context: AlertContext ): Promise { - const payload = { - event: context.eventType, - timestamp: new Date().toISOString(), - status: deriveStatus(context.eventType, context.data), - data: { - orgId: context.orgId, - ...context.data - } - }; + const eventType = context.eventType; + const timestamp = new Date().toISOString(); + const status = deriveStatus(eventType, context.data); + const data = { orgId: context.orgId, ...context.data }; + + let body: string; + if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) { + body = renderTemplate(webhookConfig.bodyTemplate, { + event: eventType, + timestamp, + status, + data + }); + } else { + body = JSON.stringify({ event: eventType, timestamp, status, data }); + } - const body = JSON.stringify(payload); const headers = buildHeaders(webhookConfig); let lastError: Error | undefined; @@ -217,3 +223,52 @@ function buildHeaders( return headers; } + +// --------------------------------------------------------------------------- +// Body template rendering +// --------------------------------------------------------------------------- + +interface TemplateContext { + event: string; + timestamp: string; + status: string; + data: Record; +} + +/** + * Render a body template with {{event}}, {{timestamp}}, {{status}}, and + * {{data}} placeholders, mirroring the logic in HttpLogDestination. + * + * {{data}} is replaced first (as raw JSON) so that any literal "{{…}}" + * strings inside data values are not re-expanded. + */ +function renderTemplate(template: string, ctx: TemplateContext): string { + const rendered = template + .replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)) + .replace(/\{\{event\}\}/g, escapeJsonString(ctx.event)) + .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp)) + .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status)); + + // Validate the rendered result is valid JSON; if not, log a warning and + // fall back to the default payload so the webhook still fires. + try { + JSON.parse(rendered); + return rendered; + } catch { + logger.warn( + `sendAlertWebhook: body template produced invalid JSON for event ` + + `"${ctx.event}" destined for a webhook. Falling back to default ` + + `payload. Check that {{data}} is NOT wrapped in quotes in your template.` + ); + return JSON.stringify({ + event: ctx.event, + timestamp: ctx.timestamp, + status: ctx.status, + data: ctx.data + }); + } +} + +function escapeJsonString(value: string): string { + return JSON.stringify(value).slice(1, -1); +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index e79db2ef5..36a71026d 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -45,6 +45,10 @@ export interface WebhookAlertConfig { headers?: Array<{ key: string; value: string }>; /** HTTP method (default POST) */ method?: string; + /** Whether to use a custom body template */ + useBodyTemplate?: boolean; + /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */ + bodyTemplate?: string; } // --------------------------------------------------------------------------- @@ -60,4 +64,4 @@ export interface AlertContext { healthCheckId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; -} \ No newline at end of file +} diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index 1dc9f83a4..9df9b3b74 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -19,12 +19,13 @@ import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: Tier | null; active: boolean }> { +): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> { let tier: Tier | null = null; let active = false; + let isTrial = false; if (build !== "saas") { - return { tier, active }; + return { tier, active, isTrial }; } try { @@ -35,7 +36,7 @@ export async function getOrgTierData( .limit(1); if (!org) { - return { tier, active }; + return { tier, active, isTrial }; } let orgIdToUse = org.orgId; @@ -44,7 +45,7 @@ export async function getOrgTierData( logger.warn( `Org ${orgId} is not a billing org and does not have a billingOrgId` ); - return { tier, active }; + return { tier, active, isTrial }; } orgIdToUse = org.billingOrgId; } @@ -57,7 +58,7 @@ export async function getOrgTierData( .limit(1); if (!customer) { - return { tier, active }; + return { tier, active, isTrial }; } // Query for active subscriptions that are not license type @@ -84,11 +85,13 @@ export async function getOrgTierData( tier = subscription.type; active = true; } + + isTrial = subscription.trial ?? false; } } catch (error) { // If org not found or error occurs, return null tier and inactive // This is acceptable behavior as per the function signature } - return { tier, active }; + return { tier, active, isTrial }; } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 056624159..63ca0b068 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -21,173 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); -export const privateConfigSchema = z.object({ - app: z - .object({ - region: z.string().optional().default("default"), - base_domain: z.string().optional(), - identity_provider_mode: z.enum(["global", "org"]).optional() - }) - .optional() - .default({ - region: "default" - }), - server: z - .object({ - reo_client_id: z - .string() - .optional() - .transform(getEnvOrYaml("REO_CLIENT_ID")), - fossorial_api: z - .string() - .optional() - .default("https://api.fossorial.io"), - fossorial_api_key: z - .string() - .optional() - .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) - }) - .optional() - .prefault({}), - redis: z - .object({ - host: z.string(), - port: portSchema, - password: z - .string() - .optional() - .transform(getEnvOrYaml("REDIS_PASSWORD")), - db: z.int().nonnegative().optional().default(0), - replicas: z - .array( - z.object({ - host: z.string(), - port: portSchema, - password: z.string().optional(), - db: z.int().nonnegative().optional().default(0) +export const privateConfigSchema = z + .object({ + app: z + .object({ + region: z.string().optional().default("default"), + base_domain: z.string().optional(), + identity_provider_mode: z.enum(["global", "org"]).optional() + }) + .optional() + .default({ + region: "default" + }), + server: z + .object({ + reo_client_id: z + .string() + .optional() + .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), + fossorial_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) + }) + .optional() + .prefault({}), + redis: z + .object({ + host: z.string(), + port: portSchema, + password: z + .string() + .optional() + .transform(getEnvOrYaml("REDIS_PASSWORD")), + db: z.int().nonnegative().optional().default(0), + replicas: z + .array( + z.object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.int().nonnegative().optional().default(0) + }) + ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z.boolean().optional().default(true) }) - ) - .optional(), - tls: z - .object({ - rejectUnauthorized: z - .boolean() - .optional() - .default(true) - }) - .optional() - }) - .optional(), - gerbil: z - .object({ - local_exit_node_reachable_at: z - .string() - .optional() - .default("http://gerbil:3004") - }) - .optional() - .prefault({}), - flags: z - .object({ - enable_redis: z.boolean().optional().default(false), - use_pangolin_dns: z.boolean().optional().default(false), - use_org_only_idp: z.boolean().optional(), - enable_acme_cert_sync: z.boolean().optional().default(true) - }) - .optional() - .prefault({}), - acme: z - .object({ - acme_json_path: z - .string() - .optional() - .default("config/letsencrypt/acme.json"), - sync_interval_ms: z.number().optional().default(5000) - }) - .optional(), - branding: z - .object({ - app_name: z.string().optional(), - background_image_path: z.string().optional(), - colors: z - .object({ - light: colorsSchema.optional(), - dark: colorsSchema.optional() - }) - .optional(), - logo: z - .object({ - light_path: z.string().optional(), - dark_path: z.string().optional(), - auth_page: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional(), - navbar: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional() - }) - .optional(), - footer: z - .array( - z.object({ - text: z.string(), - href: z.string().optional() + .optional() + }) + .optional(), + gerbil: z + .object({ + local_exit_node_reachable_at: z + .string() + .optional() + .default("http://gerbil:3004") + }) + .optional() + .prefault({}), + flags: z + .object({ + enable_redis: z.boolean().optional().default(false), + use_pangolin_dns: z.boolean().optional().default(false), + use_org_only_idp: z.boolean().optional(), + enable_acme_cert_sync: z.boolean().optional().default(true) + }) + .optional() + .prefault({}), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + acme_http_endpoint: z.string().optional(), + sync_interval_ms: z.number().optional().default(5000) + }) + .optional(), + branding: z + .object({ + app_name: z.string().optional(), + background_image_path: z.string().optional(), + colors: z + .object({ + light: colorsSchema.optional(), + dark: colorsSchema.optional() }) - ) - .optional(), - hide_auth_layout_footer: z.boolean().optional().default(false), - login_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - signup_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - resource_auth_page: z - .object({ - show_logo: z.boolean().optional(), - hide_powered_by: z.boolean().optional(), - title_text: z.string().optional(), - subtitle_text: z.string().optional() - }) - .optional(), - emails: z - .object({ - signature: z.string().optional(), - colors: z - .object({ - primary: z.string().optional() + .optional(), + logo: z + .object({ + light_path: z.string().optional(), + dark_path: z.string().optional(), + auth_page: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional(), + navbar: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }) + .optional(), + footer: z + .array( + z.object({ + text: z.string(), + href: z.string().optional() }) - .optional() - }) - .optional() - }) - .optional(), - stripe: z - .object({ - secret_key: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), - webhook_secret: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), - // s3Bucket: z.string(), - // s3Region: z.string().default("us-east-1"), - // localFilePath: z.string().optional() - }) - .optional() -}) + ) + .optional(), + hide_auth_layout_footer: z.boolean().optional().default(false), + login_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + signup_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + resource_auth_page: z + .object({ + show_logo: z.boolean().optional(), + hide_powered_by: z.boolean().optional(), + title_text: z.string().optional(), + subtitle_text: z.string().optional() + }) + .optional(), + emails: z + .object({ + signature: z.string().optional(), + colors: z + .object({ + primary: z.string().optional() + }) + .optional() + }) + .optional() + }) + .optional(), + stripe: z + .object({ + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + webhook_secret: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")) + // s3Bucket: z.string(), + // s3Region: z.string().default("us-east-1"), + // localFilePath: z.string().optional() + }) + .optional() + }) .transform((data) => { // this to maintain backwards compatibility with the old config file const identityProviderMode = data.app?.identity_provider_mode; diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts index 530557463..18761b568 100644 --- a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -24,7 +24,7 @@ import { eq, and } from "drizzle-orm"; import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert -} from "#private/lib/alerts/events/healthCheckEvents"; +} from "@server/lib/alerts"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty(), @@ -73,10 +73,7 @@ export async function triggerHealthCheckAlert( .from(targetHealthCheck) .where( and( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheckId - ), + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), eq(targetHealthCheck.orgId, orgId) ) ) diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts index afda63e9a..3c2f8fb96 100644 --- a/server/private/routers/alertEvents/triggerResourceAlert.ts +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -25,7 +25,7 @@ import { fireResourceHealthyAlert, fireResourceUnhealthyAlert, fireResourceDegradedAlert -} from "#private/lib/alerts/events/resourceEvents"; +} from "@server/lib/alerts"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty(), diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts index 25b14acb9..b9f182887 100644 --- a/server/private/routers/alertEvents/triggerSiteAlert.ts +++ b/server/private/routers/alertEvents/triggerSiteAlert.ts @@ -21,10 +21,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; -import { - fireSiteOnlineAlert, - fireSiteOfflineAlert -} from "#private/lib/alerts/events/siteEvents"; +import { fireSiteOnlineAlert, fireSiteOfflineAlert } from "@server/lib/alerts"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty(), diff --git a/server/private/routers/billing/hooks/handleCustomerCreated.ts b/server/private/routers/billing/hooks/handleCustomerCreated.ts index 66ad3a4fa..79dbcea35 100644 --- a/server/private/routers/billing/hooks/handleCustomerCreated.ts +++ b/server/private/routers/billing/hooks/handleCustomerCreated.ts @@ -16,6 +16,7 @@ import { customers, db, subscriptions } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { generateId } from "@server/auth/sessions/app"; +import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; export async function handleCustomerCreated( customer: Stripe.Customer @@ -62,6 +63,13 @@ export async function handleCustomerCreated( expiresAt: trialExpiresAt, trial: true }); + + // update to the business limits for the trial + await handleSubscriptionLifesycle( + customer.metadata.orgId, + "active", + "tier3" + ); }); logger.info(`Customer with ID ${customer.id} created successfully.`); diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index 76fb6ec8e..b993a4e1a 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -44,7 +44,7 @@ function getLimitSetForSubscriptionType( export async function handleSubscriptionLifesycle( orgId: string, status: string, - subType: SubscriptionType | null + subType: SubscriptionType | null = null ) { switch (status) { case "active": diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts index 60ca2072a..2f2e50fdc 100644 --- a/server/private/routers/certificates/createCertificate.ts +++ b/server/private/routers/certificates/createCertificate.ts @@ -79,7 +79,7 @@ export async function createCertificate( let domainToWrite = domain; if ( - domainRecord.type == "wildcard" && + domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD domainRecord.preferWildcardCert && !domain.startsWith("*.") ) { @@ -89,6 +89,15 @@ export async function createCertificate( domainToWrite = parts.slice(1).join("."); domainToWrite = `*.${domainToWrite}`; } + } else if (domainRecord.type == "ns") { + if (domain == domainRecord.baseDomain) { + domainToWrite = domainRecord.baseDomain; + } else { + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + } + } } // No cert found, create a new one in pending state diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index ead58e996..0fa5a77e9 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -22,7 +22,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; -import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts"; +import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index 8afeca6a4..4df92a5a7 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -22,7 +22,11 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, isNull } from "drizzle-orm"; import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; -import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckHealthyAlert } from "#private/lib/alerts"; +import { + fireHealthCheckUnhealthyAlert, + fireHealthCheckUnknownAlert, + fireHealthCheckHealthyAlert +} from "@server/lib/alerts"; const paramsSchema = z .object({ @@ -234,7 +238,10 @@ export async function updateHealthCheck( ) .returning(); - if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") { + if ( + updated.hcHealth === "unhealthy" && + existingHealthCheck.hcHealth !== "unhealthy" + ) { await fireHealthCheckUnhealthyAlert( updated.orgId, updated.targetHealthCheckId, @@ -243,7 +250,10 @@ export async function updateHealthCheck( undefined, false // dont send the alert because we just want to create the alert, not notify users yet ); - } else if (updated.hcHealth === "unknown" && existingHealthCheck.hcHealth !== "unknown") { + } else if ( + updated.hcHealth === "unknown" && + existingHealthCheck.hcHealth !== "unknown" + ) { // if the health is unknown, we want to fire an alert to notify users to enable health checks await fireHealthCheckUnknownAlert( updated.orgId, @@ -253,7 +263,10 @@ export async function updateHealthCheck( undefined, false // dont send the alert because we just want to create the alert, not notify users yet ); - } else if (updated.hcHealth === "healthy" && existingHealthCheck.hcHealth !== "healthy") { + } else if ( + updated.hcHealth === "healthy" && + existingHealthCheck.hcHealth !== "healthy" + ) { await fireHealthCheckHealthyAlert( updated.orgId, updated.targetHealthCheckId, @@ -264,7 +277,6 @@ export async function updateHealthCheck( ); } - // Push updated health check to newt if the site is a newt site const [newt] = await db .select() diff --git a/server/private/routers/org/sendTrialNotification.ts b/server/private/routers/org/sendTrialNotification.ts index c3b7f6518..233010064 100644 --- a/server/private/routers/org/sendTrialNotification.ts +++ b/server/private/routers/org/sendTrialNotification.ts @@ -24,13 +24,18 @@ import { fromError } from "zod-validation-error"; import { sendEmail } from "@server/emails"; import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring"; import config from "@server/lib/config"; +import { handleSubscriptionLifesycle } from "../billing/subscriptionLifecycle"; const sendTrialNotificationParamsSchema = z.object({ orgId: z.string() }); const sendTrialNotificationBodySchema = z.object({ - notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]), + notificationType: z.enum([ + "trial_ending_5d", + "trial_ending_24h", + "trial_ended" + ]), orgName: z.string(), trialEndsAt: z.number(), billingLink: z.string().optional() @@ -69,9 +74,7 @@ async function getOrgAdmins(orgId: string) { ) ); - const byUserId = new Map( - admins.map((a) => [a.userId, a]) - ); + const byUserId = new Map(admins.map((a) => [a.userId, a])); const orgAdmins = Array.from(byUserId.values()).filter( (admin) => admin.email && admin.email.length > 0 ); @@ -108,8 +111,12 @@ export async function sendTrialNotification( } const { orgId } = parsedParams.data; - const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } = - parsedBody.data; + const { + notificationType, + orgName, + trialEndsAt, + billingLink: bodyBillingLink + } = parsedBody.data; // Verify organization exists const org = await db @@ -146,13 +153,17 @@ export async function sendTrialNotification( bodyBillingLink ?? `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; - const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString( - "en-US", - { year: "numeric", month: "long", day: "numeric" } - ); + const trialEndsAtFormatted = new Date( + trialEndsAt * 1000 + ).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); let daysRemaining: number | null; let subject: string; + let resetLimits = false; if (notificationType === "trial_ending_5d") { daysRemaining = 5; @@ -163,6 +174,7 @@ export async function sendTrialNotification( } else { daysRemaining = null; subject = "Your trial has ended"; + resetLimits = true; } let emailsSent = 0; @@ -201,6 +213,14 @@ export async function sendTrialNotification( } } + if (resetLimits) { + // this will only fire if they have not upgraded yet because when upgrading we delete the trial + await handleSubscriptionLifesycle(orgId, "cancled"); + logger.debug( + `Trial ended for org ${orgId}, limits reset to free tier` + ); + } + return response(res, { data: { success: true, @@ -221,4 +241,4 @@ export async function sendTrialNotification( ) ); } -} \ No newline at end of file +} diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts index 54001432f..061be1792 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts @@ -22,6 +22,91 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; +import cache from "#private/lib/cache"; +import semver from "semver"; + +let stalePangolinNodeVersion: string | null = null; + +async function getLatestPangolinNodeVersion(): Promise { + try { + const cachedVersion = await cache.get( + "cache:latestPangolinNodeVersion" + ); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const res = await fetch( + "https://api.github.com/repos/fosrl/pangolin-node/tags", + { signal: controller.signal } + ); + + clearTimeout(timeoutId); + + if (!res.ok) { + logger.warn( + `Failed to fetch latest pangolin-node version from GitHub: ${res.status} ${res.statusText}` + ); + return stalePangolinNodeVersion; + } + + let tags = await res.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for pangolin-node repository"); + return stalePangolinNodeVersion; + } + + tags = tags.filter((tag: any) => !tag.name.includes("rc")); + tags.sort((a: any, b: any) => { + const va = semver.coerce(a.name); + const vb = semver.coerce(b.name); + if (!va && !vb) return 0; + if (!va) return 1; + if (!vb) return -1; + return semver.rcompare(va, vb); + }); + + const seen = new Set(); + tags = tags.filter((tag: any) => { + const normalised = semver.coerce(tag.name)?.version; + if (!normalised || seen.has(normalised)) return false; + seen.add(normalised); + return true; + }); + + if (tags.length === 0) { + logger.warn( + "No valid semver tags found for pangolin-node repository" + ); + return stalePangolinNodeVersion; + } + + const latestVersion = tags[0].name; + stalePangolinNodeVersion = latestVersion; + await cache.set("cache:latestPangolinNodeVersion", latestVersion, 3600); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest pangolin-node version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest pangolin-node version" + ); + } else { + logger.warn( + "Error fetching latest pangolin-node version:", + error.message || error + ); + } + return stalePangolinNodeVersion; + } +} const listRemoteExitNodesParamsSchema = z.strictObject({ orgId: z.string() @@ -118,9 +203,41 @@ export async function listRemoteExitNodes( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + const latestPangolinNodeVersionPromise = getLatestPangolinNodeVersion(); + + const nodesWithUpdates = remoteExitNodesList.map((node) => ({ + ...node, + updateAvailable: false + })); + + try { + const latestPangolinNodeVersion = + await latestPangolinNodeVersionPromise; + + if (latestPangolinNodeVersion) { + nodesWithUpdates.forEach((node) => { + if (node.version) { + try { + node.updateAvailable = semver.lt( + node.version, + latestPangolinNodeVersion + ); + } catch { + node.updateAvailable = false; + } + } + }); + } + } catch (error) { + logger.warn( + "Failed to check for pangolin-node updates, continuing without update info:", + error + ); + } + return response(res, { data: { - remoteExitNodes: remoteExitNodesList, + remoteExitNodes: nodesWithUpdates, pagination: { total: totalCount, limit, diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index e3f92591d..ebffd3c5b 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -80,6 +80,10 @@ export interface WebhookAlertConfig { headers?: Array<{ key: string; value: string }>; /** HTTP method (default POST) */ method?: string; + /** Whether to use a custom body template */ + useBodyTemplate?: boolean; + /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */ + bodyTemplate?: string; } // --------------------------------------------------------------------------- diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index b824e582b..07bdf883d 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -104,8 +104,9 @@ export async function deleteMyAccount( (r) => r.isBillingOrg && r.isOwner )?.orgId; if (primaryOrgId) { - const { tier, active } = await getOrgTierData(primaryOrgId); - if (active && tier) { + const { tier, active, isTrial } = + await getOrgTierData(primaryOrgId); + if (active && tier && !isTrial) { return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index e2e5f6766..d3c110728 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1003,7 +1003,11 @@ async function checkRules( isIpInCidr(clientIp, rule.value) ) { return rule.action as any; - } else if (clientIp && rule.match == "IP" && clientIp == rule.value) { + } else if ( + clientIp && + rule.match == "IP" && + clientIp == rule.value + ) { return rule.action as any; } else if ( path && @@ -1013,16 +1017,35 @@ async function checkRules( return rule.action as any; } else if ( clientIp && - rule.match == "COUNTRY" && - (await isIpInGeoIP(ipCC, rule.value)) + rule.match == "COUNTRY" ) { - return rule.action as any; + // COUNTRY=ALL should not affect local/private/CGNAT addresses. + if ( + rule.value.toUpperCase() === "ALL" && + isLocalOrCarrierGradeNatIp(clientIp) + ) { + continue; + } + + if (await isIpInGeoIP(ipCC, rule.value)) { + return rule.action as any; + } } else if ( clientIp && - rule.match == "ASN" && - (await isIpInAsn(ipAsn, rule.value)) + rule.match == "ASN" ) { - return rule.action as any; + // ASN=ALL/AS0 should not affect local/private/CGNAT addresses. + if ( + (rule.value.toUpperCase() === "ALL" || + rule.value.toUpperCase() === "AS0") && + isLocalOrCarrierGradeNatIp(clientIp) + ) { + continue; + } + + if (await isIpInAsn(ipAsn, rule.value)) { + return rule.action as any; + } } else if ( clientIp && rule.match == "REGION" && @@ -1184,6 +1207,26 @@ async function isIpInGeoIP( return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); } +function isLocalOrCarrierGradeNatIp(ip: string): boolean { + const localAndCgnatCidrs = [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10" + ]; + + try { + return localAndCgnatCidrs.some((cidr) => isIpInCidr(ip, cidr)); + } catch { + return false; + } +} + async function isIpInAsn( ipAsn: number | undefined, checkAsn: string diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 7c9e53cf2..fc8e9b3da 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -38,10 +38,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { - assignUserToOrg, - removeUserFromOrg -} from "@server/lib/userOrg"; +import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg"; import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; const ensureTrailingSlash = (url: string): string => { @@ -336,32 +333,23 @@ export async function validateOidcCallback( .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); - // TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1 - if (allOrgs.length > 1) { - // for some reason there is more than one org - logger.error( - "More than one organization linked to this IdP. This should not happen with auto-provisioning enabled." - ); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Multiple organizations linked to this IdP. Please contact support." - ) - ); - } + // for (const org of allOrgs) { + // const subscribed = await isSubscribed( + // org.orgId, + // tierMatrix.autoProvisioning + // ); + // if (!subscribed) { + // // filter out the org + // allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId); - const subscribed = await isSubscribed( - allOrgs[0].orgId, - tierMatrix.autoProvisioning - ); - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } + // // return next( + // // createHttpError( + // // HttpCode.FORBIDDEN, + // // "This organization's current plan does not support this feature." + // // ) + // // ); + // } + // } } else { allOrgs = await db.select().from(orgs); } @@ -405,16 +393,14 @@ export async function validateOidcCallback( idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { logger.debug("Role Mapping", { roleMapping }); - const roleMappingJmes = unwrapRoleMapping( - roleMapping - ).evaluationExpression; + const roleMappingJmes = + unwrapRoleMapping(roleMapping).evaluationExpression; const roleMappingResult = jmespath.search( claims, roleMappingJmes ); - const roleNames = normalizeRoleMappingResult( - roleMappingResult - ); + const roleNames = + normalizeRoleMappingResult(roleMappingResult); const supportsMultiRole = await isLicensedOrSubscribed( org.orgId, @@ -524,7 +510,7 @@ export async function validateOidcCallback( } } - const orgUserCounts: { orgId: string; userCount: number }[] = []; + const orgUserCounts: { orgId: string; userCount: number }[] = []; // sync the user with the orgs and roles await db.transaction(async (trx) => { @@ -637,7 +623,7 @@ export async function validateOidcCallback( { orgId: org.orgId, userId: userId!, - autoProvisioned: true, + autoProvisioned: true }, org.roleIds, trx @@ -767,9 +753,7 @@ function hydrateOrgMapping( return orgMapping.split("{{orgId}}").join(orgId); } -function normalizeRoleMappingResult( - result: unknown -): string[] { +function normalizeRoleMappingResult(result: unknown): string[] { if (typeof result === "string") { const role = result.trim(); return role ? [role] : []; @@ -779,7 +763,9 @@ function normalizeRoleMappingResult( return [ ...new Set( result - .filter((value): value is string => typeof value === "string") + .filter( + (value): value is string => typeof value === "string" + ) .map((value) => value.trim()) .filter(Boolean) ) diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index a05d410c8..a2b963fc9 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -1,12 +1,8 @@ import { MessageHandler } from "@server/routers/ws"; -import { - db, - Newt, - sites -} from "@server/db"; +import { db, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; -import { fireSiteOfflineAlert } from "#dynamic/lib/alerts"; +import { fireSiteOfflineAlert } from "@server/lib/alerts"; /** * Handles disconnecting messages from sites to show disconnected in the ui @@ -38,7 +34,13 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( .where(eq(sites.siteId, newt.siteId!)) .returning(); - await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx); + await fireSiteOfflineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); }); } catch (error) { logger.error("Error handling disconnecting message", { error }); diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 6ff43688a..0d9148509 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -1,12 +1,8 @@ -import { - db, - newts, - sites -} from "@server/db"; +import { db, newts, sites } from "@server/db"; import { hasActiveConnections } from "#dynamic/routers/ws"; import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm"; import logger from "@server/logger"; -import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts"; +import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 307565723..5351c6723 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -2,7 +2,7 @@ import { db } from "@server/db"; import { sites, clients, olms } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; -import { fireSiteOnlineAlert } from "#dynamic/lib/alerts"; +import { fireSiteOnlineAlert } from "@server/lib/alerts"; /** * Ping Accumulator @@ -127,7 +127,11 @@ async function flushSitePingsToDb(): Promise { eq(sites.online, false) ) ) - .returning({ siteId: sites.siteId, orgId: sites.orgId, name: sites.name }); + .returning({ + siteId: sites.siteId, + orgId: sites.orgId, + name: sites.name + }); // Update lastPing for sites that were already online. // After the update above, the newly-online sites now have @@ -148,7 +152,13 @@ async function flushSitePingsToDb(): Promise { for (const site of newlyOnlineSites) { await db.transaction(async (trx) => { - await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx); + await fireSiteOnlineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); }); } } catch (error) { diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 25a7d6c53..9984b1b4f 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = { remoteExitNodeId: string; dateCreated: string; version: string | null; + updateAvailable?: boolean; exitNodeId: number | null; name: string; address: string; diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 45d49abe6..a16547b8d 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) { } } -export type GetSiteResponse = NonNullable< - Awaited> ->["sites"] & { newtId: string | null }; +type SiteQueryRow = NonNullable>>; + +export type GetSiteResponse = SiteQueryRow["sites"] & { + newtId: string | null; + newtVersion: string | null; +}; registry.registerPath({ method: "get", @@ -100,7 +103,8 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, - newtId: site.newt ? site.newt.newtId : null + newtId: site.newt ? site.newt.newtId : null, + newtVersion: site.newt?.version ?? null }; return response(res, { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 0da48d160..01f7a0d9c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -496,11 +496,6 @@ export async function createSiteResource( ); } } - - await rebuildClientAssociationsFromSiteResource( - newSiteResource, - trx - ); // we need to call this because we added to the admin role }); if (!newSiteResource) { @@ -526,6 +521,22 @@ export async function createSiteResource( await createCertificate(domainId, fullDomain, db); } + // Run in the background after the response is sent. Wrapped in its + // own transaction so it always executes on the primary — avoiding any + // replica-lag issues while still allowing the HTTP response to return + // early. + db.transaction(async (trx) => { + await rebuildClientAssociationsFromSiteResource( + newSiteResource!, + trx + ); + }).catch((err) => { + logger.error( + `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, + err + ); + }); + return response(res, { data: newSiteResource, success: true, diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index df43d5c25..7dbb111ad 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -63,17 +63,26 @@ export async function deleteSiteResource( ); } - await db.transaction(async (trx) => { - // Delete the site resource - const [removedSiteResource] = await trx - .delete(siteResources) - .where(eq(siteResources.siteResourceId, siteResourceId)) - .returning(); + // Delete the site resource + const [removedSiteResource] = await db + .delete(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .returning(); + // Run in the background after the response is sent. Wrapped in its + // own transaction so it always executes on the primary — avoiding any + // replica-lag issues while still allowing the HTTP response to return + // early. + db.transaction(async (trx) => { await rebuildClientAssociationsFromSiteResource( removedSiteResource, trx ); + }).catch((err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, + err + ); }); logger.info(`Deleted site resource ${siteResourceId}`); diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index d0efa0cf4..8a3f93326 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -431,9 +431,6 @@ export async function updateSiteResource( }) .returning(); - // wait some time to allow for messages to be handled - await new Promise((resolve) => setTimeout(resolve, 750)); - const sshPamSet = isLicensedSshPam && (authDaemonPort !== undefined || @@ -556,11 +553,6 @@ export async function updateSiteResource( })) ); } - - await rebuildClientAssociationsFromSiteResource( - updatedSiteResource, - trx - ); } else { // Update the site resource const sshPamSet = @@ -690,7 +682,24 @@ export async function updateSiteResource( } logger.info(`Updated site resource ${siteResourceId}`); + } + }); + // Background: wait for removal messages to propagate, then rebuild + // associations for the re-created resource. Own transaction ensures + // execution on the primary against fully committed state. + (async () => { + await db.transaction(async (trx) => { + if (!updatedSiteResource) { + throw new Error("No updated resource found after update"); + } + if (sitesChanged) { + await new Promise((resolve) => setTimeout(resolve, 750)); + await rebuildClientAssociationsFromSiteResource( + updatedSiteResource, + trx + ); + } await handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, @@ -700,7 +709,12 @@ export async function updateSiteResource( })), trx ); - } + }); + })().catch((err) => { + logger.error( + `Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`, + err + ); }); return response(res, { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index d582d06da..c629e378e 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -23,7 +23,7 @@ import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert -} from "#dynamic/lib/alerts"; +} from "@server/lib/alerts"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index e5f286524..61a927d3e 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -6,7 +6,7 @@ import logger from "@server/logger"; import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert -} from "#dynamic/lib/alerts"; +} from "@server/lib/alerts"; interface TargetHealthStatus { status: string; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 92c434a19..4533dc2e5 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,7 +10,11 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; -import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnknownAlert, + fireHealthCheckUnhealthyAlert +} from "@server/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -169,7 +173,7 @@ export async function updateTarget( let updatedTarget: any; let updatedHc: any; await db.transaction(async (trx) => { - [updatedTarget] = await trx + [updatedTarget] = await trx .update(targets) .set({ siteId: parsedBody.data.siteId, @@ -181,8 +185,12 @@ export async function updateTarget( path: parsedBody.data.path, pathMatchType: parsedBody.data.pathMatchType, priority: parsedBody.data.priority, - rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, - rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType + rewritePath: pathMatchTypeRemoved + ? null + : parsedBody.data.rewritePath, + rewritePathType: pathMatchTypeRemoved + ? null + : parsedBody.data.rewritePathType }) .where(eq(targets.targetId, targetId)) .returning(); @@ -213,7 +221,8 @@ export async function updateTarget( // If hcEnabled is being turned on (was false, now true), set to "unhealthy" // so the target must pass a health check before being considered healthy. const hcEnabledTurnedOn = - parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; + parsedBody.data.hcEnabled === true && + existingHc.hcEnabled === false; let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; if ( @@ -253,7 +262,10 @@ export async function updateTarget( .where(eq(targetHealthCheck.targetId, targetId)) .returning(); - if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { + if ( + updatedHc.hcHealth === "unhealthy" && + existingHc.hcHealth !== "unhealthy" + ) { logger.debug( `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert` ); @@ -266,7 +278,10 @@ export async function updateTarget( false, // dont send the alert because we just want to create the alert, not notify users yet trx ); - } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { + } else if ( + updatedHc.hcHealth === "unknown" && + existingHc.hcHealth !== "unknown" + ) { logger.debug( `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert` ); @@ -280,7 +295,10 @@ export async function updateTarget( false, // dont send the alert because we just want to create the alert, not notify users yet trx ); - } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { + } else if ( + updatedHc.hcHealth === "healthy" && + existingHc.hcHealth !== "healthy" + ) { logger.debug( `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert` ); diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index df22faa2d..88b2fb5bc 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -16,6 +16,9 @@ export default async function migration() { thc."targetId", t."siteId", s."orgId", + r."name" AS "resourceName", + t."ip", + t."port", thc."hcEnabled", thc."hcPath", thc."hcScheme", @@ -33,13 +36,17 @@ export default async function migration() { thc."hcTlsServerName" FROM "targetHealthCheck" thc JOIN "targets" t ON thc."targetId" = t."targetId" - JOIN "sites" s ON t."siteId" = s."siteId"` + JOIN "sites" s ON t."siteId" = s."siteId" + JOIN "resources" r ON t."resourceId" = r."resourceId"` ); const existingHealthChecks = healthChecksQuery.rows as { targetHealthCheckId: number; targetId: number; siteId: number; orgId: string; + resourceName: string; + ip: string; + port: number; hcEnabled: boolean; hcPath: string | null; hcScheme: string | null; @@ -385,6 +392,7 @@ export default async function migration() { "targetId", "orgId", "siteId", + "name", "hcEnabled", "hcPath", "hcScheme", @@ -405,6 +413,7 @@ export default async function migration() { ${hc.targetId}, ${hc.orgId}, ${hc.siteId}, + ${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`}, ${hc.hcEnabled}, ${hc.hcPath}, ${hc.hcScheme}, diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index 49ee8c450..a5078e2d3 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -22,6 +22,9 @@ export default async function migration() { thc."targetId", t."siteId", s."orgId", + r."name" AS "resourceName", + t."ip", + t."port", thc."hcEnabled", thc."hcPath", thc."hcScheme", @@ -39,13 +42,17 @@ export default async function migration() { thc."hcTlsServerName" FROM 'targetHealthCheck' thc JOIN 'targets' t ON thc."targetId" = t."targetId" - JOIN 'sites' s ON t."siteId" = s."siteId"` + JOIN 'sites' s ON t."siteId" = s."siteId" + JOIN 'resources' r ON t."resourceId" = r."resourceId"` ) .all() as { targetHealthCheckId: number; targetId: number; siteId: number; orgId: string; + resourceName: string; + ip: string; + port: number; hcEnabled: number; hcPath: string | null; hcScheme: string | null; @@ -392,6 +399,7 @@ export default async function migration() { "targetId", "orgId", "siteId", + "name", "hcEnabled", "hcPath", "hcScheme", @@ -407,7 +415,7 @@ export default async function migration() { "hcStatus", "hcHealth", "hcTlsServerName" - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ); const insertAll = db.transaction(() => { @@ -417,6 +425,7 @@ export default async function migration() { hc.targetId, hc.orgId, hc.siteId, + `Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`, hc.hcEnabled, hc.hcPath, hc.hcScheme, diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 778062e8e..f9f9bd77f 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -35,6 +35,7 @@ import { } from "@app/components/Credenza"; import { cn } from "@app/lib/cn"; import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react"; +import { Badge } from "@app/components/ui/badge"; import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert"; import { Tooltip, @@ -55,6 +56,7 @@ import { tier3LimitSet } from "@server/lib/billing/limitSet"; import { FeatureId } from "@server/lib/billing/features"; +import TrialBillingBanner from "@app/components/TrialBillingBanner"; // Plan tier definitions matching the mockup type PlanId = "basic" | "home" | "team" | "business" | "enterprise"; @@ -805,6 +807,20 @@ export default function BillingPage() { return ( + {/* Trial Banner */} + {isTrial && ( + { + const currentPlan = planOptions.find( + (p) => p.id === currentPlanId + ); + if (currentPlan?.tierType) { + handleStartSubscription(currentPlan.tierType); + } + }} + /> + )} + {/* Subscription Status Alert */} {isProblematicState && statusMessage && ( @@ -859,8 +875,19 @@ export default function BillingPage() { )} >
-
- {plan.name} +
+ + {plan.name} + + {isCurrentPlan && isTrial && ( + + {t("billingTrialBadge") || + "Free Trial"} + + )}
diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx index 2c34d92ec..890a14564 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -45,6 +45,7 @@ export default async function RemoteExitNodesPage( type: node.type, dateCreated: node.dateCreated, version: node.version || undefined, + updateAvailable: node.updateAvailable, orgId: params.orgId }; } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index c2aaefaa6..6373e334a 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -160,6 +160,18 @@ export default async function Page(props: { redirect={redirectUrl} forceLogin={forceLogin} defaultUser={defaultUser} + orgSignIn={ + !isInvite && + (build === "saas" || + env.app.identityProviderMode === "org") + ? { + href: `/auth/org${buildQueryString(searchParams)}`, + linkText: t("orgAuthSignInToOrg"), + descriptionText: + t("needToSignInToOrg") + } + : undefined + } /> @@ -195,7 +207,8 @@ export default async function Page(props: {

)} - {!isInvite && + {!useSmartLogin && + !isInvite && (build === "saas" || env.app.identityProviderMode === "org") ? ( { }; const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { - // return ( - //
- // {children} - //
- // ); - return (
- {children} +
{children}
+
); }; @@ -172,7 +170,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => { return ( { const [isDismissed, setIsDismissed] = useState(true); const t = useTranslations(); @@ -66,19 +68,21 @@ export const DismissableBanner = ({ ); }; - if (isDismissed) { + if (dismissable && isDismissed) { return null; } return ( - + {dismissable && ( + + )}
diff --git a/src/components/ExitNodesTable.tsx b/src/components/ExitNodesTable.tsx index 67d819a47..73e96a96c 100644 --- a/src/components/ExitNodesTable.tsx +++ b/src/components/ExitNodesTable.tsx @@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import { Badge } from "@app/components/ui/badge"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type RemoteExitNodeRow = { id: string; @@ -33,6 +34,7 @@ export type RemoteExitNodeRow = { online: boolean; dateCreated: string; version?: string; + updateAvailable?: boolean; }; type ExitNodesTableProps = { @@ -233,13 +235,18 @@ export default function ExitNodesTable({ const originalRow = row.original; return (
- {originalRow.version && originalRow.version ? ( + {originalRow.version ? ( {"v" + originalRow.version} ) : ( "-" )} + {originalRow.updateAvailable && ( + + )}
); } diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index bdbaafa27..c0199c6d3 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -25,7 +25,6 @@ import { import { ArrowRight, ArrowUpDown, - KeyRound, MoreHorizontal } from "lucide-react"; import { useMemo, useState } from "react"; @@ -50,6 +49,7 @@ import { useQuery } from "@tanstack/react-query"; import { useDebounce } from "use-debounce"; import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; import { cn } from "@app/lib/cn"; +import { Badge } from "@app/components/ui/badge"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; @@ -63,6 +63,61 @@ export type IdpRow = { type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number]; +type ImportSourceOrg = { orgId: string; orgName: string }; + +type GroupedImportableIdp = { + idpId: number; + name: string; + type: string; + variant: string; + tags: string | null; + sources: ImportSourceOrg[]; +}; + +function adminRowForImport( + group: GroupedImportableIdp, + source: ImportSourceOrg +): AdminIdpRow { + return { + idpId: group.idpId, + orgId: source.orgId, + orgName: source.orgName, + name: group.name, + type: group.type, + variant: group.variant, + tags: group.tags + }; +} + +function groupImportableIdps(rows: AdminIdpRow[]): GroupedImportableIdp[] { + const map = new Map(); + for (const row of rows) { + let g = map.get(row.idpId); + if (!g) { + g = { + idpId: row.idpId, + name: row.name, + type: row.type, + variant: row.variant, + tags: row.tags, + sources: [] + }; + map.set(row.idpId, g); + } + if (!g.sources.some((s) => s.orgId === row.orgId)) { + g.sources.push({ orgId: row.orgId, orgName: row.orgName }); + } + } + return Array.from(map.values()) + .map((item) => ({ + ...item, + sources: [...item.sources].sort((a, b) => + a.orgName.localeCompare(b.orgName) + ) + })) + .sort((a, b) => b.name.localeCompare(a.name)); +} + function IdpImportRowIcon({ type, variant @@ -114,16 +169,22 @@ export default function IdpTable({ idps, orgId }: Props) { ); }, [adminIdpsRaw, orgId, idps]); - const shownImportIdps = useMemo(() => { + const importableGrouped = useMemo( + () => groupImportableIdps(importableIdps), + [importableIdps] + ); + + const shownImportGrouped = useMemo(() => { const q = debouncedImportSearch.trim().toLowerCase(); if (!q) { - return importableIdps; + return importableGrouped; } - return importableIdps.filter((row) => { - const hay = `${row.orgName} ${row.name}`.toLowerCase(); + return importableGrouped.filter((group) => { + const hay = + `${group.name} ${group.sources.map((s) => s.orgName).join(" ")}`.toLowerCase(); return hay.includes(q); }); - }, [importableIdps, debouncedImportSearch]); + }, [importableGrouped, debouncedImportSearch]); const deleteIdp = async (idpId: number) => { try { @@ -364,31 +425,44 @@ export default function IdpTable({ idps, orgId }: Props) { {t("idpImportEmpty")} - {shownImportIdps.map((row) => ( + {shownImportGrouped.map((group) => ( s.orgName).join(" ")}`} disabled={!canImportOrgOidcIdp} onSelect={() => { if (!canImportOrgOidcIdp) { return; } - void importIdp(row); + void importIdp( + adminRowForImport( + group, + group.sources[0] + ) + ); }} >
- {row.orgName} + {group.name}
-
- {row.name} +
+ {group.sources.map((src) => ( + + {src.orgName} + + ))}
diff --git a/src/components/OrgSignInLink.tsx b/src/components/OrgSignInLink.tsx index 819a1dc7f..d900b98d3 100644 --- a/src/components/OrgSignInLink.tsx +++ b/src/components/OrgSignInLink.tsx @@ -5,11 +5,15 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { Building2 } from "lucide-react"; type OrgSignInLinkProps = { href: string; linkText: string; descriptionText: string; + primaryActionVariant?: "link" | "button"; + className?: string; }; const STORAGE_KEY_CLICKED = "orgSignInLinkClicked"; @@ -18,7 +22,9 @@ const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged"; export default function OrgSignInLink({ href, linkText, - descriptionText + descriptionText, + primaryActionVariant = "link", + className }: OrgSignInLinkProps) { const router = useRouter(); const t = useTranslations(); @@ -93,14 +99,32 @@ export default function OrgSignInLink({ )} -
- {descriptionText} - +
+ {primaryActionVariant === "button" ? ( + + ) : ( + + )}
); diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index b1da32a93..0d88853a7 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -81,10 +81,10 @@ export default function ProductUpdates({ const showNewVersionPopup = Boolean( latestVersion && - valid(latestVersion) && - valid(currentVersion) && - ignoredVersionUpdate !== latestVersion && - gt(latestVersion, currentVersion) + valid(latestVersion) && + valid(currentVersion) && + ignoredVersionUpdate !== latestVersion && + gt(latestVersion, currentVersion) ); const filteredUpdates = data.updates.filter( @@ -103,40 +103,51 @@ export default function ProductUpdates({ )} >
- {filteredUpdates.length > 1 && ( - 0 && ( +
+ {filteredUpdates.length > 1 && ( + + + + {showNewVersionPopup + ? t("productUpdateMoreInfo", { + noOfUpdates: + filteredUpdates.length + }) + : t("productUpdateInfo", { + noOfUpdates: + filteredUpdates.length + })} + + )} - > - - - {showNewVersionPopup - ? t("productUpdateMoreInfo", { - noOfUpdates: filteredUpdates.length - }) - : t("productUpdateInfo", { - noOfUpdates: filteredUpdates.length - })} - - + 0} + onDimissAll={() => + setProductUpdatesRead([ + ...productUpdatesRead, + ...filteredUpdates.map( + (update) => update.id + ) + ]) + } + onDimiss={(id) => + setProductUpdatesRead([ + ...productUpdatesRead, + id + ]) + } + /> +
)} - 0} - onDimissAll={() => - setProductUpdatesRead([ - ...productUpdatesRead, - ...filteredUpdates.map((update) => update.id) - ]) - } - onDimiss={(id) => - setProductUpdatesRead([...productUpdatesRead, id]) - } - />
{ - if (type === "newt") { - return "Newt"; - } else if (type === "wireguard") { - return "WireGuard"; - } else if (type === "local") { - return t("local"); - } else { - return t("unknown"); - } - }; +export default function SiteInfoCard({}: SiteInfoCardProps) { + const { site } = useSiteContext(); + const t = useTranslations(); + + const identifierSection = ( + + {t("identifier")} + {site.niceId} + + ); + + const statusSection = ( + + {t("status")} + + {site.online ? ( +
+
+ {t("online")} +
+ ) : ( +
+
+ {t("offline")} +
+ )} +
+
+ ); + + const endpointSection = site.endpoint ? ( + + {t("publicIpEndpoint")} + + {formatPublicEndpoint(site.endpoint)} + + + ) : null; + + if (site.type === "newt") { + return ( + + + + {identifierSection} + {statusSection} + + + {t("connectionType")} + + Newt + + + + {t("newtVersion")} + + + {site.newtVersion + ? `v${site.newtVersion}` + : "-"} + + + {endpointSection} + + + + ); + } + + if (site.type === "wireguard") { + return ( + + + + {identifierSection} + {statusSection} + + + {t("connectionType")} + + WireGuard + + {endpointSection} + + + + ); + } + + if (site.type === "local") { + return ( + + + + {identifierSection} + + + {t("connectionType")} + + + {t("local")} + + + {endpointSection} + + + + ); + } return ( - - - {t("identifier")} - {site.niceId} - - {(site.type == "newt" || site.type == "wireguard") && ( - <> - - - {t("status")} - - - {site.online ? ( -
-
- {t("online")} -
- ) : ( -
-
- {t("offline")} -
- )} -
-
- - )} + + {identifierSection} {t("connectionType")} - - {getConnectionTypeString(site.type)} - + {t("unknown")} - {site.endpoint && ( - - - {t("publicIpEndpoint")} - - - {site.endpoint.includes(":") - ? site.endpoint.substring(0, site.endpoint.lastIndexOf(":")) - : site.endpoint} - - - )} + {endpointSection}
diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx index 24f2acb72..7d695127f 100644 --- a/src/components/SmartLoginForm.tsx +++ b/src/components/SmartLoginForm.tsx @@ -15,15 +15,18 @@ import { FormMessage } from "@app/components/ui/form"; import { Alert, AlertDescription } from "@app/components/ui/alert"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useUserLookup } from "@app/hooks/useUserLookup"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { LookupUserResponse } from "@server/routers/auth/lookupUser"; import { useTranslations } from "next-intl"; import LoginPasswordForm from "@app/components/LoginPasswordForm"; -import LoginOrgSelector from "@app/components/LoginOrgSelector"; +import SmartLoginOrgSelector from "@app/components/SmartLoginOrgSelector"; import UserProfileCard from "@app/components/UserProfileCard"; -import { ArrowLeft } from "lucide-react"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; +import { Separator } from "@app/components/ui/separator"; +import OrgSignInLink from "@app/components/OrgSignInLink"; const identifierSchema = z.object({ identifier: z.string().min(1, "Username or email is required") @@ -39,10 +42,17 @@ const isValidEmail = (str: string): boolean => { } }; +type OrgSignInConfig = { + href: string; + linkText: string; + descriptionText: string; +}; + type SmartLoginFormProps = { redirect?: string; forceLogin?: boolean; defaultUser?: string; + orgSignIn?: OrgSignInConfig; }; type ViewState = @@ -58,12 +68,31 @@ type ViewState = lookupResult: LookupUserResponse; }; +function buildResetPasswordHref( + dashboardUrl: string, + identifier: string, + redirectParam?: string +) { + const trimmed = identifier.trim(); + const params = new URLSearchParams(); + if (isValidEmail(trimmed)) { + params.set("email", trimmed); + } + if (redirectParam) { + params.set("redirect", redirectParam); + } + const qs = params.toString(); + return `${dashboardUrl}/auth/reset-password${qs ? `?${qs}` : ""}`; +} + export default function SmartLoginForm({ redirect, forceLogin, - defaultUser + defaultUser, + orgSignIn }: SmartLoginFormProps) { const router = useRouter(); + const { env } = useEnvContext(); const { lookup, loading, error } = useUserLookup(); const t = useTranslations(); const [viewState, setViewState] = useState({ type: "initial" }); @@ -78,6 +107,13 @@ export default function SmartLoginForm({ } }); + const watchedIdentifier = form.watch("identifier"); + const resetPasswordHref = buildResetPasswordHref( + env.app.dashboardUrl, + watchedIdentifier, + redirect + ); + const hasAutoLookedUp = useRef(false); useEffect(() => { if (defaultUser?.trim() && !hasAutoLookedUp.current) { @@ -170,7 +206,7 @@ export default function SmartLoginForm({ if (viewState.type === "orgSelector") { return (
- +
+ + {t("passwordForgot")} + +
+ {(error || securityKeyError) && ( @@ -219,7 +264,7 @@ export default function SmartLoginForm({ -
+
); diff --git a/src/components/SmartLoginOrgSelector.tsx b/src/components/SmartLoginOrgSelector.tsx new file mode 100644 index 000000000..5cc8fe600 --- /dev/null +++ b/src/components/SmartLoginOrgSelector.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Badge } from "@app/components/ui/badge"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useTranslations } from "next-intl"; +import LoginPasswordForm from "@app/components/LoginPasswordForm"; +import { LookupUserResponse } from "@server/routers/auth/lookupUser"; +import UserProfileCard from "@app/components/UserProfileCard"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; +import { generateOidcUrlProxy } from "@app/actions/server"; +import { + redirect as redirectTo, + useRouter, + useSearchParams +} from "next/navigation"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { Separator } from "@app/components/ui/separator"; + +type SmartLoginOrgSelectorProps = { + identifier: string; + lookupResult: LookupUserResponse; + redirect?: string; + forceLogin?: boolean; + onUseDifferentAccount?: () => void; +}; + +type OrgBucket = { + orgId: string; + orgName: string; + idps: Array<{ + idpId: number; + name: string; + variant: string | null; + }>; + hasInternalAuth: boolean; +}; + +type GroupedLoginIdp = { + idpId: number; + name: string; + variant: string | null; + orgs: { orgId: string; orgName: string }[]; +}; + +function buildOrgMap(lookupResult: LookupUserResponse) { + const orgMap = new Map(); + + for (const account of lookupResult.accounts) { + for (const org of account.orgs) { + if (!orgMap.has(org.orgId)) { + orgMap.set(org.orgId, { + orgId: org.orgId, + orgName: org.orgName, + idps: org.idps, + hasInternalAuth: org.hasInternalAuth + }); + } else { + const existing = orgMap.get(org.orgId)!; + const existingIdpIds = new Set( + existing.idps.map((i) => i.idpId) + ); + for (const idp of org.idps) { + if (!existingIdpIds.has(idp.idpId)) { + existing.idps.push(idp); + } + } + if (org.hasInternalAuth) { + existing.hasInternalAuth = true; + } + } + } + } + + return Array.from(orgMap.values()); +} + +function groupIdpsAcrossOrgs(orgs: OrgBucket[]): GroupedLoginIdp[] { + const map = new Map(); + + for (const org of orgs) { + for (const idp of org.idps) { + let g = map.get(idp.idpId); + if (!g) { + g = { + idpId: idp.idpId, + name: idp.name, + variant: idp.variant, + orgs: [] + }; + map.set(idp.idpId, g); + } + if (!g.orgs.some((o) => o.orgId === org.orgId)) { + g.orgs.push({ orgId: org.orgId, orgName: org.orgName }); + } + } + } + + return Array.from(map.values()) + .map((g) => ({ + ...g, + orgs: [...g.orgs].sort((a, b) => a.orgName.localeCompare(b.orgName)) + })) + .sort((a, b) => b.name.localeCompare(a.name)); +} + +export default function SmartLoginOrgSelector({ + identifier, + lookupResult, + redirect, + forceLogin, + onUseDifferentAccount +}: SmartLoginOrgSelectorProps) { + const t = useTranslations(); + const [showPasswordForm, setShowPasswordForm] = useState(false); + const [error, setError] = useState(null); + const [pendingIdpId, setPendingIdpId] = useState(null); + const params = useSearchParams(); + const router = useRouter(); + + const orgs = buildOrgMap(lookupResult); + const groupedIdps = groupIdpsAcrossOrgs(orgs); + + const hasInternalAccount = lookupResult.accounts.some( + (acc) => acc.hasInternalAuth + ); + + function goToApp() { + const url = window.location.href.split("?")[0]; + router.push(url); + } + + useEffect(() => { + if (params.get("gotoapp")) { + goToApp(); + } + }, []); + + async function loginWithIdp(idpId: number, orgId: string) { + setPendingIdpId(idpId); + setError(null); + + let redirectToUrl: string | undefined; + try { + const safeRedirect = cleanRedirect(redirect || "/"); + const response = await generateOidcUrlProxy( + idpId, + safeRedirect, + orgId, + forceLogin + ); + + if (response.error) { + setError(response.message); + setPendingIdpId(null); + return; + } + + const data = response.data; + if (data?.redirectUrl) { + redirectToUrl = data.redirectUrl; + } + } catch { + setError( + t("loginError", { + defaultValue: + "An unexpected error occurred. Please try again." + }) + ); + } + + if (redirectToUrl) { + redirectTo(redirectToUrl); + } else { + setPendingIdpId(null); + } + } + + if (showPasswordForm) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + {hasInternalAccount && ( +
+ +
+ )} + + {groupedIdps.length > 0 ? ( +
+ {error && ( + + {error} + + )} + +
+
+ +
+
+ + {t("idpContinue")} + +
+
+ +
+ {params.get("gotoapp") ? ( + + ) : ( + groupedIdps.map((group) => { + const effectiveType = + group.variant || group.name.toLowerCase(); + const sourceOrgId = group.orgs[0].orgId; + + return ( + + ); + }) + )} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/TrialBillingBanner.tsx b/src/components/TrialBillingBanner.tsx new file mode 100644 index 000000000..52fcb4873 --- /dev/null +++ b/src/components/TrialBillingBanner.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { ClockIcon, ArrowRight } from "lucide-react"; +import { useTranslations } from "next-intl"; +import DismissableBanner from "./DismissableBanner"; + +type TrialBillingBannerProps = { + onUpgrade: () => void; +}; + +export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => { + const t = useTranslations(); + + return ( + } + description={t("billingTrialBannerDescription")} + dismissable={false} + > + + + ); +}; + +export default TrialBillingBanner; diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index eac0a72f6..d787595ed 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -15,12 +15,15 @@ import { } from "@app/components/ui/command"; import { FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { Switch } from "@app/components/ui/switch"; +import { Textarea } from "@app/components/ui/textarea"; import { Label } from "@app/components/ui/label"; import { Popover, @@ -925,6 +928,69 @@ function WebhookActionFields({ />
+ {/* Body Template */} +
+
+ +

+ {t("httpDestBodyTemplateDescription")} +

+
+ ( + +
+ + + + +
+
+ )} + /> + {useWatch({ + control, + name: `actions.${index}.useBodyTemplate` + }) && ( + ( + + + {t("httpDestBodyTemplateLabel")} + + +