From 3564674bedabf9b6b96c10bb89790cc8ee77cc63 Mon Sep 17 00:00:00 2001 From: Tim <82370418+Tim5965@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:29:31 +0200 Subject: [PATCH 01/13] Update nl-NL.json Also did them on crowdin, but think there is some difference between the files --- messages/nl-NL.json | 84 ++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index f914a3c80..a7dd85a91 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -13,7 +13,7 @@ "orgDisplayName": "Dit is de weergavenaam van de organisatie.", "orgId": "Organisatie ID", "setupIdentifierMessage": "Dit is de unieke identificatie voor de organisatie.", - "setupErrorIdentifier": "Organisatie-ID is al in gebruik. Kies een andere.", + "setupErrorIdentifier": "Organisatie ID is al in gebruik, verzin iets anders.", "componentsErrorNoMemberCreate": "U bent momenteel geen lid van een organisatie. Maak een organisatie aan om aan de slag te gaan.", "componentsErrorNoMember": "U bent momenteel geen lid van een organisatie.", "welcome": "Welkom bij Pangolin!", @@ -78,7 +78,7 @@ "siteManageSites": "Sites beheren", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "sitesBannerTitle": "Verbind elk netwerk", - "sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt tot bronnen, zowel openbaar als privé, aan gebruikers overal. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.", + "sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt voor gebruikers voor bronnen, zowel openbaar als privé. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.", "sitesBannerButtonText": "Site installeren", "approvalsBannerTitle": "Toegang tot het apparaat goedkeuren of weigeren", "approvalsBannerDescription": "Bekijk en keur toestelverzoeken goed of weiger toegang van gebruikers. Wanneer apparaatgoedkeuringen vereist zijn, moeten gebruikers de goedkeuring van beheerders krijgen voordat hun apparaten verbinding kunnen maken met de bronnen van uw organisatie.", @@ -219,12 +219,12 @@ "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", - "publicResourcesBannerTitle": "Web-gebaseerde Openbare Toegang", - "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot privébronnen hoeven ze geen client-software te hebben en kunnen ze identiteit- en context bewuste toegangsmiddelen bevatten.", + "publicResourcesBannerTitle": "Webgebaseerde openbare bronnen", + "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS proxy's die te bereiken zijn voor iedereen op het internet via een webbrowser. Het is mogelijk om deze te beveiligen door identiteitscontrole of een toegangsbeleid. In tegenstelling tot privébronnen hoeven ze geen clientsoftware te installeren.", "clientResourceTitle": "Privébronnen beheren", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", - "privateResourcesBannerTitle": "Zero-Trust Private Access", - "privateResourcesBannerDescription": "Privé bronnen maken gebruik van zero-trust-beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot middelen die jij specifiek toestaat. Verbind gebruikersapparaten of machineclients om deze middelen te benaderen via een veilig virtueel priv��netwerk.", + "privateResourcesBannerTitle": "Privébronnen via Zero Trust", + "privateResourcesBannerDescription": "Privébronnen maken gebruik van zero trust beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot resources die je specifiek toestaat. Verbind gebruikersapparaten of machines om deze middelen te benaderen via een veilig, virtueel privénetwerk.", "resourcesSearch": "Zoek bronnen...", "resourceAdd": "Bron toevoegen", "resourceErrorDelte": "Fout bij verwijderen document", @@ -253,7 +253,7 @@ "resourcePolicyUsersRolesDescription": "Bepaal welke gebruikers en rollen geassocieerde bronnen kunnen bezoeken", "rulesResourcePolicyDescription": "Stel regels in om toegang te regelen tot bronnen die zijn gekoppeld aan dit beleid", "authentication": "Authenticatie", - "protected": "Beschermd", + "protected": "Beveiligd", "notProtected": "Niet beveiligd", "resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.", "resourceQuestionRemove": "Weet u zeker dat u het document van de organisatie wilt verwijderen?", @@ -324,7 +324,7 @@ "labelEdit": "Label bewerken", "editLabelDialogTitle": "Label bijwerken", "editLabelDialogDescription": "Bewerk een nieuw label dat aan deze organisatie kan worden gekoppeld", - "labelDeleteConfirm": "Bevestigen Verwijderen Label", + "labelDeleteConfirm": "Label verwijderen", "labelErrorDelete": "Kan label niet verwijderen", "labelMessageRemove": "Deze handeling is definitief. Alle sites, bronnen en klanten met dit label zullen worden onttakeld.", "labelQuestionRemove": "Weet u zeker dat u het label uit de organisatie wilt verwijderen?", @@ -409,40 +409,40 @@ "years": "Jaar", "day": "{count, plural, one {# dag} other {# dagen}}", "apiKeysTitle": "API Key Informatie", - "apiKeysConfirmCopy2": "Bevestig dat u de API-sleutel hebt gekopieerd.", - "apiKeysErrorCreate": "Fout bij maken API-sleutel", + "apiKeysConfirmCopy2": "Bevestig dat u de API sleutel hebt gekopieerd.", + "apiKeysErrorCreate": "Fout bij maken API sleutel", "apiKeysErrorSetPermission": "Fout instellen permissies", - "apiKeysCreate": "API-sleutel genereren", - "apiKeysCreateDescription": "Een nieuwe API-sleutel voor de organisatie genereren", + "apiKeysCreate": "API sleutel genereren", + "apiKeysCreateDescription": "Een nieuwe API sleutel voor de organisatie genereren", "apiKeysGeneralSettings": "Machtigingen", - "apiKeysGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", - "apiKeysList": "Nieuwe API-sleutel", - "apiKeysSave": "De API-sleutel opslaan", + "apiKeysGeneralSettingsDescription": "Bepaal wat deze API sleutel kan doen", + "apiKeysList": "Nieuwe API sleutel", + "apiKeysSave": "De API sleutel opslaan", "apiKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plek.", - "apiKeysInfo": "De API-sleutel is:", - "apiKeysConfirmCopy": "Ik heb de API-sleutel gekopieerd", + "apiKeysInfo": "De API sleutel is:", + "apiKeysConfirmCopy": "Ik heb de API sleutel gekopieerd", "generate": "Genereren", "done": "Voltooid", - "apiKeysSeeAll": "Alle API-sleutels bekijken", + "apiKeysSeeAll": "Alle API sleutels bekijken", "apiKeysPermissionsErrorLoadingActions": "Fout bij het laden van API key acties", "apiKeysPermissionsErrorUpdate": "Fout instellen permissies", "apiKeysPermissionsUpdated": "Permissies bijgewerkt", "apiKeysPermissionsUpdatedDescription": "De bevoegdheden zijn bijgewerkt.", "apiKeysPermissionsGeneralSettings": "Machtigingen", - "apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", + "apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API sleutel kan doen", "apiKeysPermissionsSave": "Rechten opslaan", "apiKeysPermissionsTitle": "Machtigingen", "apiKeys": "API sleutels", - "searchApiKeys": "API-sleutels zoeken...", - "apiKeysAdd": "API-sleutel genereren", - "apiKeysErrorDelete": "Fout bij verwijderen API-sleutel", - "apiKeysErrorDeleteMessage": "Fout bij verwijderen API-sleutel", - "apiKeysQuestionRemove": "Weet u zeker dat u de API-sleutel van de organisatie wilt verwijderen?", - "apiKeysMessageRemove": "Eenmaal verwijderd, kan de API-sleutel niet meer worden gebruikt.", - "apiKeysDeleteConfirm": "Bevestig Verwijderen API-sleutel", - "apiKeysDelete": "API-sleutel verwijderen", - "apiKeysManage": "API-sleutels beheren", - "apiKeysDescription": "API-sleutels worden gebruikt om te verifiëren met de integratie-API", + "searchApiKeys": "API sleutels zoeken...", + "apiKeysAdd": "API sleutel genereren", + "apiKeysErrorDelete": "Fout bij verwijderen API sleutel", + "apiKeysErrorDeleteMessage": "Fout bij verwijderen API- leutel", + "apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?", + "apiKeysMessageRemove": "Eenmaal verwijderd, kan de APIsleutel niet meer worden gebruikt.", + "apiKeysDeleteConfirm": "Bevestig verwijderen API sleutel", + "apiKeysDelete": "API sleutel verwijderen", + "apiKeysManage": "API sleutels beheren", + "apiKeysDescription": "API sleutels worden gebruikt om te verifiëren met de integratie-API", "provisioningKeysTitle": "Vertrekkende sleutel", "provisioningKeysManage": "Beheren van Provisioning Sleutels", "provisioningKeysDescription": "Provisionerende sleutels worden gebruikt om geautomatiseerde sitebepaling voor uw organisatie te verifiëren.", @@ -906,7 +906,7 @@ "resourcesUsersRolesAccess": "Gebruiker en rol-gebaseerde toegangsbeheer", "resourcesErrorUpdate": "Bron wisselen mislukt", "resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", - "access": "Toegangsrechten", + "access": "Link", "accessControl": "Toegangs controle", "shareLink": "{resource} Deelbare Link", "resourceSelect": "Selecteer resource", @@ -1480,11 +1480,11 @@ "actionCheckOrgId": "ID controleren", "actionCreateOrg": "Nieuwe organisatie aanmaken", "actionDeleteOrg": "Verwijder organisatie", - "actionListApiKeys": "API-sleutels weergeven", + "actionListApiKeys": "API sleutels weergeven", "actionListApiKeyActions": "Lijst van API Key Acties", "actionSetApiKeyActions": "Stel API Key Toegestane Acties", - "actionCreateApiKey": "API-sleutel aanmaken", - "actionDeleteApiKey": "API-sleutel verwijderen", + "actionCreateApiKey": "API sleutel aanmaken", + "actionDeleteApiKey": "API sleutel verwijderen", "actionCreateIdp": "IDP aanmaken", "actionUpdateIdp": "IDP bijwerken", "actionDeleteIdp": "Verwijder IDP", @@ -1550,7 +1550,7 @@ "commandPaletteCreateProxyResource": "Maak Openbare Bron", "commandPaletteCreatePrivateResource": "Maak Privébron", "commandPaletteCreateUser": "Gebruiker aanmaken", - "commandPaletteCreateApiKey": "API-sleutel aanmaken", + "commandPaletteCreateApiKey": "API sleutel aanmaken", "commandPaletteCreateMachineClient": "Machineclient aanmaken", "commandPaletteCreateAlertRule": "Alarmregel aanmaken", "commandPaletteCreateIdentityProvider": "Identiteitsprovider aanmaken", @@ -1598,10 +1598,10 @@ "authErrorNoUpdate": "Geen authenticatie informatie om bij te werken", "orgErrorNoUpdate": "Geen org om bij te werken", "orgErrorNoProvided": "Geen org opgegeven", - "apiKeysErrorNoUpdate": "Geen API-sleutel om bij te werken", + "apiKeysErrorNoUpdate": "Geen API sleutel om bij te werken", "sidebarOverview": "Overzicht.", "sidebarHome": "Startpagina", - "sidebarSites": "Werkruimtes", + "sidebarSites": "Sites", "sidebarApprovals": "Goedkeuringsverzoeken", "sidebarResources": "Bronnen", "sidebarProxyResources": "Openbaar", @@ -1671,8 +1671,8 @@ "commandBilling": "Facturering", "commandEnterpriseLicenses": "Licentie", "commandSettings": "Instellingen", - "commandLauncher": "Startprogramma", - "commandResourceLauncher": "Bron Startprogramma", + "commandLauncher": "Startpagina", + "commandResourceLauncher": "Bron startpagina", "commandSearchResults": "Zoekresultaten", "alertingTitle": "Waarschuwingen", "alertingDescription": "Definieer bronnen, triggers en acties voor meldingen", @@ -1830,7 +1830,7 @@ "standaloneHcFilterSiteIdFallback": "Site {id}", "standaloneHcFilterResourceIdFallback": "Bron {id}", "blueprints": "Blauwdrukken", - "blueprintsLog": "Log Blueprints", + "blueprintsLog": "Log blauwafdrukken", "blueprintsDescription": "Bekijk eerdere blauwdruktoepassingen en hun resultaten of pas een nieuwe blauwdruk toe", "blueprintAdd": "Blauwdruk toevoegen", "blueprintGoBack": "Bekijk alle Blauwdrukken", @@ -3311,7 +3311,7 @@ "internalResourceAuthDaemonPort": "Daemon poort (optioneel)", "orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?", "learnMore": "Meer informatie", - "backToHome": "Ga terug naar startpagina", + "backToHome": "Ga terug naar de startpagina", "needToSignInToOrg": "Moet u de identiteit provider van uw organisatie gebruiken?", "maintenanceMode": "Onderhoudspagina", "maintenanceModeDescription": "Toon een onderhoudspagina aan bezoekers", @@ -3524,7 +3524,7 @@ "httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.", "httpDestAuthBearerTitle": "Betere Token", "httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '' header toe aan elk verzoek.", - "httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token", + "httpDestAuthBearerPlaceholder": "Uw API sleutel of token", "httpDestAuthBasicTitle": "Basis authenticatie", "httpDestAuthBasicDescription": "Voegt een Authorization: Basic '' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.", "httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord", @@ -3678,7 +3678,7 @@ "memberPortalResourceDisabled": "Bron Uitgeschakeld", "memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen", "resourceLauncherTitle": "Bron Launcher", - "resourceSidebarLauncherTitle": "Startprogramma", + "resourceSidebarLauncherTitle": "Startpagina", "resourceLauncherDescription": "Bekijk alle beschikbare bronnen en start ze vanuit één centraal punt", "resourceLauncherSearchPlaceholder": "Zoek naar bronnen...", "resourceLauncherDefaultView": "Standaard", From c11fc86b63b8710a31bd222cbe45e6dfc4d45387 Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Thu, 30 Jul 2026 15:34:00 +0530 Subject: [PATCH 02/13] Fix integration site resource lookup --- .../siteResource/getSiteResource.test.ts | 58 +++++++++++++++++++ .../routers/siteResource/getSiteResource.ts | 24 ++++---- 2 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 server/routers/siteResource/getSiteResource.test.ts diff --git a/server/routers/siteResource/getSiteResource.test.ts b/server/routers/siteResource/getSiteResource.test.ts new file mode 100644 index 000000000..4e63742da --- /dev/null +++ b/server/routers/siteResource/getSiteResource.test.ts @@ -0,0 +1,58 @@ +import { assertEquals } from "@test/assert"; +import { getSiteResourceParamsSchema } from "./getSiteResource"; + +function testSiteResourceIdOnlyParams() { + const result = getSiteResourceParamsSchema.safeParse({ + siteResourceId: "42" + }); + + assertEquals( + result.success, + true, + "siteResourceId-only integration routes should pass validation" + ); + + if (result.success) { + assertEquals( + result.data.siteResourceId, + 42, + "siteResourceId should be parsed as a number" + ); + assertEquals( + result.data.orgId, + undefined, + "orgId should remain optional" + ); + } +} + +function testOrgScopedParamsRemainSupported() { + const result = getSiteResourceParamsSchema.safeParse({ + siteResourceId: "42", + orgId: "org-id" + }); + + assertEquals( + result.success, + true, + "org-scoped routes should continue to pass validation" + ); +} + +function testInvalidSiteResourceId() { + const result = getSiteResourceParamsSchema.safeParse({ + siteResourceId: "not-a-number" + }); + + assertEquals( + result.success, + false, + "non-numeric siteResourceIds should fail validation" + ); +} + +testSiteResourceIdOnlyParams(); +testOrgScopedParamsRemainSupported(); +testInvalidSiteResourceId(); + +console.log("All getSiteResource parameter validation tests passed."); diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index 47a89d1b1..efdac5452 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -const getSiteResourceParamsSchema = z.strictObject({ +export const getSiteResourceParamsSchema = z.strictObject({ siteResourceId: z .string() .optional() @@ -18,19 +18,21 @@ const getSiteResourceParamsSchema = z.strictObject({ .pipe(z.int().positive().optional()) .optional(), niceId: z.string().optional(), - orgId: z.string() + orgId: z.string().optional() }); async function query(siteResourceId?: number, niceId?: string, orgId?: string) { - if (siteResourceId && orgId) { + if (siteResourceId) { const [siteResource] = await db .select() .from(siteResources) .where( - and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.orgId, orgId) - ) + orgId + ? and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.orgId, orgId) + ) + : eq(siteResources.siteResourceId, siteResourceId) ) .limit(1); return siteResource; @@ -60,9 +62,7 @@ registry.registerPath({ tags: [OpenAPITags.PrivateResourceLegacy], request: { params: z.object({ - siteResourceId: z.number(), - siteId: z.number(), - orgId: z.string() + siteResourceId: z.number() }) }, responses: { @@ -90,9 +90,7 @@ registry.registerPath({ tags: [OpenAPITags.PrivateResource], request: { params: z.object({ - siteResourceId: z.number(), - siteId: z.number(), - orgId: z.string() + siteResourceId: z.number() }) }, responses: { From 8ec9fb3cb9a1938fef22bc96fe5ba8bcfd3b4120 Mon Sep 17 00:00:00 2001 From: yaqoob Date: Sat, 15 Aug 2026 03:49:08 +0530 Subject: [PATCH 03/13] Normalize CLI command import paths in pangctl entrypoint --- cli/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/index.ts b/cli/index.ts index cfa65625c..688416ab6 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -2,8 +2,8 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; -import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; +import { setAdminCredentials } from "./commands/setAdminCredentials"; +import { resetUserSecurityKeys } from "./commands/resetUserSecurityKeys"; import { clearExitNodes } from "./commands/clearExitNodes"; import { rotateServerSecret } from "./commands/rotateServerSecret"; import { clearLicenseKeys } from "./commands/clearLicenseKeys"; From e9e8457d06ed3e9324f2ebb0ac2ba6db0635917e Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 21:29:44 +0930 Subject: [PATCH 04/13] Update updateResource.ts --- server/routers/resource/updateResource.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index f3e0c3909..f8573df11 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -498,7 +498,8 @@ async function updateHttpResource( } // catch when the resource policy changes or gets cleared - if (resource.resourcePolicyId != updateData.resourcePolicyId) { + if (updateData.resourcePolicyId !== undefined && + resource.resourcePolicyId !== updateData.resourcePolicyId) { await clearResourceSpecificSettings( resource.resourceId, resource.orgId, From 4bf5efdab56177d209624ed78c6580bb80c32a8d Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 21:34:35 +0930 Subject: [PATCH 05/13] Update exportConnectionAuditLog.ts --- server/private/routers/auditLogs/exportConnectionAuditLog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/auditLogs/exportConnectionAuditLog.ts b/server/private/routers/auditLogs/exportConnectionAuditLog.ts index a20d4052a..810ae1630 100644 --- a/server/private/routers/auditLogs/exportConnectionAuditLog.ts +++ b/server/private/routers/auditLogs/exportConnectionAuditLog.ts @@ -97,7 +97,7 @@ export async function exportConnectionAuditLogs( const baseQuery = queryConnection(data); - const log = await baseQuery.limit(data.limit).offset(data.offset); + const log = await baseQuery.limit(MAX_EXPORT_LIMIT); const csvData = generateCSV(log); From 5cd98e2790b25415517eaef4e4aea09f64a493b6 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 21:37:00 +0930 Subject: [PATCH 06/13] Update updateSite.ts --- server/routers/site/updateSite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 42ab8b246..bea6eabbb 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -113,7 +113,7 @@ export async function updateSite( .where( and( eq(sites.niceId, updateData.niceId), - eq(sites.orgId, sites.orgId), + eq(sites.orgId, existingSite.orgId), ne(sites.siteId, siteId) ) ) From 2a6bd21029c9f4ab77bc7028b3b237f5ea5d0c9c Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 22:10:07 +0930 Subject: [PATCH 07/13] Update verifyApiKeyAccess.ts --- server/middlewares/verifyApiKeyAccess.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index ea1bdac18..47dac03f0 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -70,13 +70,8 @@ export async function verifyApiKeyAccess( ); } - if (!apiKeyOrg.orgId) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `API key with ID ${apiKeyId} does not have an organization ID` - ) - ); + if (!apiKey.apiKeyOrg?.orgId) { + return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `API key with ID ${apiKeyId} does not have an organization ID`)); } if (!req.userOrg) { @@ -86,7 +81,7 @@ export async function verifyApiKeyAccess( .where( and( eq(userOrgs.userId, userId), - eq(userOrgs.orgId, apiKeyOrg.orgId) + eq(userOrgs.orgId, apiKey.apiKeyOrg.orgId) ) ) .limit(1); From 64aa7292e6190f6c954a43a96a14a0b61428bd05 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 22:10:57 +0930 Subject: [PATCH 08/13] Update peers.ts --- server/routers/newt/peers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index 6c38671f3..bd11f03d0 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -31,7 +31,7 @@ export async function addPeer( .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { - throw new Error(`Site found for site ${siteId}`); + throw new Error(`Newt not found for site ${siteId}`); } newtId = newt.newtId; } From 46538eb606574137f135a98e57bce83efef057fe Mon Sep 17 00:00:00 2001 From: m1handr <71174649+m1handr@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:29:27 +0300 Subject: [PATCH 09/13] fix(site): include org subnet prefix when auto-allocating site address (#3523) --- server/routers/site/createSite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 574751e93..84f651338 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -263,7 +263,7 @@ export async function createSite( const { value: newClientAddress, release } = await getNextAvailableClientSubnet(orgId); releaseSubnetLock = release; - updatedAddress = newClientAddress.split("/")[0]; + updatedAddress = `${newClientAddress.split("/")[0]}/${org.subnet ? org.subnet.split("/")[1] : "32"}`; } let newSite: Site | undefined; From 64ae230d236df67282e507e41e61ef99932068ee Mon Sep 17 00:00:00 2001 From: breken Date: Sun, 13 Sep 2026 19:00:59 -0700 Subject: [PATCH 10/13] fix(rules): decode percent-encoded PATH rule patterns before matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPathAllowed decodes the incoming request path (and Badger already sends Go's decoded req.URL.Path), but compared it against the rule pattern as raw text. isValidUrlGlobPattern rejects raw spaces and non-ASCII and only accepts them percent-encoded, so a PATH rule such as `/my%20docs/*` or `/caf%C3%A9` was stored as `my%20docs` / `caf%C3%A9` and compared against `my docs` / `café`, and could therefore never match any request. Run the pattern through the same decodeAndResolvePath normalisation as the request path so both sides are compared in decoded form. Claude-Session: https://claude.ai/code/session_0134ujLF81GyXsCByibLcYsz --- server/lib/pathMatch.ts | 7 ++++- server/routers/badger/verifySession.test.ts | 33 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts index 8ef579607..2c3444b24 100644 --- a/server/lib/pathMatch.ts +++ b/server/lib/pathMatch.ts @@ -20,6 +20,11 @@ function getSegmentRegex(patternPart: string): RegExp { // resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` // or `/public/../admin/` is matched as `/admin/`, not as a literal segment // or a wildcard-swallowed sequence under `/public/*`. +// +// Applied to both the request path and the rule pattern: the pattern +// validator only accepts spaces / non-ASCII in percent-encoded form, so a +// rule like `/my%20docs/*` must be compared against the decoded segment +// `my docs`, not the literal text `my%20docs`. function decodeAndResolvePath(p: string): string[] { const rawParts = p.split("/").filter(Boolean); @@ -48,7 +53,7 @@ function decodeAndResolvePath(p: string): string[] { } export function isPathAllowed(pattern: string, path: string): boolean { - const patternParts = pattern.split("/").filter(Boolean); + const patternParts = decodeAndResolvePath(pattern); const pathParts = decodeAndResolvePath(path); function matchSegments( diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index c91805f8e..6254de025 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -386,6 +386,38 @@ function runSpecialCharacterTests() { console.log("All special character tests passed!"); } +function runEncodedPatternTests() { + console.log("\nRunning percent-encoded pattern tests..."); + + // isValidUrlGlobPattern accepts percent-encoded sequences and rejects + // raw spaces / non-ASCII, so `%20` and `%C3%A9` are the only way to write + // a PATH rule for such a path. Badger sends the request path already + // decoded (Go's req.URL.Path), and isPathAllowed decodes it again, so the + // rule pattern must be decoded the same way or it can never match. + assertEquals( + isPathAllowed("/my%20docs/*", "/my docs/report.pdf"), + true, + "Percent-encoded space in pattern should match decoded request path" + ); + assertEquals( + isPathAllowed("/my%20docs/*", "/my%20docs/report.pdf"), + true, + "Percent-encoded space in pattern should match raw-encoded request path" + ); + assertEquals( + isPathAllowed("/caf%C3%A9", "/café"), + true, + "Percent-encoded UTF-8 in pattern should match decoded request path" + ); + assertEquals( + isPathAllowed("/my%20docs/*", "/my-docs/report.pdf"), + false, + "Decoded pattern must still reject a different path" + ); + + console.log("All percent-encoded pattern tests passed!"); +} + function runRegionTests() { console.log("\nRunning isIpInRegion tests..."); @@ -446,6 +478,7 @@ function runRegionTests() { try { runTests(); runSpecialCharacterTests(); + runEncodedPatternTests(); runRegionTests(); console.log("\n✅ All tests passed!"); } catch (error) { From 803fbb2ea20d8ddf1739649e03d8ea4d9a836aab Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Thu, 17 Sep 2026 22:45:42 +0530 Subject: [PATCH 11/13] fix(sqlite): take a single database backup per upgrade instead of one per migration --- server/setup/migrationsSqlite.backup.test.ts | 151 +++++++++++++++++++ server/setup/migrationsSqlite.ts | 13 +- 2 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 server/setup/migrationsSqlite.backup.test.ts diff --git a/server/setup/migrationsSqlite.backup.test.ts b/server/setup/migrationsSqlite.backup.test.ts new file mode 100644 index 000000000..e450155ff --- /dev/null +++ b/server/setup/migrationsSqlite.backup.test.ts @@ -0,0 +1,151 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import Database from "better-sqlite3"; +import { assertEquals } from "@test/assert"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "..", ".."); +const migrationsScript = path.join(here, "migrationsSqlite.ts"); + +const SEED_STATEMENTS = [ + `CREATE TABLE versionMigrations (version TEXT PRIMARY KEY, executedAt INTEGER NOT NULL)`, + `INSERT INTO versionMigrations (version, executedAt) VALUES ('1.21.0', 1750000000000)`, + `CREATE TABLE sites (siteId INTEGER PRIMARY KEY AUTOINCREMENT, subnet TEXT)`, + `INSERT INTO sites (subnet) VALUES ('10.0.0.0/24')`, + `CREATE TABLE roles (roleId INTEGER PRIMARY KEY AUTOINCREMENT, orgId TEXT, isAdmin INTEGER DEFAULT 0, sshSudoMode TEXT DEFAULT 'none')`, + `INSERT INTO roles (orgId, isAdmin, sshSudoMode) VALUES ('org1', 0, 'none')`, + `CREATE TABLE licenseKey (licenseKeyId INTEGER PRIMARY KEY AUTOINCREMENT)`, + `CREATE TABLE targets (targetId INTEGER PRIMARY KEY AUTOINCREMENT, resourceId INTEGER, siteId INTEGER NOT NULL, ip TEXT NOT NULL, method TEXT, port INTEGER NOT NULL, internalPort INTEGER, enabled INTEGER DEFAULT 1, path TEXT, pathMatchType TEXT, rewritePath TEXT, rewritePathType TEXT, priority INTEGER DEFAULT 100, mode TEXT DEFAULT 'http', authToken TEXT)`, + `CREATE TABLE subscriptions (subscriptionId INTEGER PRIMARY KEY AUTOINCREMENT)`, + `CREATE TABLE clients (clientId INTEGER PRIMARY KEY AUTOINCREMENT)`, + `CREATE TABLE orgs (orgId TEXT PRIMARY KEY)`, + `INSERT INTO orgs (orgId) VALUES ('org1')`, + `CREATE TABLE siteResources (siteResourceId INTEGER PRIMARY KEY AUTOINCREMENT)`, + `CREATE TABLE eventStreamingDestinations (destinationId INTEGER PRIMARY KEY AUTOINCREMENT)`, + `CREATE TABLE roleActions (roleId INTEGER, actionId TEXT, orgId TEXT)`, + `CREATE TABLE newt (newtId INTEGER PRIMARY KEY AUTOINCREMENT)` +]; + +function seedDatabase(dbPath: string) { + const db = new Database(dbPath); + try { + for (const statement of SEED_STATEMENTS) { + db.exec(statement); + } + } finally { + db.close(); + } +} + +function siteColumns(dbPath: string): string[] { + const db = new Database(dbPath, { readonly: true }); + try { + return ( + db.prepare(`PRAGMA table_info(sites)`).all() as Array<{ + name: unknown; + }> + ).map((row) => String(row.name)); + } finally { + db.close(); + } +} + +function runMigrations(workdir: string): { + exitCode: number; + output: string; +} { + const tsconfig = ["tsconfig.json", "tsconfig.oss.json"] + .map((file) => path.join(repoRoot, file)) + .find((file) => fs.existsSync(file)); + if (!tsconfig) { + throw new Error("No tsconfig found for @server path aliases"); + } + const tsxCli = path.join( + repoRoot, + "node_modules", + "tsx", + "dist", + "cli.mjs" + ); + if (!fs.existsSync(tsxCli)) { + throw new Error("tsx is not installed; run npm ci first"); + } + try { + const output = execFileSync( + process.execPath, + [tsxCli, "--tsconfig", tsconfig, migrationsScript], + { cwd: workdir, timeout: 120000, encoding: "utf8" } + ); + return { exitCode: 0, output }; + } catch (error) { + const output = + error instanceof Error + ? (error as Error & { stdout?: unknown }).stdout + : ""; + return { exitCode: 1, output: String(output ?? "") }; + } +} + +function testSingleBackupPerUpgrade() { + console.log("Running single backup per upgrade test..."); + for (const generated of ["server/build.ts", "server/db/index.ts"]) { + if (!fs.existsSync(path.join(repoRoot, generated))) { + throw new Error( + `Missing ${generated}; run npm run set:oss && npm run set:sqlite first` + ); + } + } + const workdir = fs.mkdtempSync( + path.join(os.tmpdir(), "pangolin-backup-test-") + ); + try { + fs.mkdirSync(path.join(workdir, "config", "db"), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, "config", "config.example.yml"), + path.join(workdir, "config", "config.yml") + ); + fs.symlinkSync( + path.join(repoRoot, "server"), + path.join(workdir, "server"), + process.platform === "win32" ? "junction" : "dir" + ); + seedDatabase(path.join(workdir, "config", "db", "db.sqlite")); + const result = runMigrations(workdir); + assertEquals(result.exitCode, 0, "Seeded migrations must run cleanly"); + if (!result.output.includes("All migrations completed successfully")) { + throw new Error( + "Seeded migrations did not complete; the backup assertions below would be vacuous" + ); + } + const backupsDir = path.join(workdir, "config", "db", "backups"); + const backups = fs.existsSync(backupsDir) + ? fs + .readdirSync(backupsDir) + .filter((file) => file.endsWith(".sqlite")) + : []; + assertEquals( + backups.length, + 1, + "One upgrade must produce exactly one database backup even with several pending migrations" + ); + const columns = siteColumns(path.join(backupsDir, backups[0])); + if (!columns.includes("subnet")) { + throw new Error( + `The single backup must be the pre-upgrade snapshot (sites.subnet), got sites(${columns.join(",")})` + ); + } + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +} + +try { + testSingleBackupPerUpgrade(); + console.log("All tests passed successfully!"); +} catch (error) { + console.error("Test failed:", error); + process.exit(1); +} diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 6645d617f..07b38e52b 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -191,15 +191,18 @@ async function executeScripts() { ); // Run migrations in order + // Take a single backup before any migration runs, so one upgrade + // produces one restore point instead of one backup per migration. + if ( + migrationsToRun.length > 0 && + !process.env.DISABLE_BACKUP_ON_MIGRATION + ) { + backupDb(); + } for (const migration of migrationsToRun) { console.log(`Running migration ${migration.version}`); try { - if (!process.env.DISABLE_BACKUP_ON_MIGRATION) { - // Backup the database before running the migration - backupDb(); - } - await migration.run(); // Update version in database From b74ded3a9c823fc9aa33264be595a6ea7e7f37af Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Fri, 18 Sep 2026 21:31:00 +0530 Subject: [PATCH 12/13] fix(sqlite): tag per-migration database backups with version and prevent collisions --- server/lib/backupFileName.test.ts | 47 +++- server/lib/backupFileName.ts | 23 ++ server/setup/migrationsSqlite.backup.test.ts | 243 +++++++++++++++++-- server/setup/migrationsSqlite.ts | 30 +-- server/setup/scriptsSqlite/1.0.0-beta9.ts | 20 -- 5 files changed, 298 insertions(+), 65 deletions(-) diff --git a/server/lib/backupFileName.test.ts b/server/lib/backupFileName.test.ts index 01fc4796a..fd8a047eb 100644 --- a/server/lib/backupFileName.test.ts +++ b/server/lib/backupFileName.test.ts @@ -1,4 +1,4 @@ -import { formatBackupTimestamp } from "./backupFileName"; +import { formatBackupFileName, formatBackupTimestamp } from "./backupFileName"; import { assertEquals } from "@test/assert"; // Local-time constructors are used throughout, matching formatBackupTimestamp, @@ -29,7 +29,9 @@ function testMonthIsOneIndexed() { } { - const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59)); + const result = formatBackupTimestamp( + new Date(2026, 11, 31, 23, 59, 59) + ); assertEquals( result, "2026-12-31_23-59-59", @@ -73,9 +75,7 @@ function testNamesSortChronologically() { new Date(2026, 11, 31, 23, 59, 59) ]; - const sorted = taken - .map((date) => formatBackupTimestamp(date)) - .sort(); + const sorted = taken.map((date) => formatBackupTimestamp(date)).sort(); assertEquals( sorted.join(","), @@ -89,11 +89,48 @@ function testNamesSortChronologically() { ); } +function testFormatBackupFileName() { + console.log("Running backup file name formatting tests..."); + + const date = new Date(2026, 8, 12, 20, 35, 56); + + // With semver version string without leading 'v' + assertEquals( + formatBackupFileName("1.22.0", date), + "db_2026-09-12_20-35-56_v1.22.0.sqlite", + "Filename must include timestamp and prefixed version tag" + ); + + // With version string already containing 'v' + assertEquals( + formatBackupFileName("v1.22.0", date), + "db_2026-09-12_20-35-56_v1.22.0.sqlite", + "Filename must not duplicate 'v' prefix if already present" + ); + + // Without version (fallback/default) + assertEquals( + formatBackupFileName(undefined, date), + "db_2026-09-12_20-35-56.sqlite", + "Filename without version must match default timestamped format" + ); + + // Distinct versions within the exact same second do not collide + const sameSecondFile1 = formatBackupFileName("1.21.0", date); + const sameSecondFile2 = formatBackupFileName("1.22.0", date); + if (sameSecondFile1 === sameSecondFile2) { + throw new Error( + "Backup file names for different versions in the same second must not collide" + ); + } +} + // Run all tests try { testMonthIsOneIndexed(); testEveryFieldIsZeroPadded(); testNamesSortChronologically(); + testFormatBackupFileName(); console.log("All tests passed successfully!"); } catch (error) { console.error("Test failed:", error); diff --git a/server/lib/backupFileName.ts b/server/lib/backupFileName.ts index a5888450a..9d2c1bd62 100644 --- a/server/lib/backupFileName.ts +++ b/server/lib/backupFileName.ts @@ -26,3 +26,26 @@ export function formatBackupTimestamp(date: Date = new Date()): string { return `${datePart}_${timePart}`; } + +/** + * Builds the full database backup file name, including timestamp and optional version tag. + * + * When a migration version is provided, the filename includes `_v`, + * preventing collisions between multiple migrations running in the same second and making it easy + * to identify the migration state contained in the backup. + * + * @param version Optional migration version being run. + * @param date The moment the backup is being taken. Defaults to now. + * @returns A filename of the form `db_YYYY-MM-DD_HH-MM-SS_v.sqlite` or `db_YYYY-MM-DD_HH-MM-SS.sqlite`. + */ +export function formatBackupFileName( + version?: string, + date: Date = new Date() +): string { + const timestamp = formatBackupTimestamp(date); + if (version) { + const versionTag = version.startsWith("v") ? version : `v${version}`; + return `db_${timestamp}_${versionTag}.sqlite`; + } + return `db_${timestamp}.sqlite`; +} diff --git a/server/setup/migrationsSqlite.backup.test.ts b/server/setup/migrationsSqlite.backup.test.ts index e450155ff..616cf303d 100644 --- a/server/setup/migrationsSqlite.backup.test.ts +++ b/server/setup/migrationsSqlite.backup.test.ts @@ -40,11 +40,11 @@ function seedDatabase(dbPath: string) { } } -function siteColumns(dbPath: string): string[] { +function tableColumns(dbPath: string, tableName: string): string[] { const db = new Database(dbPath, { readonly: true }); try { return ( - db.prepare(`PRAGMA table_info(sites)`).all() as Array<{ + db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: unknown; }> ).map((row) => String(row.name)); @@ -53,7 +53,23 @@ function siteColumns(dbPath: string): string[] { } } -function runMigrations(workdir: string): { +function executedMigrationVersions(dbPath: string): string[] { + const db = new Database(dbPath, { readonly: true }); + try { + return ( + db.prepare(`SELECT version FROM versionMigrations`).all() as Array<{ + version: unknown; + }> + ).map((row) => String(row.version)); + } finally { + db.close(); + } +} + +function runMigrations( + workdir: string, + env: Record = {} +): { exitCode: number; output: string; } { @@ -77,7 +93,12 @@ function runMigrations(workdir: string): { const output = execFileSync( process.execPath, [tsxCli, "--tsconfig", tsconfig, migrationsScript], - { cwd: workdir, timeout: 120000, encoding: "utf8" } + { + cwd: workdir, + timeout: 120000, + encoding: "utf8", + env: { ...process.env, NODE_ENV: "test", ...env } + } ); return { exitCode: 0, output }; } catch (error) { @@ -89,8 +110,7 @@ function runMigrations(workdir: string): { } } -function testSingleBackupPerUpgrade() { - console.log("Running single backup per upgrade test..."); +function createTestEnvironment(): string { for (const generated of ["server/build.ts", "server/db/index.ts"]) { if (!fs.existsSync(path.join(repoRoot, generated))) { throw new Error( @@ -101,17 +121,29 @@ function testSingleBackupPerUpgrade() { const workdir = fs.mkdtempSync( path.join(os.tmpdir(), "pangolin-backup-test-") ); + fs.mkdirSync(path.join(workdir, "config", "db"), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, "config", "config.example.yml"), + path.join(workdir, "config", "config.yml") + ); + const traefikSrc = path.join(repoRoot, "config", "traefik"); + if (fs.existsSync(traefikSrc)) { + fs.cpSync(traefikSrc, path.join(workdir, "config", "traefik"), { + recursive: true + }); + } + fs.symlinkSync( + path.join(repoRoot, "server"), + path.join(workdir, "server"), + process.platform === "win32" ? "junction" : "dir" + ); + return workdir; +} + +function testMultipleSequentialMigrations() { + console.log("Running multiple sequential migrations test..."); + const workdir = createTestEnvironment(); try { - fs.mkdirSync(path.join(workdir, "config", "db"), { recursive: true }); - fs.copyFileSync( - path.join(repoRoot, "config", "config.example.yml"), - path.join(workdir, "config", "config.yml") - ); - fs.symlinkSync( - path.join(repoRoot, "server"), - path.join(workdir, "server"), - process.platform === "win32" ? "junction" : "dir" - ); seedDatabase(path.join(workdir, "config", "db", "db.sqlite")); const result = runMigrations(workdir); assertEquals(result.exitCode, 0, "Seeded migrations must run cleanly"); @@ -120,6 +152,169 @@ function testSingleBackupPerUpgrade() { "Seeded migrations did not complete; the backup assertions below would be vacuous" ); } + + const backupsDir = path.join(workdir, "config", "db", "backups"); + const backups = fs.existsSync(backupsDir) + ? fs + .readdirSync(backupsDir) + .filter((file) => file.endsWith(".sqlite")) + : []; + + // Upgrading from 1.21.0 runs 1.22.0 and 1.23.0 -> produces 2 distinct backups + assertEquals( + backups.length, + 2, + "Each migration must have its own distinct backup snapshot" + ); + + const v122Backup = backups.find((file) => + file.includes("_v1.22.0.sqlite") + ); + const v123Backup = backups.find((file) => + file.includes("_v1.23.0.sqlite") + ); + + if (!v122Backup || !v123Backup) { + throw new Error( + `Expected backups for v1.22.0 and v1.23.0, found: ${backups.join(", ")}` + ); + } + + // Verify pre-1.22.0 snapshot state: sites has 'subnet' (not exitNodeSubnet), versions = [1.21.0] + const v122Columns = tableColumns( + path.join(backupsDir, v122Backup), + "sites" + ); + assertEquals( + v122Columns.includes("subnet") && + !v122Columns.includes("exitNodeSubnet"), + true, + "Backup before 1.22.0 must retain pre-1.22.0 schema (sites.subnet)" + ); + const v122Versions = executedMigrationVersions( + path.join(backupsDir, v122Backup) + ); + assertEquals( + v122Versions.includes("1.21.0") && !v122Versions.includes("1.22.0"), + true, + "Backup before 1.22.0 must only record version 1.21.0" + ); + + // Verify pre-1.23.0 snapshot state: sites has 'exitNodeSubnet' (1.22.0 applied), newt has no agent + const v123Columns = tableColumns( + path.join(backupsDir, v123Backup), + "sites" + ); + assertEquals( + v123Columns.includes("exitNodeSubnet"), + true, + "Backup before 1.23.0 must contain successfully applied 1.22.0 schema (sites.exitNodeSubnet)" + ); + const v123NewtCols = tableColumns( + path.join(backupsDir, v123Backup), + "newt" + ); + assertEquals( + !v123NewtCols.includes("agent"), + true, + "Backup before 1.23.0 must not contain 1.23.0 schema changes yet" + ); + const v123Versions = executedMigrationVersions( + path.join(backupsDir, v123Backup) + ); + assertEquals( + v123Versions.includes("1.21.0") && v123Versions.includes("1.22.0"), + true, + "Backup before 1.23.0 must record both 1.21.0 and 1.22.0" + ); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +} + +function testFailureInLaterMigrationPreservesRestorePoints() { + console.log("Running failure in later migration test..."); + const workdir = createTestEnvironment(); + try { + const dbPath = path.join(workdir, "config", "db", "db.sqlite"); + seedDatabase(dbPath); + + // Intentionally drop table 'newt' so migration 1.23.0 fails on ALTER TABLE newt ADD COLUMN agent + const db = new Database(dbPath); + db.exec("DROP TABLE newt;"); + db.close(); + + const result = runMigrations(workdir); + assertEquals( + result.exitCode, + 1, + "Migration suite must fail when 1.23.0 errors" + ); + + const backupsDir = path.join(workdir, "config", "db", "backups"); + const backups = fs.existsSync(backupsDir) + ? fs + .readdirSync(backupsDir) + .filter((file) => file.endsWith(".sqlite")) + : []; + + // Both pre-1.22.0 and pre-1.23.0 backups must exist + assertEquals( + backups.length, + 2, + "Backups for earlier successful migration and the failed migration must both exist" + ); + + const v122Backup = backups.find((file) => + file.includes("_v1.22.0.sqlite") + ); + const v123Backup = backups.find((file) => + file.includes("_v1.23.0.sqlite") + ); + + if (!v122Backup || !v123Backup) { + throw new Error( + `Expected restore points for v1.22.0 and v1.23.0, found: ${backups.join(", ")}` + ); + } + + // Verify pre-1.23.0 backup is a valid restore point with 1.22.0 changes applied + const v123SitesCols = tableColumns( + path.join(backupsDir, v123Backup), + "sites" + ); + assertEquals( + v123SitesCols.includes("exitNodeSubnet"), + true, + "Pre-failure restore point must have 1.22.0 changes intact" + ); + const v123Versions = executedMigrationVersions( + path.join(backupsDir, v123Backup) + ); + assertEquals( + v123Versions.includes("1.22.0"), + true, + "Pre-failure restore point must record successful 1.22.0 migration" + ); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +} + +function testDisableBackupOnMigration() { + console.log("Running DISABLE_BACKUP_ON_MIGRATION test..."); + const workdir = createTestEnvironment(); + try { + seedDatabase(path.join(workdir, "config", "db", "db.sqlite")); + const result = runMigrations(workdir, { + DISABLE_BACKUP_ON_MIGRATION: "1" + }); + assertEquals( + result.exitCode, + 0, + "Migrations must succeed with backups disabled" + ); + const backupsDir = path.join(workdir, "config", "db", "backups"); const backups = fs.existsSync(backupsDir) ? fs @@ -128,23 +323,19 @@ function testSingleBackupPerUpgrade() { : []; assertEquals( backups.length, - 1, - "One upgrade must produce exactly one database backup even with several pending migrations" + 0, + "No backup files should be created when DISABLE_BACKUP_ON_MIGRATION is set" ); - const columns = siteColumns(path.join(backupsDir, backups[0])); - if (!columns.includes("subnet")) { - throw new Error( - `The single backup must be the pre-upgrade snapshot (sites.subnet), got sites(${columns.join(",")})` - ); - } } finally { fs.rmSync(workdir, { recursive: true, force: true }); } } try { - testSingleBackupPerUpgrade(); - console.log("All tests passed successfully!"); + testMultipleSequentialMigrations(); + testFailureInLaterMigrationPreservesRestorePoints(); + testDisableBackupOnMigration(); + console.log("All backup migration regression tests passed successfully!"); } catch (error) { console.error("Test failed:", error); process.exit(1); diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 07b38e52b..eb2846610 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -5,7 +5,7 @@ import path from "path"; import semver from "semver"; import { versionMigrations } from "../db/sqlite"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; -import { formatBackupTimestamp } from "@server/lib/backupFileName"; +import { formatBackupFileName } from "@server/lib/backupFileName"; import { SqliteError } from "better-sqlite3"; import fs from "fs"; import { build } from "@server/build"; @@ -107,7 +107,7 @@ async function run() { await runMigrations(); } -function backupDb() { +function backupDb(version?: string) { // make dir config/db/backups const appPath = APP_PATH; const dbDir = path.join(appPath, "db"); @@ -120,11 +120,10 @@ function backupDb() { } // copy the db.sqlite file to backups - // add the date to the filename - const date = new Date(); - const dateString = formatBackupTimestamp(date); + // add the date and migration version to the filename + const fileName = formatBackupFileName(version); const dbPath = path.join(dbDir, "db.sqlite"); - const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); + const backupPath = path.join(backupsDir, fileName); fs.copyFileSync(dbPath, backupPath); } @@ -163,6 +162,12 @@ export async function runMigrations() { } } catch (e) { console.error("Error running migrations:", e); + if ( + process.env.NODE_ENV === "test" || + process.env.ENVIRONMENT === "test" + ) { + throw e; + } await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) ); @@ -191,18 +196,15 @@ async function executeScripts() { ); // Run migrations in order - // Take a single backup before any migration runs, so one upgrade - // produces one restore point instead of one backup per migration. - if ( - migrationsToRun.length > 0 && - !process.env.DISABLE_BACKUP_ON_MIGRATION - ) { - backupDb(); - } for (const migration of migrationsToRun) { console.log(`Running migration ${migration.version}`); try { + if (!process.env.DISABLE_BACKUP_ON_MIGRATION) { + // Backup the database before running the migration + backupDb(migration.version); + } + await migration.run(); // Update version in database diff --git a/server/setup/scriptsSqlite/1.0.0-beta9.ts b/server/setup/scriptsSqlite/1.0.0-beta9.ts index 6766c0756..2494556ce 100644 --- a/server/setup/scriptsSqlite/1.0.0-beta9.ts +++ b/server/setup/scriptsSqlite/1.0.0-beta9.ts @@ -10,7 +10,6 @@ import { users } from "../../db/sqlite"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; -import { formatBackupTimestamp } from "@server/lib/backupFileName"; import { eq, sql } from "drizzle-orm"; import fs from "fs"; import * as yaml from "js-yaml"; @@ -21,25 +20,6 @@ import { fromZodError } from "zod-validation-error"; export default async function migration() { console.log("Running setup script 1.0.0-beta.9..."); - // make dir config/db/backups - const appPath = APP_PATH; - const dbDir = path.join(appPath, "db"); - - const backupsDir = path.join(dbDir, "backups"); - - // check if the backups directory exists and create it if it doesn't - if (!fs.existsSync(backupsDir)) { - fs.mkdirSync(backupsDir, { recursive: true }); - } - - // copy the db.sqlite file to backups - // add the date to the filename - const date = new Date(); - const dateString = formatBackupTimestamp(date); - const dbPath = path.join(dbDir, "db.sqlite"); - const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); - fs.copyFileSync(dbPath, backupPath); - await db.transaction(async (trx) => { try { // Determine which config file exists From 610c228d56ea6a9d670262fcfa1e8b670e4e9c25 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Sep 2026 09:14:16 -0400 Subject: [PATCH 13/13] Use postgresql Fix #3794 --- config/ha-reference/node1/docker-compose.yml | 2 +- config/ha-reference/node2/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/ha-reference/node1/docker-compose.yml b/config/ha-reference/node1/docker-compose.yml index c8f80d603..a473ee8d4 100644 --- a/config/ha-reference/node1/docker-compose.yml +++ b/config/ha-reference/node1/docker-compose.yml @@ -1,7 +1,7 @@ name: pangolin services: pangolin: - image: docker.io/fosrl/pangolin:ee-latest + image: docker.io/fosrl/pangolin:ee-postgresql-latest container_name: pangolin restart: unless-stopped volumes: diff --git a/config/ha-reference/node2/docker-compose.yml b/config/ha-reference/node2/docker-compose.yml index 8a2e6d4d7..fa06f1404 100644 --- a/config/ha-reference/node2/docker-compose.yml +++ b/config/ha-reference/node2/docker-compose.yml @@ -1,7 +1,7 @@ name: pangolin services: pangolin: - image: docker.io/fosrl/pangolin:ee-latest + image: docker.io/fosrl/pangolin:ee-postgresql-latest container_name: pangolin restart: unless-stopped volumes: