Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be7628a00 | |||
| 78cddc7775 | |||
| 1ad613f403 | |||
| d2936cd10b |
@@ -34,14 +34,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: AI Disclosure
|
||||
description: |
|
||||
If you used AI to help write this issue, please disclose it here. This is important for transparency and helps maintain the integrity of the issue tracking process.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
|
||||
@@ -37,22 +37,11 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
Get started with Pangolin Cloud at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is an open-source SASE platform, built on WireGuard®, with a simple mission: connect and protect your users, wherever they are. It brings networking and security together as one system including a zero-trust VPN, zero-trust reverse proxy, privileged access management, and an identity-aware AI gateway, all sharing one identity and policy model. It's the same idea behind platforms like Cloudflare One, Zscaler, and Prisma but open, self-hostable, and built to stay light and easy to deploy.
|
||||
|
||||
### Networking and security that's unified, open, and simple
|
||||
|
||||
Legacy SASE platforms got the idea right: connectivity and security belong together. But they delivered it as a heavyweight, closed, cloud-locked stack assembled from years of patchwork. Pangolin exists to do that unification differently, in the open, self-hostable, and simple enough that administrators actually enjoy running it.
|
||||
|
||||
* **Open source, not a black box**: the code is open and auditable, so you can see exactly how your traffic is handled and how access decisions get made, instead of trusting a closed cloud control plane.
|
||||
* **Networking and security as one platform**: sites, reverse proxy, client access, RBAC, and the AI gateway share one identity and policy model, so protecting users and connecting them are executed together.
|
||||
* **Lightweight by design**: the whole platform is built to stay small and fast: easy to self-host on a small server, with a lightweight, user-space connector that goes in your private networks.
|
||||
* **Enjoyable to use**: a clean, modern interface and a setup flow that gets out of your way, so managing access feels simple instead of like fighting a legacy admin console.
|
||||
* **Zero trust from day one**: access is granted per resource, not per network, with identity provider integration, role-based access control, and full audit logging.
|
||||
* **Run it your way**: self-host the Community Edition for free, step up to the Enterprise Edition for advanced features, or use Pangolin Cloud if you'd rather not manage infrastructure at all.
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -64,9 +53,9 @@ Legacy SASE platforms got the idea right: connectivity and security belong toget
|
||||
|
||||
## Deployment Options
|
||||
|
||||
- **Pangolin Cloud** - Fully managed service with no infrastructure required.
|
||||
- **Self-Host: Community Edition** - Free, open-source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** - Open-core, and licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
|
||||
- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { encrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { generateCA } from "@server/lib/sshCA";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
type GenerateOrgCaKeysArgs = {
|
||||
orgId: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
type RotateServerSecretArgs = {
|
||||
"old-secret": string;
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Научете повече за JMESPath",
|
||||
"idpJmespathLabel": "Идентификатор на пътя",
|
||||
"idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена",
|
||||
"idpIdentifierChangeTitle": "Предупреждение за промяна на пътя на идентификатора",
|
||||
"idpIdentifierChangeDescription": "Ще промените пътя на идентификатора. Това ще повлияе на начина, по който съществуващите потребители са разпределени. Потребители, които преди са влизали чрез този доставчик на идентичности, може вече да не бъдат разпознавани като същите потребители.",
|
||||
"idpIdentifierChangeConfirmMessage": "Потвърждавам",
|
||||
"idpIdentifierChangeWarningText": "Това ще повлияе на начина, по който съществуващите потребители са разпределени",
|
||||
"idpJmespathEmailPathOptional": "Път за имейл (по избор)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена",
|
||||
"idpJmespathNamePathOptional": "Път (по избор) на име",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Търси…",
|
||||
"searchPlaceholder": "Търсене...",
|
||||
"emptySearchOptions": "Няма намерени опции",
|
||||
"ipFilterSearchPlaceholder": "Въведете IP адрес…",
|
||||
"ipFilterEmptyMessage": "Въведете IP адрес, за да филтрирате по него",
|
||||
"create": "Създаване",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Тип:",
|
||||
"createDomainName": "Име:",
|
||||
"createDomainValue": "Стойност:",
|
||||
"multiSelectFilterCount": "{count} избрани",
|
||||
"createDomainCnameRecords": "CNAME записи",
|
||||
"createDomainARecords": "A записи",
|
||||
"createDomainRecordNumber": "Запис {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Метод",
|
||||
"healthSelectScheme": "Избор на метод",
|
||||
"healthCheckPortInvalid": "Портът трябва да бъде между 1 и 65535",
|
||||
"healthCheckHostnameInvalid": "Името на хоста не трябва да съдържа празни символи",
|
||||
"healthCheckPath": "Път",
|
||||
"healthHostname": "IP / Хост",
|
||||
"healthPort": "Порт",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайтовете с тези етикети ще предпочетат да се свържат чрез този отдалечен изходен възел.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Další informace o cestě JMESPath",
|
||||
"idpJmespathLabel": "Cesta identifikátoru",
|
||||
"idpJmespathLabelDescription": "Cesta k identifikátoru uživatele v tokenu ID",
|
||||
"idpIdentifierChangeTitle": "Varování o změně cesty identifikátoru",
|
||||
"idpIdentifierChangeDescription": "Chystáte se změnit cestu identifikátoru. Tímto se změní způsob mapování stávajících uživatelů. Uživatelé, kteří se dříve přihlásili přes tohoto poskytovatele identity, již nemusí být rozpoznáni jako stejní uživatelé.",
|
||||
"idpIdentifierChangeConfirmMessage": "Potvrzuji",
|
||||
"idpIdentifierChangeWarningText": "Toto ovlivní, jak budou mapováni stávající uživatelé",
|
||||
"idpJmespathEmailPathOptional": "Cesta e-mailu (volitelné)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Cesta k e-mailu uživatele v ID tokenu",
|
||||
"idpJmespathNamePathOptional": "Cesta k názvu (volitelné)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Vyhledávání…",
|
||||
"searchPlaceholder": "Hledat...",
|
||||
"emptySearchOptions": "Nebyly nalezeny žádné možnosti",
|
||||
"ipFilterSearchPlaceholder": "Zadejte IP adresu…",
|
||||
"ipFilterEmptyMessage": "Zadejte IP adresu pro filtrování",
|
||||
"create": "Vytvořit",
|
||||
"orgs": "Organizace",
|
||||
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Typ:",
|
||||
"createDomainName": "Jméno:",
|
||||
"createDomainValue": "Hodnota:",
|
||||
"multiSelectFilterCount": "{count} vybráno",
|
||||
"createDomainCnameRecords": "Záznamy CNAME",
|
||||
"createDomainARecords": "Záznamy",
|
||||
"createDomainRecordNumber": "Nahrát {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Způsob",
|
||||
"healthSelectScheme": "Vybrat metodu",
|
||||
"healthCheckPortInvalid": "Port musí být mezi 1 a 65535",
|
||||
"healthCheckHostnameInvalid": "Název hostitele nesmí obsahovat mezery",
|
||||
"healthCheckPath": "Cesta",
|
||||
"healthHostname": "IP / Hostitel",
|
||||
"healthPort": "Přístav",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Stránky s těmito štítky preferují spojení přes tento vzdálený výstupní uzel.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nuceny připojit se tímto vzdáleným výstupním uzlem.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Læs mere om JMESPath",
|
||||
"idpJmespathLabel": "Identifikatorsti",
|
||||
"idpJmespathLabelDescription": "Stien til brugeridentifikatoren i ID-tokenet",
|
||||
"idpIdentifierChangeTitle": "Advarsel om ændring af identifikatorsti",
|
||||
"idpIdentifierChangeDescription": "Du er ved at ændre identifikatorstien. Dette vil påvirke, hvordan eksisterende brugere bliver kortlagt. Brugere, der tidligere har logget ind gennem denne identitetsudbyder, genkendes muligvis ikke længere som de samme brugere.",
|
||||
"idpIdentifierChangeConfirmMessage": "Jeg bekræfter",
|
||||
"idpIdentifierChangeWarningText": "Dette vil påvirke, hvordan eksisterende brugere bliver kortlagt",
|
||||
"idpJmespathEmailPathOptional": "E-mailsti (Valgfrit)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Stien til brugerens e-mailadresse i ID-tokenet",
|
||||
"idpJmespathNamePathOptional": "Navn Sti (Valgfrit)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Søg…",
|
||||
"searchPlaceholder": "Søg...",
|
||||
"emptySearchOptions": "Ingen valg fundet",
|
||||
"ipFilterSearchPlaceholder": "Indtast en IP-adresse…",
|
||||
"ipFilterEmptyMessage": "Indtast en IP-adresse for at filtrere efter",
|
||||
"create": "Opret",
|
||||
"orgs": "Organisationer",
|
||||
"loginError": "Der opstod en uventet fejl. Prøv venligst igen.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Navn:",
|
||||
"createDomainValue": "Værdi:",
|
||||
"multiSelectFilterCount": "{count} valgt",
|
||||
"createDomainCnameRecords": "CNAME-poster",
|
||||
"createDomainARecords": "A-poster",
|
||||
"createDomainRecordNumber": "Post {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Metode",
|
||||
"healthSelectScheme": "Vælg metode",
|
||||
"healthCheckPortInvalid": "Porten skal være mellem 1 og 65535",
|
||||
"healthCheckHostnameInvalid": "Værtsnavnet må ikke indeholde mellemrum",
|
||||
"healthCheckPath": "Sti",
|
||||
"healthHostname": "IP / Vært",
|
||||
"healthPort": "Port",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sider med disse etiketter vil foretrække at forbinde gennem denne fjernudgarnknude.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren",
|
||||
"idpJmespathLabel": "Identifikationspfad",
|
||||
"idpJmespathLabelDescription": "Der JMESPath zum Benutzeridentifikator im ID-Token",
|
||||
"idpIdentifierChangeTitle": "Warnung zur Änderung des Identifikatorpfads",
|
||||
"idpIdentifierChangeDescription": "Sie stehen kurz davor, den Identifikatorpfad zu ändern. Dies wird beeinflussen, wie bestehende Benutzer zugeordnet werden. Benutzer, die sich zuvor über diesen Identitätsanbieter angemeldet haben, werden möglicherweise nicht mehr als dieselben Benutzer erkannt.",
|
||||
"idpIdentifierChangeConfirmMessage": "Ich bestätige",
|
||||
"idpIdentifierChangeWarningText": "Dies wird beeinflussen, wie bestehende Benutzer zugeordnet werden",
|
||||
"idpJmespathEmailPathOptional": "E-Mail-Pfad (Optional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Der JMESPath zur E-Mail-Adresse des Benutzers im ID-Token",
|
||||
"idpJmespathNamePathOptional": "Namenspfad (Optional)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Suche…",
|
||||
"searchPlaceholder": "Suche...",
|
||||
"emptySearchOptions": "Keine Optionen gefunden",
|
||||
"ipFilterSearchPlaceholder": "Geben Sie eine IP-Adresse ein…",
|
||||
"ipFilterEmptyMessage": "Geben Sie eine IP-Adresse zur Filterung ein",
|
||||
"create": "Erstellen",
|
||||
"orgs": "Organisationen",
|
||||
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Typ:",
|
||||
"createDomainName": "Name:",
|
||||
"createDomainValue": "Wert:",
|
||||
"multiSelectFilterCount": "{count} ausgewählt",
|
||||
"createDomainCnameRecords": "CNAME-Einträge",
|
||||
"createDomainARecords": "A-Aufzeichnungen",
|
||||
"createDomainRecordNumber": "Eintrag {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Methode",
|
||||
"healthSelectScheme": "Methode auswählen",
|
||||
"healthCheckPortInvalid": "Der Port muss zwischen 1 und 65535 liegen",
|
||||
"healthCheckHostnameInvalid": "Der Hostname darf keinen Leerraum enthalten",
|
||||
"healthCheckPath": "Pfad",
|
||||
"healthHostname": "IP / Host",
|
||||
"healthPort": "Port",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Labels bevorzugen die Verbindung über diesen Remote-Exit-Knoten.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
|
||||
|
||||
@@ -1424,6 +1424,24 @@
|
||||
"logoutError": "Error logging out",
|
||||
"signingAs": "Signed in as",
|
||||
"serverAdmin": "Server Admin",
|
||||
"promoteServerAdmin": "Promote to Server admin",
|
||||
"promoteServerAdminTitle": "Promote to Server Admin",
|
||||
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?",
|
||||
"promoteServerAdminMessage": "Server admins have full access to every organization, user, and setting on this instance.",
|
||||
"promoteServerAdminWarning": "This can be undone at any time by demoting the user from this page.",
|
||||
"promoteServerAdminConfirm": "Promote to server admin",
|
||||
"promoteServerAdminSuccess": "User promoted",
|
||||
"promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.",
|
||||
"promoteServerAdminError": "Failed to promote user",
|
||||
"demoteServerAdmin": "Demote from Server admin",
|
||||
"demoteServerAdminTitle": "Demote from Server Admin",
|
||||
"demoteServerAdminQuestion": "Are you sure you want to demote {selectedUser} from server admin?",
|
||||
"demoteServerAdminMessage": "{selectedUser} will lose full access to every organization, user, and setting on this instance.",
|
||||
"demoteServerAdminWarning": "This can be undone at any time by promoting the user from this page.",
|
||||
"demoteServerAdminConfirm": "Demote from server admin",
|
||||
"demoteServerAdminSuccess": "User demoted",
|
||||
"demoteServerAdminSuccessDescription": "{selectedUser} is no longer a server admin.",
|
||||
"demoteServerAdminError": "Failed to demote user",
|
||||
"managedSelfhosted": "Managed Self-Hosted",
|
||||
"otpEnable": "Enable Two-factor",
|
||||
"otpDisable": "Disable Two-factor",
|
||||
@@ -2717,7 +2735,6 @@
|
||||
"healthScheme": "Method",
|
||||
"healthSelectScheme": "Select Method",
|
||||
"healthCheckPortInvalid": "Port must be between 1 and 65535",
|
||||
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
|
||||
"healthCheckPath": "Path",
|
||||
"healthHostname": "IP / Host",
|
||||
"healthPort": "Port",
|
||||
@@ -3477,8 +3494,7 @@
|
||||
},
|
||||
"priority": "Priority",
|
||||
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
||||
"instanceName": "Server ID",
|
||||
"clearInstanceName": "Reset Server Association",
|
||||
"instanceName": "Instance Name",
|
||||
"pathMatchModalTitle": "Configure Path Matching",
|
||||
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
|
||||
"pathMatchType": "Match Type",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Más información sobre JMESPath",
|
||||
"idpJmespathLabel": "Ruta del identificador",
|
||||
"idpJmespathLabelDescription": "La ruta al identificador de usuario en el token de ID",
|
||||
"idpIdentifierChangeTitle": "Advertencia de Cambio de Ruta de Identificador",
|
||||
"idpIdentifierChangeDescription": "Está a punto de cambiar la ruta del identificador. Esto afectará cómo se asignan los usuarios existentes. Los usuarios que anteriormente iniciaron sesión a través de este proveedor de identidad pueden ya no ser reconocidos como los mismos usuarios.",
|
||||
"idpIdentifierChangeConfirmMessage": "Confirmo",
|
||||
"idpIdentifierChangeWarningText": "Esto afectará cómo se asignan los usuarios existentes",
|
||||
"idpJmespathEmailPathOptional": "Ruta de correo (opcional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "La ruta al correo electrónico del usuario en el token de ID",
|
||||
"idpJmespathNamePathOptional": "Ruta del nombre (opcional)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Buscar…",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "No se encontraron opciones",
|
||||
"ipFilterSearchPlaceholder": "Introduzca una dirección IP…",
|
||||
"ipFilterEmptyMessage": "Introduzca una dirección IP para filtrar por",
|
||||
"create": "Crear",
|
||||
"orgs": "Organizaciones",
|
||||
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nombre:",
|
||||
"createDomainValue": "Valor:",
|
||||
"multiSelectFilterCount": "{count} seleccionado",
|
||||
"createDomainCnameRecords": "Registros CNAME",
|
||||
"createDomainARecords": "Registros A",
|
||||
"createDomainRecordNumber": "Registro {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Método",
|
||||
"healthSelectScheme": "Seleccionar método",
|
||||
"healthCheckPortInvalid": "El puerto debe estar entre 1 y 65535",
|
||||
"healthCheckHostnameInvalid": "El nombre de host no debe contener espacios en blanco",
|
||||
"healthCheckPath": "Ruta",
|
||||
"healthHostname": "IP / Nombre del host",
|
||||
"healthPort": "Puerto",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas preferirán conectarse a través de este nodo de salida remoto.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "En savoir plus sur JMESPath",
|
||||
"idpJmespathLabel": "Chemin d'identification",
|
||||
"idpJmespathLabelDescription": "Le JMESPath vers l'identifiant de l'utilisateur dans le jeton ID",
|
||||
"idpIdentifierChangeTitle": "Avertissement de changement de chemin d'identification",
|
||||
"idpIdentifierChangeDescription": "Vous êtes sur le point de modifier le chemin d'identification. Cela affectera la manière dont les utilisateurs existants sont mappés. Les utilisateurs qui se sont connectés via ce fournisseur d'identité peuvent ne plus être reconnus comme les mêmes utilisateurs.",
|
||||
"idpIdentifierChangeConfirmMessage": "Je confirme",
|
||||
"idpIdentifierChangeWarningText": "Cela affectera la manière dont les utilisateurs existants sont mappés",
|
||||
"idpJmespathEmailPathOptional": "Chemin de l'email (Optionnel)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Le JMESPath vers l'email de l'utilisateur dans le jeton ID",
|
||||
"idpJmespathNamePathOptional": "Chemin du nom (Optionnel)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Rechercher…",
|
||||
"searchPlaceholder": "Recherche...",
|
||||
"emptySearchOptions": "Aucune option trouvée",
|
||||
"ipFilterSearchPlaceholder": "Entrez une adresse IP…",
|
||||
"ipFilterEmptyMessage": "Entrez une adresse IP pour filtrer",
|
||||
"create": "Créer",
|
||||
"orgs": "Organisations",
|
||||
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Type :",
|
||||
"createDomainName": "Nom :",
|
||||
"createDomainValue": "Valeur :",
|
||||
"multiSelectFilterCount": "{count} sélectionné",
|
||||
"createDomainCnameRecords": "Enregistrements CNAME",
|
||||
"createDomainARecords": "Enregistrements A",
|
||||
"createDomainRecordNumber": "Enregistrement {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Méthode",
|
||||
"healthSelectScheme": "Sélectionnez la méthode",
|
||||
"healthCheckPortInvalid": "Le port doit être compris entre 1 et 65535",
|
||||
"healthCheckHostnameInvalid": "Le nom d'hôte ne doit pas contenir d'espaces blancs",
|
||||
"healthCheckPath": "Chemin d'accès",
|
||||
"healthHostname": "IP / Hôte",
|
||||
"healthPort": "Port",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Les sites dotés de ces étiquettes préféreront se connecter via ce nœud de sortie distant.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Scopri di più su JMESPath",
|
||||
"idpJmespathLabel": "Percorso Identificativo",
|
||||
"idpJmespathLabelDescription": "Il JMESPath per l'identificatore dell'utente nel token ID",
|
||||
"idpIdentifierChangeTitle": "Avviso di cambio percorso identificatore",
|
||||
"idpIdentifierChangeDescription": "Stai per cambiare il percorso identificativo. Questo influirà su come gli utenti esistenti sono mappati. Gli utenti che in precedenza hanno effettuato l'accesso attraverso questo provider di identità potrebbero non essere più riconosciuti come gli stessi utenti.",
|
||||
"idpIdentifierChangeConfirmMessage": "Confermo",
|
||||
"idpIdentifierChangeWarningText": "Questo influirà su come gli utenti esistenti sono mappati",
|
||||
"idpJmespathEmailPathOptional": "Percorso Email (Opzionale)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Il JMESPath per l'email dell'utente nel token ID",
|
||||
"idpJmespathNamePathOptional": "Percorso Nome (Opzionale)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Cerca…",
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"emptySearchOptions": "Nessuna opzione trovata",
|
||||
"ipFilterSearchPlaceholder": "Inserisci un indirizzo IP…",
|
||||
"ipFilterEmptyMessage": "Inserisci un indirizzo IP per filtrare",
|
||||
"create": "Crea",
|
||||
"orgs": "Organizzazioni",
|
||||
"loginError": "Si è verificato un errore imprevisto. Riprova.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nome:",
|
||||
"createDomainValue": "Valore:",
|
||||
"multiSelectFilterCount": "{count} selezionato",
|
||||
"createDomainCnameRecords": "Record CNAME",
|
||||
"createDomainARecords": "Record A",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Metodo",
|
||||
"healthSelectScheme": "Seleziona Metodo",
|
||||
"healthCheckPortInvalid": "La porta deve essere compresa tra 1 e 65535",
|
||||
"healthCheckHostnameInvalid": "Il nome dell'host non deve contenere spazi",
|
||||
"healthCheckPath": "Percorso",
|
||||
"healthHostname": "IP / Nome host",
|
||||
"healthPort": "Porta",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette preferiranno connettersi tramite questo nodo di uscita remoto.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기",
|
||||
"idpJmespathLabel": "식별자 경로",
|
||||
"idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로",
|
||||
"idpIdentifierChangeTitle": "식별자 경로 변경 경고",
|
||||
"idpIdentifierChangeDescription": "식별자 경로를 변경하려고 합니다. 이는 기존 사용자의 매핑 방법에 영향을 미칩니다. 이전에 이 ID 공급자를 통해 로그인한 사용자는 더 이상 동일한 사용자로 인식되지 않을 수 있습니다. ",
|
||||
"idpIdentifierChangeConfirmMessage": "확인합니다",
|
||||
"idpIdentifierChangeWarningText": "이는 기존 사용자의 매핑 방법에 영향을 미칩니다",
|
||||
"idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)",
|
||||
"idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로",
|
||||
"idpJmespathNamePathOptional": "이름 경로 (선택 사항)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "검색…",
|
||||
"searchPlaceholder": "검색...",
|
||||
"emptySearchOptions": "옵션이 없습니다",
|
||||
"ipFilterSearchPlaceholder": "IP 주소를 입력하세요…",
|
||||
"ipFilterEmptyMessage": "필터링할 IP 주소를 입력하세요",
|
||||
"create": "생성",
|
||||
"orgs": "조직",
|
||||
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "유형:",
|
||||
"createDomainName": "이름:",
|
||||
"createDomainValue": "값:",
|
||||
"multiSelectFilterCount": "{count} 선택됨",
|
||||
"createDomainCnameRecords": "CNAME 레코드",
|
||||
"createDomainARecords": "A 레코드",
|
||||
"createDomainRecordNumber": "레코드 {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "방법",
|
||||
"healthSelectScheme": "방법 선택",
|
||||
"healthCheckPortInvalid": "포트는 1에서 65535 사이여야 합니다",
|
||||
"healthCheckHostnameInvalid": "호스트 이름에는 공백이 포함될 수 없습니다",
|
||||
"healthCheckPath": "경로",
|
||||
"healthHostname": "IP / 호스트",
|
||||
"healthPort": "포트",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 종료 노드를 통해 연결하는 것을 선호합니다.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Lær mer om JMESPath",
|
||||
"idpJmespathLabel": "Identifikatorsti",
|
||||
"idpJmespathLabelDescription": "Stien til brukeridentifikatoren i ID-tokenet",
|
||||
"idpIdentifierChangeTitle": "Advarsel om identifikatorbanendring",
|
||||
"idpIdentifierChangeDescription": "Du er i ferd med å endre identifikatorbanen. Dette vil påvirke hvordan eksisterende brukere kartlegges. Brukere som tidligere logget inn gjennom denne identitetsleverandøren kan ikke lenger bli gjenkjent som de samme brukerne.",
|
||||
"idpIdentifierChangeConfirmMessage": "Jeg bekrefter",
|
||||
"idpIdentifierChangeWarningText": "Dette vil påvirke hvordan eksisterende brukere kartlegges",
|
||||
"idpJmespathEmailPathOptional": "E-poststi (Valgfritt)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Stien til brukerens e-postadresse i ID-tokenet",
|
||||
"idpJmespathNamePathOptional": "Navn Sti (Valgfritt)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Søk…",
|
||||
"searchPlaceholder": "Søk...",
|
||||
"emptySearchOptions": "Ingen valg funnet",
|
||||
"ipFilterSearchPlaceholder": "Angi en IP-adresse…",
|
||||
"ipFilterEmptyMessage": "Angi en IP-adresse å filtrere etter",
|
||||
"create": "Opprett",
|
||||
"orgs": "Organisasjoner",
|
||||
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Navn:",
|
||||
"createDomainValue": "Verdi:",
|
||||
"multiSelectFilterCount": "{count} valgt",
|
||||
"createDomainCnameRecords": "CNAME-oppføringer",
|
||||
"createDomainARecords": "A-oppføringer",
|
||||
"createDomainRecordNumber": "Oppføring {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Metode",
|
||||
"healthSelectScheme": "Velg metode",
|
||||
"healthCheckPortInvalid": "Porten må være mellom 1 og 65535",
|
||||
"healthCheckHostnameInvalid": "Vertsnavnet må ikke inneholde mellomrom",
|
||||
"healthCheckPath": "Sti",
|
||||
"healthHostname": "IP / Vert",
|
||||
"healthPort": "Port",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Nettsteder med disse etikettene vil foretrekke å koble til gjennom denne eksterne utgangsnoden.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath",
|
||||
"idpJmespathLabel": "ID pad",
|
||||
"idpJmespathLabelDescription": "Het pad naar het gebruiker-id in het ID-token",
|
||||
"idpIdentifierChangeTitle": "Waarschuwing voor Wijziging van ID-pad",
|
||||
"idpIdentifierChangeDescription": "U staat op het punt om het ID-pad te wijzigen. Dit zal invloed hebben op hoe bestaande gebruikers worden gemapt. Gebruikers die voorheen via deze identiteitsprovider inlogden, worden mogelijk niet meer als dezelfde gebruikers herkend.",
|
||||
"idpIdentifierChangeConfirmMessage": "Ik bevestig",
|
||||
"idpIdentifierChangeWarningText": "Dit beïnvloedt hoe bestaande gebruikers worden gemapt",
|
||||
"idpJmespathEmailPathOptional": "E-mail pad (optioneel)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Het pad naar het e-mailadres van de gebruiker in het ID-token",
|
||||
"idpJmespathNamePathOptional": "Naam pad (optioneel)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Zoeken…",
|
||||
"searchPlaceholder": "Zoeken...",
|
||||
"emptySearchOptions": "Geen opties gevonden",
|
||||
"ipFilterSearchPlaceholder": "Voer een IP-adres in…",
|
||||
"ipFilterEmptyMessage": "Voer een IP-adres in om op te filteren",
|
||||
"create": "Aanmaken",
|
||||
"orgs": "Organisaties",
|
||||
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Naam:",
|
||||
"createDomainValue": "Waarde:",
|
||||
"multiSelectFilterCount": "{count} geselecteerd",
|
||||
"createDomainCnameRecords": "CNAME-records",
|
||||
"createDomainARecords": "A Records",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Methode",
|
||||
"healthSelectScheme": "Selecteer methode",
|
||||
"healthCheckPortInvalid": "Poort moet tussen 1 en 65535 zijn",
|
||||
"healthCheckHostnameInvalid": "De hostnaam mag geen witruimtes bevatten",
|
||||
"healthCheckPath": "Pad",
|
||||
"healthHostname": "IP / Hostnaam",
|
||||
"healthPort": "Poort",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels zullen bij voorkeur verbinding maken via deze externe exitnode.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Dowiedz się więcej o JMESPath",
|
||||
"idpJmespathLabel": "Ścieżka identyfikatora",
|
||||
"idpJmespathLabelDescription": "JMESPath do identyfikatora użytkownika w tokenie ID",
|
||||
"idpIdentifierChangeTitle": "Ostrzeżenie o zmianie ścieżki identyfikatora",
|
||||
"idpIdentifierChangeDescription": "Zaraz zmienisz ścieżkę identyfikatora. To wpłynie na sposób mapowania istniejących użytkowników. Użytkownicy, którzy wcześniej logowali się przez tego dostawcę tożsamości, mogą nie być już rozpoznawani jako ci sami użytkownicy.",
|
||||
"idpIdentifierChangeConfirmMessage": "Potwierdzam",
|
||||
"idpIdentifierChangeWarningText": "To wpłynie na sposób mapowania istniejących użytkowników",
|
||||
"idpJmespathEmailPathOptional": "Ścieżka email (Opcjonalnie)",
|
||||
"idpJmespathEmailPathOptionalDescription": "JMESPath do emaila użytkownika w tokenie ID",
|
||||
"idpJmespathNamePathOptional": "Ścieżka nazwy (Opcjonalnie)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Szukaj…",
|
||||
"searchPlaceholder": "Szukaj...",
|
||||
"emptySearchOptions": "Nie znaleziono opcji",
|
||||
"ipFilterSearchPlaceholder": "Wprowadź adres IP…",
|
||||
"ipFilterEmptyMessage": "Wprowadź adres IP, aby filtrować",
|
||||
"create": "Utwórz",
|
||||
"orgs": "Organizacje",
|
||||
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Typ:",
|
||||
"createDomainName": "Nazwa:",
|
||||
"createDomainValue": "Wartość:",
|
||||
"multiSelectFilterCount": "{count, plural, one {# wybrany} few {# wybrane} many {# wybranych} other {# wybranych}}",
|
||||
"createDomainCnameRecords": "Rekordy CNAME",
|
||||
"createDomainARecords": "Rekordy A",
|
||||
"createDomainRecordNumber": "Rekord {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Metoda",
|
||||
"healthSelectScheme": "Wybierz metodę",
|
||||
"healthCheckPortInvalid": "Port musi być pomiędzy 1 a 65535",
|
||||
"healthCheckHostnameInvalid": "Nazwa hosta nie może zawierać spacji",
|
||||
"healthCheckPath": "Ścieżka",
|
||||
"healthHostname": "IP / Nazwa hosta",
|
||||
"healthPort": "Port",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą preferować połączenie przez ten zdalny węzeł wyjściowy.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Saiba mais sobre JMESPath",
|
||||
"idpJmespathLabel": "Caminho do Identificador",
|
||||
"idpJmespathLabelDescription": "O JMESPath para o identificador do utilizador no token ID",
|
||||
"idpIdentifierChangeTitle": "Aviso de Mudança no Caminho do Identificador",
|
||||
"idpIdentifierChangeDescription": "Você está prestes a mudar o caminho do identificador. Isso afetará como os usuários existentes são mapeados. Usuários que anteriormente fizeram login através deste provedor de identidade podem não ser mais reconhecidos como os mesmos usuários.",
|
||||
"idpIdentifierChangeConfirmMessage": "Eu confirmo",
|
||||
"idpIdentifierChangeWarningText": "Isto afetará como os usuários existentes são mapeados",
|
||||
"idpJmespathEmailPathOptional": "Caminho do Email (Opcional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "O JMESPath para o email do utilizador no token ID",
|
||||
"idpJmespathNamePathOptional": "Caminho do Nome (Opcional)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Pesquisar…",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "Nenhuma opção encontrada",
|
||||
"ipFilterSearchPlaceholder": "Insira um endereço IP…",
|
||||
"ipFilterEmptyMessage": "Insira um endereço IP para filtrar",
|
||||
"create": "Criar",
|
||||
"orgs": "Organizações",
|
||||
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nome:",
|
||||
"createDomainValue": "Valor:",
|
||||
"multiSelectFilterCount": "{count} selecionado",
|
||||
"createDomainCnameRecords": "Registros CNAME",
|
||||
"createDomainARecords": "Registros A",
|
||||
"createDomainRecordNumber": "Registrar {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Método",
|
||||
"healthSelectScheme": "Selecione o Método",
|
||||
"healthCheckPortInvalid": "A porta deve estar entre 1 e 65535",
|
||||
"healthCheckHostnameInvalid": "O nome do host não deve conter espaços em branco",
|
||||
"healthCheckPath": "Caminho",
|
||||
"healthHostname": "IP / Nome do Host",
|
||||
"healthPort": "Porta",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites com estas etiquetas preferirão conectar-se por meio deste nó de saída remoto.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath",
|
||||
"idpJmespathLabel": "Путь идентификатора",
|
||||
"idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене",
|
||||
"idpIdentifierChangeTitle": "Предупреждение о изменении пути идентификатора",
|
||||
"idpIdentifierChangeDescription": "Вы собираетесь изменить путь идентификатора. Это повлияет на то, как отображаются существующие пользователи. Пользователи, которые ранее входили через этого поставщика идентификации, могут больше не распознаваться как те же пользователи.",
|
||||
"idpIdentifierChangeConfirmMessage": "Я подтверждаю",
|
||||
"idpIdentifierChangeWarningText": "Это повлияет на то, как отображаются существующие пользователи",
|
||||
"idpJmespathEmailPathOptional": "Путь к email (необязательно)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене",
|
||||
"idpJmespathNamePathOptional": "Путь к имени (необязательно)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Поиск…",
|
||||
"searchPlaceholder": "Поиск...",
|
||||
"emptySearchOptions": "Опции не найдены",
|
||||
"ipFilterSearchPlaceholder": "Введите IP адрес…",
|
||||
"ipFilterEmptyMessage": "Введите IP адрес для фильтрации",
|
||||
"create": "Создать",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Тип:",
|
||||
"createDomainName": "Имя:",
|
||||
"createDomainValue": "Значение:",
|
||||
"multiSelectFilterCount": "Выбрано: {count}",
|
||||
"createDomainCnameRecords": "CNAME Записи",
|
||||
"createDomainARecords": "A Записи",
|
||||
"createDomainRecordNumber": "Запись {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Метод",
|
||||
"healthSelectScheme": "Выберите метод",
|
||||
"healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535",
|
||||
"healthCheckHostnameInvalid": "Имя хоста не должно содержать пробелов",
|
||||
"healthCheckPath": "Путь",
|
||||
"healthHostname": "IP / хост",
|
||||
"healthPort": "Порт",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайты с такими метками предпочтут соединяться через этот удаленный узел выхода.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "JMESPath hakkında daha fazla bilgi edinin",
|
||||
"idpJmespathLabel": "Tanımlayıcı Yolu",
|
||||
"idpJmespathLabelDescription": "The JMESPath to the user identifier in the ID token",
|
||||
"idpIdentifierChangeTitle": "Tanımlayıcı Yol Değişikliği Uyarısı",
|
||||
"idpIdentifierChangeDescription": "Tanımlayıcı yolu değiştirmek üzeresiniz. Bu, mevcut kullanıcıların nasıl eşleneceğini etkileyecektir. Bu kimlik sağlayıcı üzerinden daha önce oturum açmış olan kullanıcılar artık aynı kullanıcılar olarak tanınmayabilir.",
|
||||
"idpIdentifierChangeConfirmMessage": "Onaylıyorum",
|
||||
"idpIdentifierChangeWarningText": "Bu, mevcut kullanıcıların nasıl eşleneceğini etkileyecek",
|
||||
"idpJmespathEmailPathOptional": "E-posta Yolu (İsteğe Bağlı)",
|
||||
"idpJmespathEmailPathOptionalDescription": "The JMESPath to the user's email in the ID token",
|
||||
"idpJmespathNamePathOptional": "Ad Yolu (İsteğe Bağlı)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "Ara…",
|
||||
"searchPlaceholder": "Ara...",
|
||||
"emptySearchOptions": "Seçenek bulunamadı",
|
||||
"ipFilterSearchPlaceholder": "Bir IP adresi girin…",
|
||||
"ipFilterEmptyMessage": "Filtrelemek için bir IP adresi girin",
|
||||
"create": "Oluştur",
|
||||
"orgs": "Organizasyonlar",
|
||||
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "Tür:",
|
||||
"createDomainName": "Ad:",
|
||||
"createDomainValue": "Değer:",
|
||||
"multiSelectFilterCount": "{count} seçildi",
|
||||
"createDomainCnameRecords": "CNAME Kayıtları",
|
||||
"createDomainARecords": "A Kayıtları",
|
||||
"createDomainRecordNumber": "Kayıt {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "Yöntem",
|
||||
"healthSelectScheme": "Yöntem Seç",
|
||||
"healthCheckPortInvalid": "Bağlantı noktası 1 ile 65535 arasında olmalıdır",
|
||||
"healthCheckHostnameInvalid": "Ana bilgisayar adı boşluk içermemelidir",
|
||||
"healthCheckPath": "Yol",
|
||||
"healthHostname": "IP / Hostname",
|
||||
"healthPort": "Bağlantı Noktası",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bağlantıyı bu uzak çıkış düğümü üzerinden tercih edecektir.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
|
||||
|
||||
@@ -1176,10 +1176,6 @@
|
||||
"idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息",
|
||||
"idpJmespathLabel": "标识符路径",
|
||||
"idpJmespathLabelDescription": "ID 令牌中用户标识符的路径",
|
||||
"idpIdentifierChangeTitle": "标识符路径更改警告",
|
||||
"idpIdentifierChangeDescription": "您即将更改标识符路径。这将影响现有用户的映射方式。以前通过此身份提供者登录的用户可能将不再被识别为相同用户。",
|
||||
"idpIdentifierChangeConfirmMessage": "我确认",
|
||||
"idpIdentifierChangeWarningText": "这将影响现有用户的映射方式",
|
||||
"idpJmespathEmailPathOptional": "邮箱路径(可选)",
|
||||
"idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径",
|
||||
"idpJmespathNamePathOptional": "用户名路径(可选)",
|
||||
@@ -1577,8 +1573,6 @@
|
||||
"search": "搜索…",
|
||||
"searchPlaceholder": "搜索...",
|
||||
"emptySearchOptions": "未找到选项",
|
||||
"ipFilterSearchPlaceholder": "输入IP地址…",
|
||||
"ipFilterEmptyMessage": "输入要筛选的IP地址",
|
||||
"create": "创建",
|
||||
"orgs": "组织",
|
||||
"loginError": "发生意外错误。请重试。",
|
||||
@@ -2602,7 +2596,6 @@
|
||||
"createDomainType": "类型:",
|
||||
"createDomainName": "名称:",
|
||||
"createDomainValue": "值:",
|
||||
"multiSelectFilterCount": "{count} 已选择",
|
||||
"createDomainCnameRecords": "CNAME 记录",
|
||||
"createDomainARecords": "A记录",
|
||||
"createDomainRecordNumber": "记录 {number}",
|
||||
@@ -2717,7 +2710,6 @@
|
||||
"healthScheme": "方法",
|
||||
"healthSelectScheme": "选择方法",
|
||||
"healthCheckPortInvalid": "端口必须在 1 和 65535 之间",
|
||||
"healthCheckHostnameInvalid": "主机名不得包含空格",
|
||||
"healthCheckPath": "路径",
|
||||
"healthHostname": "IP / 主机",
|
||||
"healthPort": "端口",
|
||||
@@ -3001,7 +2993,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "首选标签",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "带有这些标签的站点将优先通过此远程出口节点进行连接。",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
|
||||
|
||||
@@ -32,50 +32,49 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1121.0",
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.2.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.3",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.20.0",
|
||||
"axios": "1.18.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -85,106 +84,104 @@
|
||||
"cors": "2.8.6",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3": "7.9.0",
|
||||
"dns-packet": "^5.6.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.7.0",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"glob": "13.0.6",
|
||||
"gpt-tokenizer": "^4.0.0",
|
||||
"helmet": "8.3.0",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.5.0",
|
||||
"ioredis": "6.0.0",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"js-yaml": "4.3.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.38.0",
|
||||
"maxmind": "5.0.7",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.3.3",
|
||||
"next-intl": "4.14.1",
|
||||
"next": "16.3.1",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.1.0",
|
||||
"nodemailer": "9.0.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.51.4",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.8",
|
||||
"react-dom": "19.2.6",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.87.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.5",
|
||||
"semver": "7.8.1",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.6.0",
|
||||
"stripe": "22.2.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.2",
|
||||
"uuid": "14.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.3",
|
||||
"ws": "8.21.0",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.5.4",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.4.3",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "2.23.0",
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.9.3",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.102.8",
|
||||
"@react-email/ui": "^6.9.2",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3": "7.4.3",
|
||||
"@types/dns-packet": "^5.6.5",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/express-session": "1.19.0",
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"postcss": "8.5.26",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.3",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.2",
|
||||
"tsx": "4.23.13",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.23",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0"
|
||||
"typescript-eslint": "8.60.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild": "0.28.0",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.26"
|
||||
"postcss": "8.5.23"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 556 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 516 KiB |
@@ -1,8 +0,0 @@
|
||||
export async function startCertificateManager() {
|
||||
// No-op: ACME certificate generation/management is only available in
|
||||
// builds that include the private/enterprise feature set.
|
||||
}
|
||||
|
||||
export async function stopCertificateManager() {
|
||||
// No-op counterpart to startCertificateManager.
|
||||
}
|
||||
@@ -26,9 +26,7 @@ import {
|
||||
sites,
|
||||
clients,
|
||||
sessions,
|
||||
labels,
|
||||
aiProviders,
|
||||
virtualApiKeys
|
||||
labels
|
||||
} from "./schema";
|
||||
|
||||
export const dnsChallenge = pgTable("dnsChallenges", {
|
||||
@@ -616,87 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = pgTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
sessionId: varchar("sessionId").notNull(),
|
||||
orgId: varchar("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: varchar("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: varchar("requestedModel"),
|
||||
isStream: boolean("isStream").notNull().default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: boolean("truncated").notNull().default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -743,4 +660,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
>;
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
|
||||
@@ -1958,6 +1958,87 @@ export const aiBudgetBreachEvents = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = pgTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
sessionId: varchar("sessionId").notNull(),
|
||||
orgId: varchar("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: varchar("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: varchar("requestedModel"),
|
||||
isStream: boolean("isStream").notNull().default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: boolean("truncated").notNull().default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const certificates = pgTable("certificates", {
|
||||
certId: serial("certId").primaryKey(),
|
||||
domain: varchar("domain", { length: 255 }).notNull().unique(),
|
||||
@@ -2070,6 +2151,7 @@ export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
|
||||
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||
export type SiteResourceAiProvider = InferSelectModel<
|
||||
typeof siteResourceAiProviders
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
aiProviders,
|
||||
clients,
|
||||
domains,
|
||||
exitNodes,
|
||||
@@ -21,8 +20,7 @@ import {
|
||||
siteResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
users,
|
||||
virtualApiKeys
|
||||
users
|
||||
} from "./schema";
|
||||
|
||||
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
||||
@@ -611,91 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
sentAt: integer("sentAt").notNull()
|
||||
});
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = sqliteTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: text("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: text("requestedModel"),
|
||||
isStream: integer("isStream", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: integer("truncated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: integer("createdAt").notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -734,4 +647,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
|
||||
@@ -147,7 +147,9 @@ export const sites = sqliteTable(
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(table) => [index("idx_sites_orgId").on(table.orgId)]
|
||||
(table) => [
|
||||
index("idx_sites_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const resources = sqliteTable(
|
||||
@@ -190,9 +192,7 @@ export const resources = sqliteTable(
|
||||
mode: "boolean"
|
||||
}),
|
||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
@@ -220,14 +220,10 @@ export const resources = sqliteTable(
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<
|
||||
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
|
||||
>()
|
||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
|
||||
.notNull(), // rdp, ssh, http, vnc, inference
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
@@ -240,7 +236,9 @@ export const resources = sqliteTable(
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(table) => [index("idx_resources_orgId").on(table.orgId)]
|
||||
(table) => [
|
||||
index("idx_resources_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const resourceAiProviders = sqliteTable(
|
||||
@@ -290,7 +288,9 @@ export const labels = sqliteTable(
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index("idx_labels_orgId").on(table.orgId)]
|
||||
(table) => [
|
||||
index("idx_labels_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const launcherViews = sqliteTable("launcherViews", {
|
||||
@@ -409,9 +409,7 @@ export const targets = sqliteTable(
|
||||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
@@ -707,7 +705,9 @@ export const newts = sqliteTable(
|
||||
onDelete: "cascade"
|
||||
})
|
||||
},
|
||||
(table) => [index("idx_newts_siteId").on(table.siteId)]
|
||||
(table) => [
|
||||
index("idx_newts_siteId").on(table.siteId)
|
||||
]
|
||||
);
|
||||
|
||||
export const clients = sqliteTable(
|
||||
@@ -740,12 +740,8 @@ export const clients = sqliteTable(
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
// endpoint: text("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
archived: integer("archived", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
blocked: integer("blocked", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
|
||||
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
|
||||
approvalState: text("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
@@ -799,11 +795,11 @@ export const olms = sqliteTable(
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
archived: integer("archived", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
||||
},
|
||||
(table) => [index("idx_olms_userId").on(table.userId)]
|
||||
(table) => [
|
||||
index("idx_olms_userId").on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const currentFingerprint = sqliteTable("currentFingerprint", {
|
||||
@@ -979,7 +975,9 @@ export const sessions = sqliteTable(
|
||||
.notNull()
|
||||
.default(false)
|
||||
},
|
||||
(table) => [index("idx_sessions_userId").on(table.userId)]
|
||||
(table) => [
|
||||
index("idx_sessions_userId").on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const newtSessions = sqliteTable("newtSession", {
|
||||
@@ -1009,9 +1007,7 @@ export const userOrgs = sqliteTable(
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isOwner: integer("isOwner", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
@@ -1066,12 +1062,14 @@ export const roles = sqliteTable(
|
||||
}).default(false),
|
||||
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: integer("sshCreateHomeDir", {
|
||||
mode: "boolean"
|
||||
}).default(true),
|
||||
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
|
||||
true
|
||||
),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
},
|
||||
(table) => [index("idx_roles_orgId").on(table.orgId)]
|
||||
(table) => [
|
||||
index("idx_roles_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const userOrgRoles = sqliteTable(
|
||||
@@ -1999,6 +1997,91 @@ export const aiBudgetBreachEvents = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = sqliteTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: text("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: text("requestedModel"),
|
||||
isStream: integer("isStream", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: integer("truncated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: integer("createdAt").notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
domain: text("domain").notNull().unique(),
|
||||
@@ -2109,6 +2192,7 @@ export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
|
||||
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||
export type SiteResourceAiProvider = InferSelectModel<
|
||||
typeof siteResourceAiProviders
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export async function startDnsServer() {
|
||||
// No-op: the authoritative DNS server is only available in builds
|
||||
// that include the private/enterprise feature set.
|
||||
}
|
||||
|
||||
export async function stopDnsServer() {
|
||||
// No-op counterpart to startDnsServer.
|
||||
}
|
||||
@@ -25,8 +25,6 @@ import { setHostMeta } from "@server/lib/hostMeta";
|
||||
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||
import { initCleanup } from "#dynamic/cleanup";
|
||||
import { startSchedulers } from "#dynamic/startSchedulers";
|
||||
import { startDnsServer } from "#dynamic/dns";
|
||||
import { startCertificateManager } from "#dynamic/certificates";
|
||||
import license from "#dynamic/license/license";
|
||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
||||
@@ -47,10 +45,6 @@ async function startServers() {
|
||||
|
||||
startSchedulers();
|
||||
|
||||
await startDnsServer();
|
||||
|
||||
await startCertificateManager();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
const internalServer = createInternalServer();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { registry, openApiTags } from "./openApi";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { APP_PATH } from "./lib/consts";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
|
||||
@@ -121,37 +121,25 @@ export async function applyBlueprint({
|
||||
(hc) => hc.targetId === target.targetId
|
||||
);
|
||||
|
||||
// The DB writes for all resources have already committed
|
||||
// by this point, so a push failure for one target (e.g.
|
||||
// a newt rejecting a malformed health check) must not
|
||||
// abort pushing the rest, and must not mark the whole
|
||||
// blueprint as failed when the config was actually
|
||||
// persisted successfully.
|
||||
try {
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { and, asc, eq, or } from "drizzle-orm";
|
||||
import { Transaction, User, userOrgs, users } from "@server/db";
|
||||
|
||||
export async function findOrgUsersByIdentifier(
|
||||
export async function findOrgUserByIdentifier(
|
||||
trx: Transaction,
|
||||
orgId: string,
|
||||
identifier: string
|
||||
): Promise<User[]> {
|
||||
const matches = await trx
|
||||
): Promise<User | null> {
|
||||
const [match] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
@@ -16,9 +16,10 @@ export async function findOrgUsersByIdentifier(
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(users.dateCreated), asc(users.userId));
|
||||
.orderBy(asc(users.dateCreated), asc(users.userId))
|
||||
.limit(1);
|
||||
|
||||
return matches.map((match) => match.user);
|
||||
return match?.user ?? null;
|
||||
}
|
||||
|
||||
export async function resolveOrgUserIds(
|
||||
@@ -28,12 +29,8 @@ export async function resolveOrgUserIds(
|
||||
): Promise<string[]> {
|
||||
const userIds = new Set<string>();
|
||||
for (const identifier of identifiers) {
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
identifier
|
||||
);
|
||||
for (const user of matchedUsers) {
|
||||
const user = await findOrgUserByIdentifier(trx, orgId, identifier);
|
||||
if (user) {
|
||||
userIds.add(user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||
import { findOrgUserByIdentifier } from "./findOrgUser";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
import { syncInferenceAiConfig } from "./aiProviders";
|
||||
@@ -1564,27 +1564,21 @@ async function syncUserResources(
|
||||
.where(eq(userResources.resourceId, resourceId));
|
||||
|
||||
for (const username of ssoUsers) {
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
const user = await findOrgUserByIdentifier(trx, orgId, username);
|
||||
|
||||
if (matchedUsers.length === 0) {
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${username} in org ${orgId}`);
|
||||
}
|
||||
|
||||
for (const user of matchedUsers) {
|
||||
const existingUserResource = existingUserResources.find(
|
||||
(rr) => rr.userId === user.userId
|
||||
);
|
||||
const existingUserResource = existingUserResources.find(
|
||||
(rr) => rr.userId === user.userId
|
||||
);
|
||||
|
||||
if (!existingUserResource) {
|
||||
await trx.insert(userResources).values({
|
||||
userId: user.userId,
|
||||
resourceId: resourceId
|
||||
});
|
||||
}
|
||||
if (!existingUserResource) {
|
||||
await trx.insert(userResources).values({
|
||||
userId: user.userId,
|
||||
resourceId: resourceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1952,27 +1946,21 @@ async function syncUserPolicies(
|
||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||
|
||||
for (const username of ssoUsers) {
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
const user = await findOrgUserByIdentifier(trx, orgId, username);
|
||||
|
||||
if (matchedUsers.length === 0) {
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${username} in org ${orgId}`);
|
||||
}
|
||||
|
||||
for (const user of matchedUsers) {
|
||||
const existingUserPolicy = existingUserPoliciesList.find(
|
||||
(up) => up.userId === user.userId
|
||||
);
|
||||
const existingUserPolicy = existingUserPoliciesList.find(
|
||||
(up) => up.userId === user.userId
|
||||
);
|
||||
|
||||
if (!existingUserPolicy) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
if (!existingUserPolicy) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||
import { findOrgUserByIdentifier } from "./findOrgUser";
|
||||
|
||||
export type ResourcePoliciesResults = {
|
||||
resourcePolicyId: number;
|
||||
@@ -467,30 +467,24 @@ async function syncUserPolicies(
|
||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||
|
||||
for (const username of ssoUsers) {
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
const user = await findOrgUserByIdentifier(trx, orgId, username);
|
||||
|
||||
if (matchedUsers.length === 0) {
|
||||
if (!user) {
|
||||
logger.warn(
|
||||
`User '${username}' not found in org '${orgId}', skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const user of matchedUsers) {
|
||||
const alreadyExists = existingUserPolicies.some(
|
||||
(up) => up.userId === user.userId
|
||||
);
|
||||
const alreadyExists = existingUserPolicies.some(
|
||||
(up) => up.userId === user.userId
|
||||
);
|
||||
|
||||
if (!alreadyExists) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
if (!alreadyExists) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,25 +527,19 @@ async function addUserPolicies(
|
||||
trx: Transaction
|
||||
) {
|
||||
for (const username of ssoUsers) {
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
const user = await findOrgUserByIdentifier(trx, orgId, username);
|
||||
|
||||
if (matchedUsers.length === 0) {
|
||||
if (!user) {
|
||||
logger.warn(
|
||||
`User '${username}' not found in org '${orgId}', skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const user of matchedUsers) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,34 +29,8 @@ export const SiteSchema = z.object({
|
||||
"docker-socket-enabled": z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
// A malformed hostname (e.g. stray whitespace) is silently accepted here but
|
||||
// fails to parse as a URL when newt builds the health check request, which
|
||||
// takes the target out of the routing pool and breaks the resource entirely
|
||||
// (see #3677). Validate eagerly so blueprints reject it up front instead.
|
||||
const healthCheckHostnameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((val) => !/\s/.test(val), {
|
||||
message: "Hostname must not contain whitespace"
|
||||
})
|
||||
.refine(
|
||||
(val) => {
|
||||
if (z.union([z.ipv4(), z.ipv6()]).safeParse(val).success) {
|
||||
return true;
|
||||
}
|
||||
const hostnameRegex =
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
return hostnameRegex.test(val);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Hostname must be a valid IP address or hostname (no spaces or invalid characters)"
|
||||
}
|
||||
);
|
||||
|
||||
export const TargetHealthCheckSchema = z.object({
|
||||
hostname: healthCheckHostnameSchema,
|
||||
hostname: z.string(),
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
path: z.string().optional().default("/"),
|
||||
|
||||
@@ -112,11 +112,6 @@ export class Config {
|
||||
? "true"
|
||||
: "false";
|
||||
|
||||
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI = parsedConfig.flags
|
||||
?.disable_virtual_api_keys_ui
|
||||
? "true"
|
||||
: "false";
|
||||
|
||||
this.rawConfig = parsedConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function withRetry<T>(
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
logger.warn(
|
||||
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
{ code: error?.code ?? error?.cause?.code }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
@@ -348,8 +348,8 @@ export const configSchema = z
|
||||
.optional()
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/18"),
|
||||
block_size: z.number().positive().gt(0).optional().default(22),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||
block_size: z.number().positive().gt(0).optional().default(24),
|
||||
site_block_size: z
|
||||
.number()
|
||||
.positive()
|
||||
@@ -442,7 +442,6 @@ export const configSchema = z
|
||||
disable_config_managed_domains: z.boolean().optional(),
|
||||
disable_product_help_banners: z.boolean().optional(),
|
||||
disable_enterprise_features: z.boolean().optional(),
|
||||
disable_virtual_api_keys_ui: z.boolean().optional(),
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true),
|
||||
disable_private_http_placeholder: z
|
||||
.boolean()
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
retries?: number;
|
||||
baseDelayMs?: number;
|
||||
label?: string;
|
||||
// Called with each caught error to decide whether it's worth
|
||||
// retrying. Defaults to retrying everything (existing behavior) -
|
||||
// pass this to exclude errors that are known to be permanent (e.g.
|
||||
// an upstream rate limit or validation rejection) rather than
|
||||
// transient, so they fail fast instead of wasting retry attempts.
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
retries = 3,
|
||||
baseDelayMs = 250,
|
||||
label = "operation",
|
||||
shouldRetry = () => true
|
||||
} = options;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
attempt++;
|
||||
if (attempt > retries || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter so retries don't all land at once.
|
||||
const delay =
|
||||
baseDelayMs * 2 ** (attempt - 1) * (0.5 + Math.random());
|
||||
|
||||
logger.warn(
|
||||
`${label} failed (attempt ${attempt}/${retries + 1}), retrying in ${delay.toFixed(0)}ms`,
|
||||
error
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds an operation that has no timeout of its own (e.g. acme-client's
|
||||
// axios instance never sets one, so a stalled TCP connection to the ACME
|
||||
// server hangs forever instead of erroring). Without this, a single hung
|
||||
// call can leave its caller's promise permanently unsettled - fatal for
|
||||
// code that gates future work on that promise resolving, like the
|
||||
// scheduler's runExclusive() waiting on a batch's Promise.all.
|
||||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
label = "operation"
|
||||
): Promise<T> {
|
||||
let timer: NodeJS.Timeout;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
||||
ms
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer!);
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ export class TraefikConfigManager {
|
||||
this.lastActiveDomains = new Set(domains);
|
||||
}
|
||||
|
||||
if (process.env.CERT_MODE === "pangolin" && build != "oss") {
|
||||
if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
|
||||
// Scan current local certificate state
|
||||
this.lastLocalCertificateState =
|
||||
await this.scanLocalCertificateState();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
||||
// Wildcard resources match any single subdomain via HostRegexp.
|
||||
/**
|
||||
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
||||
* Wildcard resources match any single subdomain via HostRegexp.
|
||||
*/
|
||||
export function buildHostRule(
|
||||
fullDomain: string,
|
||||
wildcard?: boolean | null
|
||||
@@ -12,8 +14,10 @@ export function buildHostRule(
|
||||
return `Host(\`${fullDomain}\`)`;
|
||||
}
|
||||
|
||||
// Append a path-matching clause to a Traefik rule based on the resource's
|
||||
// configured path and pathMatchType.
|
||||
/**
|
||||
* Append a path-matching clause to a Traefik rule based on the resource's
|
||||
* configured path and pathMatchType.
|
||||
*/
|
||||
export function appendPathMatch(
|
||||
rule: string,
|
||||
path: string | null | undefined,
|
||||
@@ -36,8 +40,10 @@ export function appendPathMatch(
|
||||
return rule;
|
||||
}
|
||||
|
||||
// Compute the router priority for a resource, favoring an explicit override
|
||||
// and otherwise deriving it from the path match specificity.
|
||||
/**
|
||||
* Compute the router priority for a resource, favoring an explicit override
|
||||
* and otherwise deriving it from the path match specificity.
|
||||
*/
|
||||
export function computeRoutePriority(
|
||||
priority: number | null | undefined,
|
||||
path: string | null | undefined,
|
||||
|
||||
@@ -1,17 +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.
|
||||
*/
|
||||
|
||||
export {
|
||||
startCertificateManager,
|
||||
stopCertificateManager
|
||||
} from "./lib/certificates";
|
||||
@@ -20,8 +20,6 @@ import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"
|
||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
||||
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
||||
import { stopDnsServer } from "./dns";
|
||||
import { stopCertificateManager } from "./certificates";
|
||||
|
||||
async function cleanup() {
|
||||
await stopPingAccumulator();
|
||||
@@ -33,8 +31,6 @@ async function cleanup() {
|
||||
await rateLimitService.cleanup();
|
||||
await wsCleanup();
|
||||
await logStreamingManager.shutdown();
|
||||
await stopDnsServer();
|
||||
await stopCertificateManager();
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -1,44 +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 { AuthoritativeDNSServer } from "#private/lib/dns";
|
||||
import { privateConfig } from "#private/lib/config";
|
||||
|
||||
let dnsServer: AuthoritativeDNSServer | undefined;
|
||||
|
||||
export async function startDnsServer() {
|
||||
const dnsConfig = privateConfig.getRawPrivateConfig().dns;
|
||||
if (!dnsConfig || !dnsConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheOptions = {
|
||||
stdTTL: 300, // 5 minutes default TTL
|
||||
checkperiod: 60, // Check for expired keys every 60 seconds
|
||||
useClones: false // Better performance
|
||||
};
|
||||
|
||||
// Create DNS server
|
||||
dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port, cacheOptions);
|
||||
|
||||
await dnsServer.start();
|
||||
}
|
||||
|
||||
export async function stopDnsServer() {
|
||||
if (!dnsServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await dnsServer.stop();
|
||||
dnsServer = undefined;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import NodeCache from "node-cache";
|
||||
import logger from "@server/logger";
|
||||
import { redisManager, regionalRedisManager } from "#private/lib/redis";
|
||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||
|
||||
// Create local cache with maxKeys limit to prevent memory leaks
|
||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||
@@ -97,11 +97,11 @@ class AdaptiveCache {
|
||||
const value = await redisManager.get(key);
|
||||
|
||||
if (value !== null) {
|
||||
// logger.debug(`Cache hit in Redis: ${key}`);
|
||||
logger.debug(`Cache hit in Redis: ${key}`);
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
// logger.debug(`Cache miss in Redis: ${key}`);
|
||||
logger.debug(`Cache miss in Redis: ${key}`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.error(`Redis get error for key ${key}:`, error);
|
||||
@@ -134,7 +134,7 @@ class AdaptiveCache {
|
||||
const success = await redisManager.del(k);
|
||||
if (success) {
|
||||
deletedCount++;
|
||||
// logger.debug(`Deleted key from Redis: ${k}`);
|
||||
logger.debug(`Deleted key from Redis: ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ class AdaptiveCache {
|
||||
const success = localCache.del(k);
|
||||
if (success > 0) {
|
||||
deletedCount++;
|
||||
// logger.debug(`Deleted key from local cache: ${k}`);
|
||||
logger.debug(`Deleted key from local cache: ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ class AdaptiveCache {
|
||||
}
|
||||
|
||||
localCache.flushAll();
|
||||
// logger.debug("Flushed local cache");
|
||||
logger.debug("Flushed local cache");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,7 +332,7 @@ class RegionalAdaptiveCache {
|
||||
redisTtl
|
||||
);
|
||||
if (success) {
|
||||
// logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -353,10 +353,10 @@ class RegionalAdaptiveCache {
|
||||
try {
|
||||
const value = await regionalRedisManager.get(key);
|
||||
if (value !== null) {
|
||||
// logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
// logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -385,7 +385,7 @@ class RegionalAdaptiveCache {
|
||||
const success = await regionalRedisManager.del(k);
|
||||
if (success) {
|
||||
deletedCount++;
|
||||
// logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||
}
|
||||
}
|
||||
if (deletedCount === keys.length) return deletedCount;
|
||||
@@ -400,7 +400,7 @@ class RegionalAdaptiveCache {
|
||||
const count = regionalLocalCache.del(k);
|
||||
if (count > 0) {
|
||||
deletedCount++;
|
||||
// logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||
}
|
||||
}
|
||||
return deletedCount;
|
||||
|
||||
@@ -1,298 +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 * as acme from "acme-client";
|
||||
import * as fs from "fs";
|
||||
import { eq } from "drizzle-orm/sql";
|
||||
import { privateConfig as config } from "#private/lib/config";
|
||||
import { DnsChallenge, db, dnsChallenge } from "@server/db";
|
||||
import { withRetry } from "@server/lib/retry";
|
||||
import logger from "@server/logger";
|
||||
import { acmeRateLimiter } from "./acmeRateLimiter";
|
||||
|
||||
// acme-client's own retry/backoff logging (429 retries, 5xx retries, each
|
||||
// status-poll tick in waitForValidStatus) is a no-op by default - it only
|
||||
// activates via DEBUG=acme-client or this call, neither of which was wired
|
||||
// up. Without it, a cert silently retrying a Let's Encrypt rate limit for
|
||||
// several minutes is indistinguishable in our logs from one that's actually
|
||||
// hung, since our own logging only wraps the call, not what happens inside
|
||||
// it. Must run before any AcmeClient method is called.
|
||||
acme.setLogger((msg: string) => logger.info(`[acme-client] ${msg}`));
|
||||
|
||||
// acme-client's axios retry wrapper treats any response-less request error
|
||||
// (timeout, connection reset, DNS blip reaching the ACME server) as
|
||||
// retryable, but once its internal retries are exhausted it falls through to
|
||||
// `validateStatus(response)` with `response` still undefined, throwing this
|
||||
// uninformative TypeError instead of the real network error.
|
||||
// https://github.com/publishlab/node-acme-client/blob/master/src/axios.js
|
||||
function isUnresponsiveAcmeError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof TypeError &&
|
||||
error.message ===
|
||||
"Cannot read properties of undefined (reading 'config')"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAcmeError(error: unknown): Error {
|
||||
if (isUnresponsiveAcmeError(error)) {
|
||||
return new Error(
|
||||
"ACME server did not respond after repeated attempts (network error reaching the ACME endpoint)",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export class AcmeClientManager {
|
||||
private client: acme.Client | null = null;
|
||||
private accountKey: string | null = null;
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
this.accountKey = await this.loadAccountKey();
|
||||
|
||||
this.client = new acme.Client({
|
||||
directoryUrl: config.getRawConfig().acme!.acme_directory_url,
|
||||
accountKey: this.accountKey
|
||||
});
|
||||
|
||||
// Try to create account or get existing one
|
||||
await this.client.createAccount({
|
||||
termsOfServiceAgreed: true,
|
||||
contact: [`mailto:${config.getRawConfig().acme!.contact_email}`]
|
||||
});
|
||||
|
||||
logger.info("ACME client initialized successfully");
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize ACME client:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccountKey(): Promise<string> {
|
||||
const keyPath = config.getRawConfig().acme!.acme_account_key_path;
|
||||
|
||||
if (fs.existsSync(keyPath)) {
|
||||
logger.info("Loading existing account key");
|
||||
return fs.readFileSync(keyPath, "utf8");
|
||||
} else {
|
||||
logger.info("Generating new account key");
|
||||
const privateKey = await acme.crypto.createPrivateKey();
|
||||
const privateKeyString = privateKey.toString();
|
||||
fs.writeFileSync(keyPath, privateKeyString);
|
||||
return privateKeyString;
|
||||
}
|
||||
}
|
||||
|
||||
getClient(): acme.Client {
|
||||
if (!this.client) {
|
||||
throw new Error("ACME client not initialized");
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async createOrder(domain: string, wildcard: boolean = false): Promise<any> {
|
||||
const client = this.getClient();
|
||||
|
||||
const identifiers = wildcard
|
||||
? [
|
||||
{ type: "dns", value: domain },
|
||||
{ type: "dns", value: `*.${domain}` }
|
||||
]
|
||||
: [{ type: "dns", value: domain }];
|
||||
|
||||
await acmeRateLimiter.acquire();
|
||||
const order = await client.createOrder({
|
||||
identifiers
|
||||
});
|
||||
|
||||
if (wildcard) {
|
||||
logger.info(`Created wildcard order for domain: ${domain}`);
|
||||
} else {
|
||||
logger.info(`Created order for domain: ${domain}`);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async getAuthorizations(order: any): Promise<any[]> {
|
||||
const client = this.getClient();
|
||||
await acmeRateLimiter.acquire();
|
||||
return client.getAuthorizations(order);
|
||||
}
|
||||
|
||||
async handleDnsChallenge(
|
||||
dnsChallenges: {
|
||||
authz: any;
|
||||
challenge: any;
|
||||
}[]
|
||||
): Promise<void> {
|
||||
const client = this.getClient();
|
||||
|
||||
let challengeDomains: DnsChallenge[] = [];
|
||||
|
||||
for (const { authz, challenge } of dnsChallenges) {
|
||||
const keyAuthorization =
|
||||
await client.getChallengeKeyAuthorization(challenge);
|
||||
|
||||
// Extract the domain from authorization
|
||||
const domain = authz.identifier.value;
|
||||
|
||||
// Store challenge in database for DNS server to pick up
|
||||
challengeDomains = await withRetry(
|
||||
() =>
|
||||
db
|
||||
.insert(dnsChallenge)
|
||||
.values({
|
||||
domain: domain,
|
||||
token: challenge.token,
|
||||
keyAuthorization,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
expiresAt: Math.floor(
|
||||
(Date.now() +
|
||||
config.getRawConfig().acme!
|
||||
.challenge_ttl_ms) /
|
||||
1000
|
||||
)
|
||||
})
|
||||
.returning(),
|
||||
{ label: `insert dnsChallenge for domain ${domain}` }
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`DNS challenge stored for domain: ${domain} as token ${challenge.token} and keyAuthorization`
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const failedDomains: string[] = [];
|
||||
|
||||
for (const { authz, challenge } of dnsChallenges) {
|
||||
const domain = authz.identifier.value;
|
||||
const challengeDomain = `_acme-challenge.${domain}`;
|
||||
|
||||
try {
|
||||
// The ACME server occasionally has a transient network blip
|
||||
// mid-sequence; retry the whole verify/complete/wait sequence
|
||||
// rather than just the DNS challenge propagation wait, since
|
||||
// these calls are safe to repeat against the ACME server.
|
||||
await withRetry(
|
||||
async () => {
|
||||
// Verify challenge
|
||||
await acmeRateLimiter.acquire();
|
||||
await client.verifyChallenge(authz, challenge);
|
||||
|
||||
// Complete challenge
|
||||
logger.info(
|
||||
`Completing challenge for domain: ${challengeDomain}`
|
||||
);
|
||||
await acmeRateLimiter.acquire();
|
||||
await client.completeChallenge(challenge);
|
||||
|
||||
// Wait for validation
|
||||
logger.info(
|
||||
`Waiting for challenge to be validated for domain: ${challengeDomain}...`
|
||||
);
|
||||
await acmeRateLimiter.acquire();
|
||||
await client.waitForValidStatus(challenge);
|
||||
},
|
||||
{
|
||||
retries: 2,
|
||||
baseDelayMs: 5000,
|
||||
label: `ACME challenge completion for domain ${domain}`,
|
||||
// Only retry the known network-blip crash - a
|
||||
// genuine validation failure (e.g. challenge marked
|
||||
// "invalid" because the DNS record wasn't found) is
|
||||
// permanent and should fail immediately instead of
|
||||
// burning Let's Encrypt's per-hostname failed-
|
||||
// validation rate limit on retries that can't help.
|
||||
shouldRetry: isUnresponsiveAcmeError
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Challenge completed for domain: ${domain}`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to complete challenge for domain ${domain}:`,
|
||||
normalizeAcmeError(error)
|
||||
);
|
||||
failedDomains.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
for (const challengeDomain of challengeDomains) {
|
||||
await this.removeDnsChallenge(challengeDomain.dnsChallengeId);
|
||||
logger.info(
|
||||
`Removed DNS challenge for domain: ${challengeDomain.domain}`
|
||||
);
|
||||
}
|
||||
|
||||
// A failed dns-01 challenge leaves the order stuck in "pending" -
|
||||
// finalizing it would just fail with a confusing ACME error, so
|
||||
// stop here and let the caller mark the certificate as failed.
|
||||
if (failedDomains.length > 0) {
|
||||
throw new Error(
|
||||
`DNS-01 challenge validation failed for domain(s): ${failedDomains.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async removeDnsChallenge(dnsChallengeId: number): Promise<void> {
|
||||
try {
|
||||
await withRetry(
|
||||
() =>
|
||||
db
|
||||
.delete(dnsChallenge)
|
||||
.where(eq(dnsChallenge.dnsChallengeId, dnsChallengeId)),
|
||||
{ label: `delete dnsChallenge ${dnsChallengeId}` }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to clean up DNS challenge for id ${dnsChallengeId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeCertificate(
|
||||
order: any,
|
||||
domain: string,
|
||||
wildcard: boolean = false
|
||||
): Promise<{ certificate: string; privateKey: string }> {
|
||||
const client = this.getClient();
|
||||
|
||||
const altNames = wildcard ? [`*.${domain}`, domain] : [domain];
|
||||
|
||||
// Create CSR
|
||||
const [privateKey, csr] = await acme.crypto.createCsr({
|
||||
altNames
|
||||
});
|
||||
|
||||
// Finalize order
|
||||
await acmeRateLimiter.acquire();
|
||||
const finalizedOrder = await client.finalizeOrder(order, csr);
|
||||
|
||||
// Get certificate
|
||||
await acmeRateLimiter.acquire();
|
||||
const certificate = await client.getCertificate(finalizedOrder);
|
||||
|
||||
logger.info(`Certificate obtained for domain: ${domain}`);
|
||||
|
||||
return {
|
||||
certificate: certificate.toString(),
|
||||
privateKey: privateKey.toString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const acmeClientManager = new AcmeClientManager();
|
||||
@@ -1,71 +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 { privateConfig as config } from "#private/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { redis } from "../redis";
|
||||
// Caps outgoing ACME API calls to a fixed budget per wall-clock second,
|
||||
// shared across all pops workers via Redis (mirrors the lockManager pattern
|
||||
// in @lib/lock) - a per-process limiter wouldn't be enough since multiple
|
||||
// workers issue certificates against the same Let's Encrypt account.
|
||||
const ACQUIRE_SCRIPT = `
|
||||
local key = KEYS[1]
|
||||
local limit = tonumber(ARGV[1])
|
||||
local current = redis.call('INCR', key)
|
||||
if current == 1 then
|
||||
redis.call('PEXPIRE', key, 2000)
|
||||
end
|
||||
if current > limit then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
end
|
||||
`;
|
||||
|
||||
class AcmeRateLimiter {
|
||||
async acquire(): Promise<void> {
|
||||
const limit =
|
||||
config.getRawConfig().acme?.acme_requests_per_second ?? 15;
|
||||
|
||||
for (;;) {
|
||||
const bucket = Math.floor(Date.now() / 1000);
|
||||
const key = `acme_rate_limit:${bucket}`;
|
||||
|
||||
let allowed: number;
|
||||
try {
|
||||
allowed = (await redis.eval(
|
||||
ACQUIRE_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
limit.toString()
|
||||
)) as number;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"ACME rate limiter check failed, proceeding without throttling:",
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowed === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Budget for this second is spent - wait for the next window.
|
||||
const waitMs = 1000 - (Date.now() % 1000) + 10;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const acmeRateLimiter = new AcmeRateLimiter();
|
||||
@@ -1,511 +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 { acmeClientManager } from "./acme-client";
|
||||
import { dnsValidator } from "./dns-validator";
|
||||
import { getTableColumns } from "drizzle-orm";
|
||||
import { eq, and, or, isNull, lt, asc } from "drizzle-orm/sql";
|
||||
import { config } from "@server/lib/config";
|
||||
import { db, certificates, domains, Certificate } from "@server/db";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { withTimeout, withRetry } from "@server/lib/retry";
|
||||
import logger from "@server/logger";
|
||||
import { lockManager } from "../lock";
|
||||
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Number of on-demand DNS validation attempts made right before a
|
||||
// certificate is (re)issued, to avoid burning Let's Encrypt rate limits on
|
||||
// domains whose DNS has drifted since they were last verified.
|
||||
const PRE_CERT_DNS_VALIDATION_ATTEMPTS = 3;
|
||||
|
||||
// Hard ceiling on a single certificate's issuance/renewal flow. acme-client's
|
||||
// axios instance never sets a request timeout, so a stalled connection to
|
||||
// the ACME server hangs forever instead of erroring - and since
|
||||
// processPendingCertificates/processRenewalCandidates gate the *next* batch
|
||||
// on Promise.all(...) over the current one, one hung certificate would
|
||||
// otherwise stall every other domain permanently. Sized generously above the
|
||||
// legitimate worst case (acme-client's own bounded backoff is ~3.6min per
|
||||
// status-polling loop, and a wildcard cert's two identifiers plus order
|
||||
// finalization can chain a few of those) so this only fires on a genuine hang.
|
||||
const CERTIFICATE_ISSUANCE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
// "requested" is set the instant a cert starts processing and is never
|
||||
// queried anywhere else - processPendingCertificates only selects "pending"
|
||||
// and processRenewalCandidates only selects "valid". So if the *process*
|
||||
// dies mid-flight (OOM, node eviction, a rolling deploy) rather than just
|
||||
// hanging, the row is orphaned in "requested" permanently with nothing to
|
||||
// ever pick it back up, no matter how good the in-process timeouts are.
|
||||
// Threshold is set comfortably above CERTIFICATE_ISSUANCE_TIMEOUT_MS plus the
|
||||
// scheduler's own outer backstop so this never reclaims a cert that's still
|
||||
// genuinely being worked on.
|
||||
const STUCK_CERTIFICATE_THRESHOLD_MS = 40 * 60 * 1000;
|
||||
|
||||
export class CertificateService {
|
||||
// Runs at the top of every processPendingCertificates tick so an
|
||||
// interrupted worker's leftovers always get put back in the queue
|
||||
// instead of sitting invisible to every query forever.
|
||||
private async reclaimStuckCertificates(): Promise<void> {
|
||||
const staleBefore =
|
||||
Math.floor(Date.now() / 1000) -
|
||||
Math.floor(STUCK_CERTIFICATE_THRESHOLD_MS / 1000);
|
||||
|
||||
const reclaimed = await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "pending",
|
||||
errorMessage:
|
||||
'Reclaimed after being stuck in "requested" state - the worker processing it likely restarted or crashed',
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.status, "requested"),
|
||||
lt(certificates.updatedAt, staleBefore)
|
||||
)
|
||||
)
|
||||
.returning({ domain: certificates.domain });
|
||||
|
||||
if (reclaimed.length > 0) {
|
||||
logger.warn(
|
||||
`Reclaimed ${reclaimed.length} certificate(s) stuck in "requested" state: ${reclaimed
|
||||
.map((c) => c.domain)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingCertificates(): Promise<void> {
|
||||
logger.debug("Checking for pending certificates...");
|
||||
|
||||
await this.reclaimStuckCertificates();
|
||||
|
||||
const pendingCerts = await db
|
||||
.select(getTableColumns(certificates))
|
||||
.from(certificates)
|
||||
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.status, "pending"),
|
||||
or(
|
||||
// Certs with no linked domain row (e.g. legacy certs
|
||||
// imported from acme.json) aren't gated on domain
|
||||
// verification since there's nothing to check.
|
||||
isNull(certificates.domainId),
|
||||
and(
|
||||
eq(domains.verified, true),
|
||||
eq(domains.failed, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(10);
|
||||
|
||||
if (pendingCerts.length === 0) {
|
||||
logger.debug("No pending certificates found");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${pendingCerts.length} pending certificates`);
|
||||
|
||||
// Process the batch concurrently so one domain stuck retrying a slow
|
||||
// DNS-01 challenge (the ACME client's waitForValidStatus can spend
|
||||
// minutes on a bad domain) doesn't stall the rest of the batch.
|
||||
// processSingleCertificate catches its own errors and each cert uses
|
||||
// an independent per-domain lock, so this is safe to parallelize.
|
||||
await Promise.all(
|
||||
pendingCerts.map((cert) => this.processSingleCertificate(cert))
|
||||
);
|
||||
}
|
||||
|
||||
async processRenewalCandidates(): Promise<void> {
|
||||
logger.debug("Checking for certificates needing renewal...");
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const renewalCandidates = await db
|
||||
.select(getTableColumns(certificates))
|
||||
.from(certificates)
|
||||
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.status, "valid"),
|
||||
lt(certificates.expiresAt, now + 15 * 24 * 60 * 60), // 15 days from now
|
||||
or(
|
||||
// Certs with no linked domain row (e.g. legacy certs
|
||||
// imported from acme.json) aren't gated on domain
|
||||
// verification since there's nothing to check.
|
||||
isNull(certificates.domainId),
|
||||
and(
|
||||
eq(domains.verified, true),
|
||||
eq(domains.failed, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
// Most urgent first, so already-expired certs aren't starved
|
||||
// behind the limit by certs that still have weeks of runway.
|
||||
.orderBy(asc(certificates.expiresAt))
|
||||
.limit(50);
|
||||
|
||||
if (renewalCandidates.length === 0) {
|
||||
logger.debug("No certificates need renewal");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Found ${renewalCandidates.length} certificates needing renewal`
|
||||
);
|
||||
|
||||
for (const cert of renewalCandidates) {
|
||||
if (cert.expiresAt !== null && cert.expiresAt < now) {
|
||||
logger.warn(
|
||||
`Certificate for ${cert.domain} is marked "valid" but already expired at ${new Date(cert.expiresAt * 1000).toISOString()} (bad state) - renewing immediately`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Process the batch concurrently - see processPendingCertificates for why.
|
||||
await Promise.all(
|
||||
renewalCandidates.map((cert) => this.renewCertificate(cert))
|
||||
);
|
||||
}
|
||||
|
||||
private async processSingleCertificate(cert: Certificate): Promise<void> {
|
||||
const lockKey = `cert:${cert.domain}`;
|
||||
|
||||
const lockToken = await lockManager.acquireLock(lockKey);
|
||||
if (!lockToken) {
|
||||
logger.debug(
|
||||
`Could not acquire lock for certificate: ${cert.domain}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Processing certificate for domain: ${cert.domain}`);
|
||||
|
||||
// Update status to processing
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "requested",
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, cert.certId));
|
||||
//
|
||||
|
||||
await withTimeout(
|
||||
this.obtainCertificate(cert),
|
||||
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
|
||||
`certificate issuance for ${cert.domain}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to process certificate for ${cert.domain}:`,
|
||||
error
|
||||
);
|
||||
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, cert.certId));
|
||||
} finally {
|
||||
await lockManager.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async renewCertificate(cert: Certificate): Promise<void> {
|
||||
const lockKey = `cert:${cert.domain}`;
|
||||
|
||||
const lockToken = await lockManager.acquireLock(lockKey);
|
||||
if (!lockToken) {
|
||||
logger.debug(
|
||||
`Could not acquire lock for certificate renewal: ${cert.domain}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Renewing certificate for domain: ${cert.domain}`);
|
||||
|
||||
// Update last renewal attempt
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
lastRenewalAttempt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, cert.certId));
|
||||
|
||||
await withTimeout(
|
||||
this.obtainCertificate(cert),
|
||||
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
|
||||
`certificate renewal for ${cert.domain}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to renew certificate for ${cert.domain}:`,
|
||||
error
|
||||
);
|
||||
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
lastRenewalAttempt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, cert.certId));
|
||||
} finally {
|
||||
await lockManager.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-checks the domain's DNS records right before we spend a Let's
|
||||
// Encrypt order on it, so drift that happened after the domain was
|
||||
// originally verified doesn't burn ACME rate limits. Certs with no
|
||||
// linked domain row (e.g. legacy/manually-managed certs) skip this and
|
||||
// proceed as before, since there are no tracked DNS records to check.
|
||||
private async verifyDomainBeforeIssuance(cert: Certificate): Promise<void> {
|
||||
if (!cert.domainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [domain] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, cert.domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= PRE_CERT_DNS_VALIDATION_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
// Offset `tries` so each attempt round-robins to a different
|
||||
// privateConfigured DNS resolver instead of re-querying the same one.
|
||||
const probe = { ...domain, tries: domain.tries + attempt - 1 };
|
||||
if (
|
||||
await dnsValidator.validateDomain(probe, {
|
||||
forceRecheck: true
|
||||
})
|
||||
) {
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ verified: true, failed: false, errorMessage: null })
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Pre-certificate DNS check ${attempt}/${PRE_CERT_DNS_VALIDATION_ATTEMPTS} failed for domain ${domain.baseDomain} (cert: ${cert.domain})`
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = `Domain failed DNS validation ${PRE_CERT_DNS_VALIDATION_ATTEMPTS} times before certificate issuance`;
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ verified: false, failed: true, errorMessage })
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
private async obtainCertificate(cert: Certificate): Promise<void> {
|
||||
await this.verifyDomainBeforeIssuance(cert);
|
||||
|
||||
// Create order
|
||||
const order = await acmeClientManager.createOrder(
|
||||
cert.domain,
|
||||
cert.wildcard || false
|
||||
);
|
||||
|
||||
// Update with order ID
|
||||
await withRetry(
|
||||
() =>
|
||||
db
|
||||
.update(certificates)
|
||||
.set({
|
||||
orderId: order.url,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, cert.certId)),
|
||||
{ label: `update orderId for certificate ${cert.domain}` }
|
||||
);
|
||||
|
||||
// Get authorizations
|
||||
const authorizations = await acmeClientManager.getAuthorizations(order);
|
||||
|
||||
// Aggregate all DNS-01 challenges
|
||||
const dnsChallenges = authorizations.map((authz: any) => {
|
||||
const dnsChallenge = authz.challenges.find(
|
||||
(c: any) => c.type === "dns-01"
|
||||
);
|
||||
if (!dnsChallenge) {
|
||||
throw new Error(
|
||||
`No DNS-01 challenge found for ${authz.identifier.value}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
authz,
|
||||
challenge: dnsChallenge
|
||||
};
|
||||
});
|
||||
|
||||
// Send all DNS-01 challenges in one request to handleDnsChallenge
|
||||
await acmeClientManager.handleDnsChallenge(dnsChallenges);
|
||||
|
||||
// Finalize certificate
|
||||
const { certificate, privateKey } =
|
||||
await acmeClientManager.finalizeCertificate(
|
||||
order,
|
||||
cert.domain,
|
||||
cert.wildcard || false
|
||||
);
|
||||
|
||||
const encryptionKey = config.getRawConfig().server.secret;
|
||||
if (!encryptionKey) {
|
||||
throw new Error("Encryption key not provided");
|
||||
}
|
||||
|
||||
// Encrypt certificate and private key
|
||||
const encryptedCert = encrypt(certificate, encryptionKey);
|
||||
const encryptedKey = encrypt(privateKey, encryptionKey);
|
||||
|
||||
// Parse certificate to get expiration date
|
||||
const expiresAt = this.extractExpirationDate(certificate);
|
||||
|
||||
// Update database record. This persists the certificate we just
|
||||
// obtained from the ACME server, so it's retried aggressively -
|
||||
// losing this write means re-issuing the cert from scratch.
|
||||
await withRetry(
|
||||
() =>
|
||||
db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "valid",
|
||||
expiresAt: Math.floor(expiresAt.getTime() / 1000),
|
||||
renewalCount: (cert.renewalCount || 0) + 1,
|
||||
errorMessage: null,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey
|
||||
})
|
||||
.where(eq(certificates.certId, cert.certId)),
|
||||
{
|
||||
retries: 5,
|
||||
label: `persist issued certificate for ${cert.domain}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Certificate successfully obtained/renewed for domain: ${cert.domain}`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
cert.domain,
|
||||
cert.domainId ?? null,
|
||||
certificate,
|
||||
privateKey
|
||||
);
|
||||
}
|
||||
|
||||
private extractExpirationDate(certificate: string): Date {
|
||||
try {
|
||||
// Extract the certificate block
|
||||
const pem = certificate
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||
.replace(/-----END CERTIFICATE-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
const der = Buffer.from(pem, "base64");
|
||||
|
||||
// Use Node.js crypto to parse the certificate
|
||||
const x509 = new crypto.X509Certificate(der);
|
||||
return new Date(x509.validTo);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
"Failed to parse certificate expiration date, using default",
|
||||
error
|
||||
);
|
||||
// Default to 90 days from now (Let's Encrypt default)
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async addCertificateRequest(domain: string): Promise<void> {
|
||||
try {
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
status: "pending",
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
logger.info(`Certificate request added for domain: ${domain}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("unique")) {
|
||||
logger.warn(
|
||||
`Certificate request already exists for domain: ${domain}`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCertificateStatus(domain: string) {
|
||||
const cert = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
return cert[0] || null;
|
||||
}
|
||||
|
||||
async cleanupExpiredChallenges(): Promise<void> {
|
||||
try {
|
||||
const result = await db
|
||||
.delete(certificates)
|
||||
.where(
|
||||
lt(certificates.expiresAt, Math.floor(Date.now() / 1000))
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length > 0) {
|
||||
logger.info(
|
||||
`Cleaned up ${result.length} expired DNS challenges`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to cleanup expired challenges:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const certificateService = new CertificateService();
|
||||
@@ -1,334 +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 { eq, and, lt } from "drizzle-orm";
|
||||
import * as dns from "dns/promises";
|
||||
import { privateConfig as config } from "#private/lib/config";
|
||||
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { lockManager } from "../lock";
|
||||
|
||||
export const DNS_VALIDATOR_MAX_TRIES = 300;
|
||||
|
||||
export class DNSValidator {
|
||||
private static readonly MAX_TRIES = DNS_VALIDATOR_MAX_TRIES;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async validateAll(): Promise<void> {
|
||||
// Get all domains that are not yet verified and haven't exceeded max tries
|
||||
const unverifiedDomains: Domain[] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.verified, false),
|
||||
lt(domains.tries, DNSValidator.MAX_TRIES)
|
||||
)
|
||||
);
|
||||
|
||||
if (unverifiedDomains.length === 0) {
|
||||
logger.debug("No unverified domains found for DNS validation");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Validating ${unverifiedDomains.length} DNS records`);
|
||||
|
||||
for (const domain of unverifiedDomains) {
|
||||
const lockKey = `dns:${domain.baseDomain}`;
|
||||
const lockToken = await lockManager.acquireLock(lockKey);
|
||||
if (!lockToken) {
|
||||
logger.debug(
|
||||
`Could not acquire lock for DNS validation: ${domain.baseDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const isValid = await this.validateDomain(domain);
|
||||
if (isValid) {
|
||||
await db
|
||||
.update(domains)
|
||||
.set({
|
||||
verified: true,
|
||||
failed: false,
|
||||
tries: 0,
|
||||
errorMessage: null
|
||||
})
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
logger.info(
|
||||
`Domain ${domain.baseDomain} validated successfully`
|
||||
);
|
||||
} else {
|
||||
const newTries = domain.tries + 1;
|
||||
const shouldMarkAsFailed =
|
||||
newTries >= DNSValidator.MAX_TRIES;
|
||||
|
||||
await db
|
||||
.update(domains)
|
||||
.set({
|
||||
tries: newTries,
|
||||
failed: shouldMarkAsFailed
|
||||
})
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
|
||||
if (shouldMarkAsFailed) {
|
||||
logger.warn(
|
||||
`Domain ${domain.baseDomain} exceeded maximum tries (${DNSValidator.MAX_TRIES}), marking as failed`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Domain ${domain.baseDomain} did not validate (attempt ${newTries}/${DNSValidator.MAX_TRIES})`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Error validating domain ${domain.baseDomain}:`,
|
||||
err
|
||||
);
|
||||
// Increment tries even on error
|
||||
const newTries = domain.tries + 1;
|
||||
const shouldMarkAsFailed = newTries >= DNSValidator.MAX_TRIES;
|
||||
|
||||
await db
|
||||
.update(domains)
|
||||
.set({
|
||||
tries: newTries,
|
||||
failed: shouldMarkAsFailed
|
||||
})
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
} finally {
|
||||
await lockManager.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateDomain(
|
||||
domain: Domain,
|
||||
opts: { forceRecheck?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const { forceRecheck = false } = opts;
|
||||
const resolver = new dns.Resolver();
|
||||
const servers = config.getRawConfig().acme?.dns_resolvers;
|
||||
if (!servers || servers.length === 0) {
|
||||
throw new Error("No DNS resolvers configured");
|
||||
}
|
||||
const dnsServer = servers[domain.tries % servers.length]!;
|
||||
resolver.setServers([dnsServer]);
|
||||
logger.debug(
|
||||
`Using DNS server ${dnsServer} for domain ${domain.baseDomain} (try ${domain.tries})`
|
||||
);
|
||||
|
||||
// Get all DNS records for this domain
|
||||
const records: DnsRecord[] = await db
|
||||
.select()
|
||||
.from(dnsRecords)
|
||||
.where(eq(dnsRecords.domainId, domain.domainId));
|
||||
|
||||
if (records.length === 0) {
|
||||
logger.warn(`No DNS records found for domain ${domain.baseDomain}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!forceRecheck && records.every((r) => r.verified)) {
|
||||
logger.info(
|
||||
`All DNS records already verified for domain ${domain.baseDomain}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Validating ${records.length} DNS records for domain ${domain.baseDomain}`
|
||||
);
|
||||
|
||||
// Collect the full set of expected NS values for this domain so we can
|
||||
// detect extra records that are present in DNS but not in our DB.
|
||||
const expectedNsValues = new Set<string>(
|
||||
records.filter((r) => r.recordType === "NS").map((r) => r.value)
|
||||
);
|
||||
|
||||
// Cache resolved NS records across iterations — there will be 3 NS
|
||||
// records in the DB and we don't need to hit the upstream server 3 times.
|
||||
let previousNs: string[] | null = null;
|
||||
|
||||
for (const record of records) {
|
||||
// Skip already verified records, unless a live recheck was requested
|
||||
if (record.verified && !forceRecheck) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
|
||||
try {
|
||||
if (record.recordType === "NS") {
|
||||
let nsRecords: string[] | null = previousNs;
|
||||
if (!nsRecords) {
|
||||
nsRecords = await resolver.resolveNs(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
`NS records for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
}:`,
|
||||
nsRecords
|
||||
);
|
||||
|
||||
// Check if this expected NS value is present in the live records.
|
||||
// A stale/legacy expected value (e.g. left over from a
|
||||
// nameserver rebrand) is also accepted as long as the live
|
||||
// records resolve to some other known-valid nameserver —
|
||||
// the specific literal hostname stored per-domain isn't
|
||||
// meaningful once it's a recognized alias.
|
||||
isValid = nsRecords.some((ns) => ns === record.value);
|
||||
|
||||
previousNs = nsRecords;
|
||||
} else if (record.recordType === "CNAME") {
|
||||
const cnameRecords = await resolver.resolveCname(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
logger.info(
|
||||
`CNAME records for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
}:`,
|
||||
cnameRecords
|
||||
);
|
||||
|
||||
// Check if the CNAME record matches the expected value
|
||||
isValid =
|
||||
cnameRecords.length === 1 &&
|
||||
cnameRecords[0] === record.value;
|
||||
} else if (record.recordType === "TXT") {
|
||||
const txtRecords = await resolver.resolveTxt(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
logger.info(
|
||||
`TXT records for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
}:`,
|
||||
txtRecords
|
||||
);
|
||||
|
||||
// TXT records come as an array of arrays, flatten and check
|
||||
const flatTxtRecords = txtRecords.flat();
|
||||
isValid = flatTxtRecords.includes(record.value);
|
||||
} else if (record.recordType === "A") {
|
||||
const aRecords = await resolver.resolve4(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
logger.info(
|
||||
`A records for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
}:`,
|
||||
aRecords
|
||||
);
|
||||
|
||||
// Check if the A record matches the expected value
|
||||
isValid = aRecords.includes(record.value);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Unsupported record type: ${record.recordType}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
isValid = false;
|
||||
logger.debug(
|
||||
`Did not resolve ${record.recordType} record for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Update the individual record verification status. Runs for
|
||||
// both a mismatched value and a failed/thrown DNS lookup, so a
|
||||
// previously-verified record that stops resolving (e.g. NXDOMAIN
|
||||
// after NS delegation is dropped) gets downgraded instead of
|
||||
// leaving stale `verified: true` state behind.
|
||||
if (isValid) {
|
||||
await db
|
||||
.update(dnsRecords)
|
||||
.set({ verified: true })
|
||||
.where(eq(dnsRecords.id, record.id));
|
||||
logger.info(
|
||||
`DNS record ${record.id} (${record.recordType}) for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
} verified successfully`
|
||||
);
|
||||
} else {
|
||||
if (record.verified) {
|
||||
await db
|
||||
.update(dnsRecords)
|
||||
.set({ verified: false })
|
||||
.where(eq(dnsRecords.id, record.id));
|
||||
}
|
||||
logger.debug(
|
||||
`DNS record ${record.id} (${record.recordType}) for ${
|
||||
record.baseDomain || domain.baseDomain
|
||||
} does not match expected value: ${record.value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Extra NS record check ---
|
||||
// If we resolved NS records during this pass, verify that the live DNS
|
||||
// has no nameservers beyond the ones we expect. Individual records may
|
||||
// already be marked verified above, but we must block full domain
|
||||
// verification until the extra records are removed.
|
||||
if (previousNs !== null && expectedNsValues.size > 0) {
|
||||
const extraNsRecords = previousNs.filter(
|
||||
(ns) => !expectedNsValues.has(ns)
|
||||
);
|
||||
|
||||
if (extraNsRecords.length > 0) {
|
||||
const errorMessage = `Extra NS records found that are not expected: ${extraNsRecords.join(", ")}. Remove these nameservers to complete domain verification.`;
|
||||
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ errorMessage })
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
|
||||
logger.warn(
|
||||
`Domain ${domain.baseDomain} has extra NS records that prevent verification: ${extraNsRecords.join(", ")}`
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// No extras — clear any stale error that was previously written
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ errorMessage: null })
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
}
|
||||
|
||||
// Check if all records are now verified
|
||||
const updatedRecords: DnsRecord[] = await db
|
||||
.select()
|
||||
.from(dnsRecords)
|
||||
.where(eq(dnsRecords.domainId, domain.domainId));
|
||||
|
||||
const allRecordsVerified = updatedRecords.every((r) => r.verified);
|
||||
|
||||
logger.info(
|
||||
`Domain ${domain.baseDomain}: ${
|
||||
updatedRecords.filter((r) => r.verified).length
|
||||
}/${updatedRecords.length} records verified`
|
||||
);
|
||||
|
||||
return allRecordsVerified;
|
||||
}
|
||||
}
|
||||
|
||||
export const dnsValidator = new DNSValidator();
|
||||
@@ -1,233 +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 { eq, and, or, isNull, lt } from "drizzle-orm";
|
||||
import * as dns from "dns/promises";
|
||||
import { DNS_VALIDATOR_MAX_TRIES } from "./dns-validator";
|
||||
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { lockManager } from "../lock";
|
||||
import { privateConfig as config } from "#private/lib/config";
|
||||
|
||||
// Module-level counter so successive domains in a batch round-robin across servers.
|
||||
let serverIndex = 0;
|
||||
|
||||
export class DomainReverifier {
|
||||
async reverifyAll(): Promise<void> {
|
||||
const certConfig = config.getRawConfig().acme;
|
||||
if (!certConfig) {
|
||||
logger.debug(
|
||||
"No certificate config — skipping domain reverification"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const windowMs = certConfig.domain_reverification_window_ms;
|
||||
const batchSize = certConfig.domain_reverification_batch_size;
|
||||
const windowSecs = Math.floor(windowMs / 1000);
|
||||
const cutoff = Math.floor(Date.now() / 1000) - windowSecs;
|
||||
|
||||
const domainsToCheck: Domain[] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.verified, true),
|
||||
or(
|
||||
isNull(domains.lastCheckedAt),
|
||||
lt(domains.lastCheckedAt, cutoff)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(batchSize);
|
||||
|
||||
if (domainsToCheck.length === 0) {
|
||||
logger.debug("No verified domains due for reverification");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Reverifying ${domainsToCheck.length} domains`);
|
||||
|
||||
for (const domain of domainsToCheck) {
|
||||
const lockKey = `dns-reverify:${domain.baseDomain}`;
|
||||
const lockToken = await lockManager.acquireLock(lockKey);
|
||||
if (!lockToken) {
|
||||
logger.debug(
|
||||
`Could not acquire lock for domain reverification: ${domain.baseDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.reverifyDomain(domain, certConfig.dns_resolvers);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Unexpected error reverifying domain ${domain.baseDomain}:`,
|
||||
err
|
||||
);
|
||||
// Still stamp lastCheckedAt so we don't hammer a broken domain every run.
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ lastCheckedAt: Math.floor(Date.now() / 1000) })
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
} finally {
|
||||
await lockManager.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reverifyDomain(
|
||||
domain: Domain,
|
||||
servers: string[]
|
||||
): Promise<void> {
|
||||
if (!servers || servers.length === 0) {
|
||||
throw new Error("No DNS resolvers configured");
|
||||
}
|
||||
|
||||
// Round-robin across servers; advance the global counter so the next
|
||||
// domain in the same batch gets a different server.
|
||||
const dnsServer = servers[serverIndex % servers.length]!;
|
||||
serverIndex++;
|
||||
|
||||
const resolver = new dns.Resolver();
|
||||
resolver.setServers([dnsServer]);
|
||||
|
||||
logger.debug(
|
||||
`Reverifying domain ${domain.baseDomain} using DNS server ${dnsServer}`
|
||||
);
|
||||
|
||||
const records: DnsRecord[] = await db
|
||||
.select()
|
||||
.from(dnsRecords)
|
||||
.where(eq(dnsRecords.domainId, domain.domainId));
|
||||
|
||||
if (records.length === 0) {
|
||||
logger.warn(
|
||||
`No DNS records found for domain ${domain.baseDomain} during reverification — marking failed`
|
||||
);
|
||||
await this.markFailed(
|
||||
domain.domainId,
|
||||
"No DNS records found during periodic reverification"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedNsValues = new Set<string>(
|
||||
records.filter((r) => r.recordType === "NS").map((r) => r.value)
|
||||
);
|
||||
|
||||
let allValid = true;
|
||||
let errorMessage: string | null = null;
|
||||
let resolvedNs: string[] | null = null;
|
||||
|
||||
for (const record of records) {
|
||||
let isValid = false;
|
||||
|
||||
try {
|
||||
if (record.recordType === "NS") {
|
||||
if (!resolvedNs) {
|
||||
resolvedNs = await resolver.resolveNs(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
}
|
||||
isValid = resolvedNs.some((ns) => ns === record.value);
|
||||
} else if (record.recordType === "CNAME") {
|
||||
const cnameRecords = await resolver.resolveCname(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
isValid =
|
||||
cnameRecords.length === 1 &&
|
||||
cnameRecords[0] === record.value;
|
||||
} else if (record.recordType === "TXT") {
|
||||
const txtRecords = await resolver.resolveTxt(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
isValid = txtRecords.flat().includes(record.value);
|
||||
} else if (record.recordType === "A") {
|
||||
const aRecords = await resolver.resolve4(
|
||||
record.baseDomain || domain.baseDomain
|
||||
);
|
||||
isValid = aRecords.includes(record.value);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Unsupported record type ${record.recordType} during reverification of ${domain.baseDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`DNS lookup failed for ${record.recordType} record on ${record.baseDomain || domain.baseDomain}:`,
|
||||
err
|
||||
);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
allValid = false;
|
||||
errorMessage = `${record.recordType} record for ${record.baseDomain || domain.baseDomain} no longer resolves to expected value "${record.value}"`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra NS records beyond what we expect.
|
||||
if (allValid && resolvedNs !== null && expectedNsValues.size > 0) {
|
||||
const extraNs = resolvedNs.filter(
|
||||
(ns) => !expectedNsValues.has(ns)
|
||||
);
|
||||
if (extraNs.length > 0) {
|
||||
allValid = false;
|
||||
errorMessage = `Extra NS records found: ${extraNs.join(", ")}. Remove these nameservers.`;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (allValid) {
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ lastCheckedAt: now, errorMessage: null })
|
||||
.where(eq(domains.domainId, domain.domainId));
|
||||
logger.debug(
|
||||
`Domain ${domain.baseDomain} passed periodic reverification`
|
||||
);
|
||||
} else {
|
||||
await this.markFailed(domain.domainId, errorMessage);
|
||||
logger.warn(
|
||||
`Domain ${domain.baseDomain} failed periodic reverification: ${errorMessage}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async markFailed(
|
||||
domainId: string,
|
||||
errorMessage: string | null
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(domains)
|
||||
.set({
|
||||
verified: false,
|
||||
failed: true,
|
||||
// Three below MAX_TRIES: keeps the domain out of the DNS
|
||||
// validator's immediate retry loop, while still leaving it
|
||||
// eligible (tries < MAX_TRIES) for a few more validation
|
||||
// passes instead of being excluded forever once tries hits
|
||||
// MAX_TRIES.
|
||||
tries: DNS_VALIDATOR_MAX_TRIES - 3,
|
||||
lastCheckedAt: Math.floor(Date.now() / 1000),
|
||||
errorMessage
|
||||
})
|
||||
.where(eq(domains.domainId, domainId));
|
||||
}
|
||||
}
|
||||
|
||||
export const domainReverifier = new DomainReverifier();
|
||||
@@ -1,45 +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 { privateConfig } from "#private/lib/config";
|
||||
import { acmeClientManager } from "./acme-client";
|
||||
import { jobScheduler } from "./scheduler";
|
||||
|
||||
export async function startCertificateManager() {
|
||||
const acmeConfig = privateConfig.getRawPrivateConfig().acme;
|
||||
if (
|
||||
acmeConfig &&
|
||||
acmeConfig.cert_mode === "pangolin" &&
|
||||
acmeConfig.enable_acme_client
|
||||
) {
|
||||
logger.info("Starting certificate management server...");
|
||||
|
||||
// Initialize ACME client
|
||||
await acmeClientManager.initialize();
|
||||
|
||||
// Start certificate issuance/renewal jobs
|
||||
await jobScheduler.start();
|
||||
}
|
||||
|
||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
// DNS record validation/reverification doesn't require certs, so it
|
||||
// runs whenever Pangolin is acting as the authoritative DNS server,
|
||||
// independent of the cert manager above.
|
||||
await jobScheduler.startDnsJobs();
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopCertificateManager() {
|
||||
await jobScheduler.stop();
|
||||
}
|
||||
@@ -1,190 +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 { withTimeout } from "@server/lib/retry";
|
||||
import logger from "@server/logger";
|
||||
import { certificateService } from "./certificate-service";
|
||||
import { privateConfig as config } from "#private/lib/config";
|
||||
import { dnsValidator } from "./dns-validator";
|
||||
import { domainReverifier } from "./domain-reverifier";
|
||||
|
||||
// Backstop for runExclusive: no single job's own internal timeouts (e.g.
|
||||
// certificate-service's per-cert issuance timeout) are relied on here. This
|
||||
// is the last line of defense - if *anything* inside a job hangs with no
|
||||
// error (a stalled Redis/DB call, a future code path that forgets to bound
|
||||
// itself, etc.), state.active must still reset so the next tick can run.
|
||||
// Without it, one hung run permanently skips every future tick for that job,
|
||||
// since runExclusive only clears state.active after the job promise settles.
|
||||
const RUN_EXCLUSIVE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export class JobScheduler {
|
||||
private certIntervals: NodeJS.Timeout[] = [];
|
||||
private dnsIntervals: NodeJS.Timeout[] = [];
|
||||
private certRunning = false;
|
||||
private dnsRunning = false;
|
||||
|
||||
// Guards against a slow batch (e.g. 10 certs whose DNS challenges take a
|
||||
// while) still being processed when the next interval tick fires -
|
||||
// without this, overlapping ticks would each pull their own batch of up
|
||||
// to 10 pending/renewal certs and process them concurrently instead of
|
||||
// waiting for the prior batch to finish.
|
||||
private runExclusive(
|
||||
job: () => Promise<void>,
|
||||
state: { active: boolean },
|
||||
label: string
|
||||
): () => Promise<void> {
|
||||
return async () => {
|
||||
if (state.active) {
|
||||
logger.debug(
|
||||
`Skipping ${label} tick - previous run still in progress`
|
||||
);
|
||||
return;
|
||||
}
|
||||
state.active = true;
|
||||
try {
|
||||
await withTimeout(job(), RUN_EXCLUSIVE_TIMEOUT_MS, label);
|
||||
} catch (error) {
|
||||
logger.error(`Error in ${label}:`, error);
|
||||
} finally {
|
||||
state.active = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Certificate issuance/renewal - requires an ACME client, so this is
|
||||
// only started when Pangolin is actually managing certs.
|
||||
async start(): Promise<void> {
|
||||
if (this.certRunning) {
|
||||
logger.warn("Certificate job scheduler is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.certRunning = true;
|
||||
logger.info("Starting certificate job scheduler");
|
||||
|
||||
const newCertState = { active: false };
|
||||
const renewalState = { active: false };
|
||||
|
||||
const runNewCertCheck = this.runExclusive(
|
||||
() => certificateService.processPendingCertificates(),
|
||||
newCertState,
|
||||
"processing pending certificates"
|
||||
);
|
||||
const runRenewalCheck = this.runExclusive(
|
||||
() => certificateService.processRenewalCandidates(),
|
||||
renewalState,
|
||||
"processing renewal candidates"
|
||||
);
|
||||
|
||||
// Schedule new certificate processing
|
||||
const newCertInterval = setInterval(
|
||||
runNewCertCheck,
|
||||
config.getRawConfig().acme!.new_cert_check_interval_ms
|
||||
);
|
||||
|
||||
// Schedule renewal processing (every 24 hours)
|
||||
const renewalInterval = setInterval(
|
||||
runRenewalCheck,
|
||||
config.getRawConfig().acme!.renewal_check_interval_ms
|
||||
);
|
||||
|
||||
this.certIntervals.push(newCertInterval, renewalInterval);
|
||||
|
||||
// Run initial checks
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await runNewCertCheck();
|
||||
// await runRenewalCheck();
|
||||
} catch (error) {
|
||||
logger.error("Error in initial certificate processing:", error);
|
||||
}
|
||||
}, 1000); // Wait 1 second after startup
|
||||
|
||||
logger.info("Certificate job scheduler started successfully");
|
||||
}
|
||||
|
||||
// DNS record validation/reverification - doesn't touch certs at all, so
|
||||
// this runs independently whenever Pangolin is acting as the
|
||||
// authoritative DNS server, regardless of cert_mode.
|
||||
async startDnsJobs(): Promise<void> {
|
||||
if (this.dnsRunning) {
|
||||
logger.warn("DNS validation job scheduler is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.dnsRunning = true;
|
||||
logger.info("Starting DNS validation job scheduler");
|
||||
|
||||
const dnsValidationState = { active: false };
|
||||
const reverifyState = { active: false };
|
||||
|
||||
const runDnsValidation = this.runExclusive(
|
||||
() => dnsValidator.validateAll(),
|
||||
dnsValidationState,
|
||||
"validating DNS records"
|
||||
);
|
||||
const runReverify = this.runExclusive(
|
||||
() => domainReverifier.reverifyAll(),
|
||||
reverifyState,
|
||||
"reverifying domains"
|
||||
);
|
||||
|
||||
// Schedule DNS validation
|
||||
const dnsValidationInterval = setInterval(
|
||||
runDnsValidation,
|
||||
config.getRawConfig().acme?.dns_check_interval_ms ?? 60000
|
||||
);
|
||||
|
||||
// Schedule periodic reverification of already-verified domains
|
||||
const reverifyInterval = setInterval(
|
||||
runReverify,
|
||||
config.getRawConfig().acme?.domain_reverification_interval_ms ??
|
||||
3600000
|
||||
);
|
||||
|
||||
this.dnsIntervals.push(dnsValidationInterval, reverifyInterval);
|
||||
|
||||
// Run an initial validation pass shortly after startup
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await runDnsValidation();
|
||||
} catch (error) {
|
||||
logger.error("Error in initial DNS validation:", error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
logger.info("DNS validation job scheduler started successfully");
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.certRunning) {
|
||||
logger.info("Stopping certificate job scheduler");
|
||||
this.certRunning = false;
|
||||
this.certIntervals.forEach((interval) => clearInterval(interval));
|
||||
this.certIntervals = [];
|
||||
}
|
||||
|
||||
if (this.dnsRunning) {
|
||||
logger.info("Stopping DNS validation job scheduler");
|
||||
this.dnsRunning = false;
|
||||
this.dnsIntervals.forEach((interval) => clearInterval(interval));
|
||||
this.dnsIntervals = [];
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.certRunning || this.dnsRunning;
|
||||
}
|
||||
}
|
||||
|
||||
export const jobScheduler = new JobScheduler();
|
||||
@@ -146,20 +146,12 @@ export class PrivateConfig {
|
||||
process.env.USE_PANGOLIN_DNS =
|
||||
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
||||
}
|
||||
|
||||
if (this.rawPrivateConfig.acme?.cert_mode) {
|
||||
process.env.CERT_MODE = this.rawPrivateConfig.acme.cert_mode;
|
||||
}
|
||||
}
|
||||
|
||||
public getRawPrivateConfig() {
|
||||
return this.rawPrivateConfig;
|
||||
}
|
||||
|
||||
public getRawConfig() {
|
||||
return this.getRawPrivateConfig();
|
||||
}
|
||||
|
||||
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
||||
// and `acme` used to live in the private config file. They now live in
|
||||
// the public config file. If an operator still has them set in the
|
||||
|
||||
@@ -1,14 +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.
|
||||
*/
|
||||
|
||||
export * from "./server";
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import { privateConfigFilePath1 } from "@server/lib/consts";
|
||||
import { z } from "zod";
|
||||
import { colorsSchema } from "@server/lib/colorsSchema";
|
||||
@@ -95,70 +95,6 @@ export const privateConfigSchema = z
|
||||
.optional()
|
||||
})
|
||||
.optional(),
|
||||
dns: z
|
||||
.object({
|
||||
enabled: z.boolean().optional().default(false),
|
||||
listen_port: z.number().int().positive().optional().default(53),
|
||||
nameserver_name: z.string(),
|
||||
cname_extension: z.string(),
|
||||
site_extension: z.string().optional(),
|
||||
cname_alternate_extensions: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([]),
|
||||
alternate_nameservers: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([]),
|
||||
rate_limit: z
|
||||
.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
window_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1000)
|
||||
.max(600000)
|
||||
.optional()
|
||||
.default(60000),
|
||||
max_requests: z
|
||||
.number()
|
||||
.int()
|
||||
.min(50)
|
||||
.max(100000)
|
||||
.optional()
|
||||
.default(1200),
|
||||
max_requests_per_query_type: z
|
||||
.number()
|
||||
.int()
|
||||
.min(10)
|
||||
.max(50000)
|
||||
.optional()
|
||||
.default(600)
|
||||
})
|
||||
.default({
|
||||
enabled: true,
|
||||
window_ms: 60000,
|
||||
max_requests: 1200,
|
||||
max_requests_per_query_type: 600
|
||||
}),
|
||||
static_records: z
|
||||
.array(
|
||||
z.object({
|
||||
domain: z.string(),
|
||||
type: z.enum(["TXT", "CNAME", "A", "NS"]),
|
||||
value: z.string(),
|
||||
ttl: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(300)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([])
|
||||
})
|
||||
.optional(),
|
||||
gerbil: z
|
||||
.object({
|
||||
local_exit_node_reachable_at: z
|
||||
@@ -189,86 +125,15 @@ export const privateConfigSchema = z
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
// @deprecated Moved to the public config file as `acme`
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||
// config files keep parsing; any value set here is migrated into the
|
||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||
acme: z
|
||||
.object({
|
||||
cert_mode: z
|
||||
.enum(["traefik", "pangolin"])
|
||||
.optional()
|
||||
.default("traefik"),
|
||||
enable_acme_client: z.boolean().optional().default(false),
|
||||
// @deprecated Moved to the public config file
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||
// config files keep parsing; any value set here is migrated into the
|
||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||
acme_json_path: z.string().optional(),
|
||||
// @deprecated Moved to the public config file
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||
// config files keep parsing; any value set here is migrated into the
|
||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||
acme_http_endpoint: z.string().optional(),
|
||||
// @deprecated Moved to the public config file
|
||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||
// config files keep parsing; any value set here is migrated into the
|
||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||
sync_interval_ms: z.number().optional(),
|
||||
acme_directory_url: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://acme-v02.api.letsencrypt.org/directory"),
|
||||
contact_email: z.string().email(),
|
||||
acme_account_key_path: z
|
||||
.string()
|
||||
.default("./config/account.key"),
|
||||
challenge_ttl_ms: z.number().int().positive().default(300000),
|
||||
renewal_check_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(3600000),
|
||||
new_cert_check_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(60000),
|
||||
// Kept safely under Let's Encrypt's ~20 req/s limit since this
|
||||
// budget is shared across all pops workers and only covers the
|
||||
// request-issuing calls we make directly (not every request
|
||||
// acme-client makes internally, e.g. while polling for
|
||||
// challenge/order status).
|
||||
acme_requests_per_second: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(15),
|
||||
dns_check_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(60000),
|
||||
domain_reverification_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(3600000), // 1 hour — how often to run the reverification pass
|
||||
domain_reverification_window_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(259200000), // 72 hours — how old checkedAt must be before rechecking
|
||||
domain_reverification_batch_size: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(20), // max domains to recheck per pass
|
||||
dns_resolvers: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"9.9.9.9",
|
||||
"208.67.222.222"
|
||||
])
|
||||
sync_interval_ms: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
branding: z
|
||||
|
||||
@@ -158,7 +158,7 @@ class RedisManager {
|
||||
this.writeClient = new Redis({
|
||||
...masterConfig,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 50,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -169,7 +169,7 @@ class RedisManager {
|
||||
this.readClient = new Redis({
|
||||
...replicaConfig!,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 50,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -186,7 +186,7 @@ class RedisManager {
|
||||
this.publisher = new Redis({
|
||||
...masterConfig,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 50,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -196,7 +196,7 @@ class RedisManager {
|
||||
this.subscriber = new Redis({
|
||||
...(this.hasReplicas ? replicaConfig! : masterConfig),
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 50,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -901,9 +901,7 @@ class RegionalRedisManager {
|
||||
// if the configured host doesn't match that pattern (e.g. local dev),
|
||||
// in which case callers should fall back to the primary for reads.
|
||||
private getReplicaHost(primaryHost: string): string | null {
|
||||
const match = primaryHost.match(
|
||||
/^redis\.([^.]+)\.svc\.cluster\.local$/
|
||||
);
|
||||
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
|
||||
if (!match) return null;
|
||||
const namespace = match[1];
|
||||
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
|
||||
@@ -914,7 +912,7 @@ class RegionalRedisManager {
|
||||
const baseOpts = {
|
||||
...cfg,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 50,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 10000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
|
||||
@@ -396,7 +396,7 @@ export async function getTraefikConfig(
|
||||
);
|
||||
|
||||
let validCerts: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
// create a list of all domains to get certs for
|
||||
const domains = new Set<string>();
|
||||
for (const resource of resourcesMap.values()) {
|
||||
@@ -522,10 +522,7 @@ export async function getTraefikConfig(
|
||||
);
|
||||
|
||||
let tls = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
tls = buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!resource.subdomain,
|
||||
@@ -792,8 +789,7 @@ export async function getTraefikConfig(
|
||||
preferWildcardCert
|
||||
}) => {
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
return buildWildcardTls({
|
||||
fullDomain,
|
||||
@@ -836,8 +832,7 @@ export async function getTraefikConfig(
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (fullDomain) => {
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
// siteResource aliases don't have a per-domain cert
|
||||
// resolver stored, so always fall back to the global
|
||||
@@ -929,10 +924,7 @@ export async function getTraefikConfig(
|
||||
const rule = buildHostRule(fullDomain, ir.wildcard);
|
||||
|
||||
let tls: any = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
tls = buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!ir.subdomain,
|
||||
@@ -1013,8 +1005,7 @@ export async function getTraefikConfig(
|
||||
|
||||
let tls: any = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
// siteResource aliases don't have a per-domain cert
|
||||
// resolver stored, so always fall back to the global
|
||||
@@ -1089,7 +1080,7 @@ export async function getTraefikConfig(
|
||||
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
||||
|
||||
let validCertsLoginPages: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
// create a list of all domains to get certs for
|
||||
const domains = new Set<string>();
|
||||
for (const lp of exitNodeLoginPages) {
|
||||
@@ -1135,8 +1126,7 @@ export async function getTraefikConfig(
|
||||
|
||||
const tls = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
// TODO: we need to add the wildcard logic here too
|
||||
} else {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { processTestAlerts } from "#private/lib/alerts/processTestAlerts";
|
||||
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
|
||||
import { type AlertAction } from "@server/routers/alertRule/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
|
||||
@@ -33,11 +33,8 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
HC_EVENT_TYPES,
|
||||
SITE_EVENT_TYPES,
|
||||
RESOURCE_EVENT_TYPES
|
||||
} from "./createAlertRule";
|
||||
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
|
||||
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -88,57 +85,35 @@ const bodySchema = z
|
||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isResourceEvent = (
|
||||
RESOURCE_EVENT_TYPES as readonly string[]
|
||||
).includes(val.eventType);
|
||||
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
|
||||
if (
|
||||
isSiteEvent &&
|
||||
val.siteIds !== undefined &&
|
||||
val.siteIds.length === 0 &&
|
||||
!val.allSites
|
||||
) {
|
||||
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"At least one siteId is required for site event types when allSites is false",
|
||||
message: "At least one siteId is required for site event types when allSites is false",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isHcEvent &&
|
||||
val.healthCheckIds !== undefined &&
|
||||
val.healthCheckIds.length === 0 &&
|
||||
!val.allHealthChecks
|
||||
) {
|
||||
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isResourceEvent &&
|
||||
val.resourceIds !== undefined &&
|
||||
val.resourceIds.length === 0 &&
|
||||
!val.allResources
|
||||
) {
|
||||
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"At least one resourceId is required for resource event types when allResources is false",
|
||||
message: "At least one resourceId is required for resource event types when allResources is false",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isSiteEvent &&
|
||||
val.healthCheckIds !== undefined &&
|
||||
val.healthCheckIds.length > 0
|
||||
) {
|
||||
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for site event types",
|
||||
@@ -154,11 +129,7 @@ const bodySchema = z
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isResourceEvent &&
|
||||
val.siteIds !== undefined &&
|
||||
val.siteIds.length > 0
|
||||
) {
|
||||
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for resource event types",
|
||||
@@ -166,15 +137,10 @@ const bodySchema = z
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isResourceEvent &&
|
||||
val.healthCheckIds !== undefined &&
|
||||
val.healthCheckIds.length > 0
|
||||
) {
|
||||
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"healthCheckIds must not be set for resource event types",
|
||||
message: "healthCheckIds must not be set for resource event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
@@ -187,6 +153,7 @@ const UpdateAlertRuleResponseDataSchema = z.object({
|
||||
alertRuleId: z.number()
|
||||
});
|
||||
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
@@ -207,9 +174,7 @@ registry.registerPath({
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
UpdateAlertRuleResponseDataSchema
|
||||
)
|
||||
schema: createApiResponseSchema(UpdateAlertRuleResponseDataSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,11 +250,9 @@ export async function updateAlertRule(
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (eventType !== undefined) updateData.eventType = eventType;
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
if (cooldownSeconds !== undefined)
|
||||
updateData.cooldownSeconds = cooldownSeconds;
|
||||
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
|
||||
if (allSites !== undefined) updateData.allSites = allSites;
|
||||
if (allHealthChecks !== undefined)
|
||||
updateData.allHealthChecks = allHealthChecks;
|
||||
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
|
||||
if (allResources !== undefined) updateData.allResources = allResources;
|
||||
|
||||
await db
|
||||
@@ -310,11 +273,7 @@ export async function updateAlertRule(
|
||||
|
||||
// Only insert junction rows when allSites is not true
|
||||
const effectiveAllSites = allSites ?? false;
|
||||
if (
|
||||
!effectiveAllSites &&
|
||||
siteIds !== undefined &&
|
||||
siteIds.length > 0
|
||||
) {
|
||||
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
|
||||
await db.insert(alertSites).values(
|
||||
siteIds.map((siteId) => ({
|
||||
alertRuleId,
|
||||
@@ -331,11 +290,7 @@ export async function updateAlertRule(
|
||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||
|
||||
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
||||
if (
|
||||
!effectiveAllHealthChecks &&
|
||||
healthCheckIds !== undefined &&
|
||||
healthCheckIds.length > 0
|
||||
) {
|
||||
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
|
||||
await db.insert(alertHealthChecks).values(
|
||||
healthCheckIds.map((healthCheckId) => ({
|
||||
alertRuleId,
|
||||
@@ -352,11 +307,7 @@ export async function updateAlertRule(
|
||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||
|
||||
const effectiveAllResources = allResources ?? false;
|
||||
if (
|
||||
!effectiveAllResources &&
|
||||
resourceIds !== undefined &&
|
||||
resourceIds.length > 0
|
||||
) {
|
||||
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
|
||||
await db.insert(alertResources).values(
|
||||
resourceIds.map((resourceId) => ({
|
||||
alertRuleId,
|
||||
@@ -441,10 +392,7 @@ export async function updateAlertRule(
|
||||
webhookActions.map((wa) => ({
|
||||
alertRuleId,
|
||||
webhookUrl: wa.webhookUrl,
|
||||
config:
|
||||
wa.config != null
|
||||
? encrypt(wa.config, serverSecret)
|
||||
: null,
|
||||
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
||||
enabled: wa.enabled
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function clearInstanceName(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = clearInstanceNameParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -65,8 +63,7 @@ export async function clearInstanceName(
|
||||
return next(
|
||||
createHttpError(
|
||||
data.status || HttpCode.BAD_REQUEST,
|
||||
data.message ||
|
||||
"Failed to clear server ID from Fossorial API"
|
||||
data.message || "Failed to clear instance name from Fossorial API"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -75,7 +72,7 @@ export async function clearInstanceName(
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Server ID cleared successfully",
|
||||
message: "Instance name cleared successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -83,8 +80,8 @@ export async function clearInstanceName(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while clearing the server ID."
|
||||
"An error occurred while clearing the instance name."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2478,12 +2478,7 @@ hybridRouter.post(
|
||||
destinations: destinations
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof Error &&
|
||||
error.message === "Exit node not allowed"
|
||||
)) {
|
||||
logger.error(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -1,23 +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 { EventEmitter } from "events";
|
||||
|
||||
export interface ExitNodeOnlineEvent {
|
||||
exitNodeId: number;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export const EXIT_NODE_ONLINE_EVENT = "exit-node-online";
|
||||
|
||||
export const exitNodeEvents = new EventEmitter();
|
||||
@@ -12,27 +12,11 @@
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import redisManager from "#private/lib/redis";
|
||||
import { sendToClient } from "../ws";
|
||||
import {
|
||||
exitNodeEvents,
|
||||
EXIT_NODE_ONLINE_EVENT,
|
||||
ExitNodeOnlineEvent
|
||||
} from "./exitNodeEvents";
|
||||
|
||||
exitNodeEvents.on(
|
||||
EXIT_NODE_ONLINE_EVENT,
|
||||
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
|
||||
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", {
|
||||
error
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
// import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
@@ -42,7 +26,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||
|
||||
interface PendingReconnect {
|
||||
startTime: number;
|
||||
endpoint: string;
|
||||
reachableAt: string;
|
||||
}
|
||||
|
||||
// In-memory tracking for this node
|
||||
@@ -56,15 +40,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
*/
|
||||
export async function scheduleExitNodeReconnect(
|
||||
exitNodeId: number,
|
||||
endpoint: string
|
||||
reachableAt: string
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||
);
|
||||
|
||||
const entry: PendingReconnect = {
|
||||
startTime: Date.now(),
|
||||
endpoint
|
||||
reachableAt
|
||||
};
|
||||
|
||||
pendingReconnects.set(exitNodeId, entry);
|
||||
@@ -79,8 +63,8 @@ export async function scheduleExitNodeReconnect(
|
||||
);
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"endpoint",
|
||||
endpoint
|
||||
"reachableAt",
|
||||
reachableAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,14 +101,14 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"startTime"
|
||||
);
|
||||
const endpoint = await redisManager.hget(
|
||||
const reachableAt = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"endpoint"
|
||||
"reachableAt"
|
||||
);
|
||||
if (startTimeStr && endpoint) {
|
||||
if (startTimeStr && reachableAt) {
|
||||
toProcess.set(id, {
|
||||
startTime: parseInt(startTimeStr, 10),
|
||||
endpoint
|
||||
reachableAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -151,7 +135,7 @@ async function processPendingReconnects(): Promise<void> {
|
||||
}
|
||||
|
||||
// Check if the exit node HTTP endpoint is reachable
|
||||
const pingUrl = `http://${entry.endpoint}/ping`;
|
||||
const pingUrl = `${entry.reachableAt}/ping`;
|
||||
try {
|
||||
await axios.get(pingUrl, { timeout: 5000 });
|
||||
} catch {
|
||||
@@ -166,47 +150,47 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||
);
|
||||
|
||||
await sendReconnectToNewts(exitNodeId);
|
||||
// await sendReconnectToNewts(exitNodeId);
|
||||
await removePending(exitNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
try {
|
||||
const connectedNewts = await db
|
||||
.select({ newtId: newts.newtId })
|
||||
.from(newts)
|
||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
.where(eq(sites.exitNodeId, exitNodeId));
|
||||
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
// try {
|
||||
// const connectedNewts = await db
|
||||
// .select({ newtId: newts.newtId })
|
||||
// .from(newts)
|
||||
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
// .where(eq(sites.exitNodeId, exitNodeId));
|
||||
|
||||
if (connectedNewts.length === 0) {
|
||||
logger.debug(
|
||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (connectedNewts.length === 0) {
|
||||
// logger.debug(
|
||||
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
logger.info(
|
||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
);
|
||||
// logger.info(
|
||||
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
// );
|
||||
|
||||
const reconnectMessage = {
|
||||
type: "newt/wg/reconnect",
|
||||
data: {}
|
||||
};
|
||||
// const reconnectMessage = {
|
||||
// type: "newt/wg/reconnect",
|
||||
// data: {}
|
||||
// };
|
||||
|
||||
await Promise.allSettled(
|
||||
connectedNewts.map(({ newtId }) =>
|
||||
sendToClient(newtId, reconnectMessage)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
// await Promise.allSettled(
|
||||
// connectedNewts.map(({ newtId }) =>
|
||||
// sendToClient(newtId, reconnectMessage)
|
||||
// )
|
||||
// );
|
||||
// } catch (error) {
|
||||
// logger.error(
|
||||
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
// { error }
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
async function removePending(exitNodeId: number): Promise<void> {
|
||||
pendingReconnects.delete(exitNodeId);
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
validateRemoteExitNodeSessionToken,
|
||||
EXPIRES
|
||||
} from "#private/auth/sessions/remoteExitNode";
|
||||
import { getOrCreateCachedToken } from "#private/lib/tokenCache";
|
||||
import { getOrCreateCachedToken } from "@server/private/lib/tokenCache";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
||||
import { RemoteExitNode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
|
||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
try {
|
||||
// Fetch the current state before updating so we can detect the offline→online transition
|
||||
const [currentExitNode] = await db
|
||||
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
|
||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||
.limit(1);
|
||||
@@ -55,14 +55,12 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||
|
||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||
if (
|
||||
currentExitNode &&
|
||||
!currentExitNode.online &&
|
||||
currentExitNode.endpoint
|
||||
) {
|
||||
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
|
||||
exitNodeId: remoteExitNode.exitNodeId,
|
||||
endpoint: currentExitNode.endpoint
|
||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||
scheduleExitNodeReconnect(
|
||||
remoteExitNode.exitNodeId,
|
||||
currentExitNode.reachableAt
|
||||
).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", { error });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -104,8 +104,7 @@ const processMessage = async (
|
||||
|
||||
const handler = messageHandlers[message.type];
|
||||
if (!handler) {
|
||||
logger.debug(`No handler found for message type: ${message.type}`);
|
||||
return;
|
||||
throw new Error(`Unsupported message type: ${message.type}`);
|
||||
}
|
||||
|
||||
const response = await handler({
|
||||
|
||||
@@ -300,15 +300,12 @@ export async function createOrgDomain(
|
||||
{
|
||||
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||
baseDomain: baseDomain
|
||||
}
|
||||
];
|
||||
|
||||
if (build == "saas") {
|
||||
cnameRecords.push({
|
||||
},
|
||||
{
|
||||
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||
baseDomain: `_acme-challenge.${baseDomain}`
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Save CNAME records to database
|
||||
for (const cnameRecord of cnameRecords) {
|
||||
|
||||
@@ -1378,6 +1378,12 @@ if (build !== "saas") {
|
||||
user.adminGeneratePasswordResetCode
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/server-admin",
|
||||
verifyUserIsServerAdmin,
|
||||
user.adminSetServerAdmin
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/user/:userId",
|
||||
verifyUserIsServerAdmin,
|
||||
|
||||
@@ -984,7 +984,7 @@ async function listLabelGroups(
|
||||
).length;
|
||||
}
|
||||
|
||||
const groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
|
||||
let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
|
||||
(row) => ({
|
||||
groupKey: String(row.labelId),
|
||||
name: row.name,
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/*
|
||||
* 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 { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
);
|
||||
|
||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||
const siteResourcesForExitNode = [];
|
||||
let siteResourcesForExitNode = [];
|
||||
for (const row of allClientSiteResources) {
|
||||
if (row.siteResource.requiresExitNodeConnection) {
|
||||
siteResourcesForExitNode.push(row.siteResource);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import config from "@server/lib/config";
|
||||
|
||||
// Mirrors the optional fields on the olm client's TunnelConfig - any field
|
||||
// present here overrides the value the olm client is otherwise locally
|
||||
// configured with; an absent field leaves the client's own config alone.
|
||||
export type OlmDnsConfig = {
|
||||
upstreamDns?: string[];
|
||||
overrideDns?: boolean;
|
||||
tunnelDns?: boolean;
|
||||
matchDomains?: string[];
|
||||
};
|
||||
|
||||
export function buildOlmDnsConfig(): OlmDnsConfig | undefined {
|
||||
return {
|
||||
upstreamDns: undefined,
|
||||
overrideDns: undefined,
|
||||
tunnelDns: undefined,
|
||||
matchDomains: undefined
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||
import { buildOlmDnsConfig } from "./dnsConfig";
|
||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
import { build } from "@server/build";
|
||||
@@ -513,7 +512,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it
|
||||
}
|
||||
: undefined,
|
||||
dnsConfig: buildOlmDnsConfig(),
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
|
||||
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
if (resources.length > 1) {
|
||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!resource.networkId) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||
|
||||
if (!siteRows || siteRows.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -164,7 +164,9 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +193,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -203,7 +205,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -227,7 +229,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!handshakeInitiated) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -196,7 +196,7 @@ export async function adminListUsers(
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(users.serverAdmin, false)];
|
||||
const conditions: Array<SQL<unknown> | undefined> = [];
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
|
||||
const setServerAdminParamsSchema = z.strictObject({
|
||||
userId: z.string()
|
||||
});
|
||||
|
||||
const setServerAdminBodySchema = z.strictObject({
|
||||
serverAdmin: z.boolean()
|
||||
});
|
||||
|
||||
export type AdminSetServerAdminResponse = {
|
||||
userId: string;
|
||||
serverAdmin: boolean;
|
||||
};
|
||||
|
||||
const AdminSetServerAdminResponseDataSchema = z.object({
|
||||
userId: z.string(),
|
||||
serverAdmin: z.boolean()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/user/{userId}/server-admin",
|
||||
description: "Promote or demote a user's server admin status (server admin).",
|
||||
tags: [OpenAPITags.User],
|
||||
request: {
|
||||
params: setServerAdminParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setServerAdminBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
AdminSetServerAdminResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminSetServerAdmin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setServerAdminParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setServerAdminBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userId } = parsedParams.data;
|
||||
const { serverAdmin } = parsedBody.data;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select({
|
||||
userId: users.userId,
|
||||
serverAdmin: users.serverAdmin
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (!serverAdmin && req.user?.userId === userId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"You cannot remove your own server admin status"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser.serverAdmin !== serverAdmin) {
|
||||
logger.info(
|
||||
`${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ serverAdmin })
|
||||
.where(eq(users.userId, userId));
|
||||
}
|
||||
|
||||
return response<AdminSetServerAdminResponse>(res, {
|
||||
data: {
|
||||
userId: existingUser.userId,
|
||||
serverAdmin
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: serverAdmin
|
||||
? "User promoted to server admin successfully"
|
||||
: "User demoted from server admin successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export * from "./adminListUsers";
|
||||
export * from "./adminRemoveUser";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./adminGeneratePasswordResetCode";
|
||||
export * from "./adminSetServerAdmin";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
|
||||
@@ -353,10 +353,7 @@ const setupConnection = async (
|
||||
|
||||
const handler = messageHandlers[message.type];
|
||||
if (!handler) {
|
||||
logger.debug(
|
||||
`No handler found for message type: ${message.type}`
|
||||
);
|
||||
return;
|
||||
throw new Error(`Unsupported message type: ${message.type}`);
|
||||
}
|
||||
|
||||
const response = await handler({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateCA } from "@server/lib/sshCA";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const version = "1.16.0";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import path, { join } from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const version = "1.6.0";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.10...");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "../../db/sqlite";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.12...");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from "../../db/sqlite";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { domains, orgDomains, resources } from "@server/db";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.2...");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.3...");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.6...");
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generateCA } from "@server/lib/sshCA";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import * as yaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const version = "1.16.0";
|
||||
|
||||
|
||||