diff --git a/Dockerfile b/Dockerfile index 327481e05..8466f054f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,12 +58,14 @@ COPY server/db/ios_models.json ./dist/ios_models.json COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public -# Copy MaxMind databases (empty directory for non-saas builds) -ARG BUILD=oss +RUN mkdir -p ./maxmind -COPY --from=builder-dev /app/maxmind ./maxmind +# Copy MaxMind databases (empty placeholders unless real files were in the build context) +COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb +COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb -RUN if [ "$BUILD" != "saas" ]; then rmdir ./maxmind; fi +# Remove empty placeholder MaxMind databases (and the directory if nothing real is left) +RUN find ./maxmind -empty -delete # OCI Image Labels - Build Args for dynamic values ARG VERSION="dev" diff --git a/Makefile b/Makefile index 6e555698a..4c8610993 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,12 @@ build-saas: --platform linux/arm64 \ --tag $(AWS_IMAGE):$(tag) \ --push . + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=pg \ + --platform linux/arm64 \ + --tag $(AWS_IMAGE):$(tag)-ee \ + --push . docker buildx build \ --build-arg BUILD=enterprise \ diff --git a/install/go.mod b/install/go.mod index cdaa154e1..8507d7b99 100644 --- a/install/go.mod +++ b/install/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 - golang.org/x/term v0.46.0 + golang.org/x/term v0.47.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -33,6 +33,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.48.0 // indirect + golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.23.0 // indirect ) diff --git a/install/go.sum b/install/go.sum index cd7266fa1..a7388a521 100644 --- a/install/go.sum +++ b/install/go.sum @@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/package-lock.json b/package-lock.json index 844dc8956..43950a6e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1830,9 +1830,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1848,13 +1848,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1870,20 +1870,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1893,9 +1893,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1909,9 +1909,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1925,9 +1925,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -1944,9 +1944,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -1963,9 +1963,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -1982,9 +1982,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -2001,9 +2001,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -2020,9 +2020,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -2039,9 +2039,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -2058,9 +2058,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -2077,9 +2077,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -2098,13 +2098,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -2123,13 +2123,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -2148,13 +2148,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -2173,13 +2173,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -2198,13 +2198,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -2223,13 +2223,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -2248,13 +2248,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -2273,13 +2273,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2303,16 +2303,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2322,9 +2322,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2341,9 +2341,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2360,9 +2360,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -16397,9 +16397,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -16414,31 +16414,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { diff --git a/server/lib/deleteOrg.childTest.ts b/server/lib/deleteOrg.childTest.ts new file mode 100644 index 000000000..15e0763e8 --- /dev/null +++ b/server/lib/deleteOrg.childTest.ts @@ -0,0 +1,139 @@ +// Test child for deleteOrg.test.ts. Boots the real sqlite driver against a +// fresh database in the temp workdir (cwd), seeds an org with two wireguard +// sites and two newt sites, runs deleteOrgById against a local HTTP stand-in +// for the exit node, and prints one JSON line of observations. +// The app boots with `import "./extendZod"` before anything else; zod +// schemas in the import chain use .openapi(), so do the same here. +import "@server/extendZod"; +import http from "http"; +import { deleteOrgById } from "@server/lib/deleteOrg"; +import { + db, + exitNodes, + orgs, + sites, + newts, + newtSessions, + clientSitesAssociationsCache +} from "@server/db"; +import { count, eq } from "drizzle-orm"; + +const peerRemovals: string[] = []; +const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + if (req.method === "DELETE") { + if (!req.url) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + const url = new URL(req.url, "http://localhost"); + const pubKey = url.searchParams.get("public_key"); + if (pubKey !== null) { + peerRemovals.push(pubKey); + } + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + }); +}); + +server.listen(0, "127.0.0.1", async () => { + const addr = server.address(); + if (addr === null || typeof addr === "string") { + throw new Error("failed to bind the stub exit node"); + } + const port = addr.port; + try { + await db.insert(exitNodes).values({ + exitNodeId: 1, + name: "en", + address: "10.0.0.1", + endpoint: "10.0.0.1:51820", + publicKey: "enKey", + listenPort: 51820, + reachableAt: "http://127.0.0.1:" + port, + type: "wireguard" + }); + await db.insert(orgs).values({ + orgId: "org1", + name: "Org", + settingsJitModeLimit: 1 + }); + for (let i = 1; i <= 2; i++) { + await db.insert(sites).values({ + siteId: i, + orgId: "org1", + niceId: String(i), + name: "wg" + i, + pubKey: "wgKey" + i, + exitNodeId: 1, + type: "wireguard" + }); + } + for (let i = 3; i <= 4; i++) { + await db.insert(sites).values({ + siteId: i, + orgId: "org1", + niceId: String(i), + name: "newt" + i, + pubKey: "newtPub" + i, + publicKey: "newtSession" + i, + type: "newt" + }); + await db.insert(newts).values({ + newtId: "newtId" + i, + secretHash: "h" + i, + dateCreated: "2026-01-01", + siteId: i + }); + await db.insert(newtSessions).values({ + sessionId: "sess" + i, + newtId: "newtId" + i, + expiresAt: 4102444800 + }); + } + // A stale association-cache row pinned to one of the newt sites, the + // kind cleanupSiteAssociations exists to clear. + await db.insert(clientSitesAssociationsCache).values({ + clientId: 99, + siteId: 3, + isRelayed: false + }); + + const result = await deleteOrgById("org1"); + + // Read the leftovers through the query builder, not db.get(): the + // pg build type-checks this file against the pg driver too, and + // .get() exists only on the sqlite driver. + const [sitesRow] = await db.select({ count: count() }).from(sites); + const [newtsRow] = await db.select({ count: count() }).from(newts); + const [sessionsRow] = await db + .select({ count: count() }) + .from(newtSessions); + const [assocRow] = await db + .select({ count: count() }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, 3)); + + console.log( + "OBSERVATIONS " + + JSON.stringify({ + peerRemovals: peerRemovals.slice().sort(), + terminatedNewtIds: result.deletedNewtIds.slice().sort(), + sitesLeft: sitesRow.count, + newtsLeft: newtsRow.count, + sessionsLeft: sessionsRow.count, + associationRowsLeft: assocRow.count + }) + ); + server.close(); + process.exit(0); + } catch (e: any) { + console.error("CHILD_FAIL", e && e.message ? e.message : String(e)); + server.close(); + process.exit(1); + } +}); diff --git a/server/lib/deleteOrg.test.ts b/server/lib/deleteOrg.test.ts new file mode 100644 index 000000000..8ec46183b --- /dev/null +++ b/server/lib/deleteOrg.test.ts @@ -0,0 +1,185 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import { assertEquals } from "@test/assert"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "..", ".."); + +const CHILD_SCRIPT_PATH = path.join( + repoRoot, + "server", + "lib", + "deleteOrg.childTest.ts" +); + +function runChild(workdir: string): { + exitCode: number; + observations: any; + 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, CHILD_SCRIPT_PATH], + { + cwd: workdir, + timeout: 120000, + encoding: "utf8", + env: { ...process.env, NODE_ENV: "test" } + } + ); + const m = output.match(/OBSERVATIONS (.*)/); + if (!m) { + throw new Error("child did not print observations: " + output); + } + return { exitCode: 0, observations: JSON.parse(m[1]), output }; + } catch (error: any) { + const output = String(error?.stdout ?? ""); + return { exitCode: 1, observations: null, output }; + } +} + +function createTestEnvironment(): string { + 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-org-cleanup-") + ); + 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" + ); + fs.symlinkSync( + path.join(repoRoot, "test"), + path.join(workdir, "test"), + process.platform === "win32" ? "junction" : "dir" + ); + fs.symlinkSync( + path.join(repoRoot, "node_modules"), + path.join(workdir, "node_modules"), + process.platform === "win32" ? "junction" : "dir" + ); + return workdir; +} + +function applyMigrations(workdir: 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" + ); + // CI applies migrations with `npm run db:push` before booting the app; + // server/db/migrate.ts is the drizzle migrator that script invokes. + const migrationsScript = path.join(repoRoot, "server", "db", "migrate.ts"); + execFileSync( + process.execPath, + [tsxCli, "--tsconfig", tsconfig, migrationsScript], + { + cwd: workdir, + timeout: 120000, + encoding: "utf8", + env: { ...process.env, NODE_ENV: "test" } + } + ); +} + +function testOrgCleanupCoversEverySite() { + console.log("Running deleteOrgById per-site cleanup test..."); + const workdir = createTestEnvironment(); + applyMigrations(workdir); + + const { exitCode, observations, output } = runChild(workdir); + if (exitCode !== 0) { + throw new Error("deleteOrgById child run failed: " + output); + } + + // The exact bug from the issue: with a single-row sites fetch, only one + // of the four sites gets its side effects. All wireguard peers must be + // removed from the exit node... + assertEquals( + JSON.stringify(observations.peerRemovals), + JSON.stringify(["wgKey1", "wgKey2"]), + "deleteOrgById must remove every wireguard site's peer from its exit node" + ); + // ...and every newt connector must be collected for the terminate message. + assertEquals( + JSON.stringify(observations.terminatedNewtIds), + JSON.stringify(["newtId3", "newtId4"]), + "deleteOrgById must collect every newt id so all connectors get the terminate message" + ); + // DB rows must still cascade to nothing left. + assertEquals(observations.sitesLeft, 0, "all site rows must be gone"); + assertEquals(observations.newtsLeft, 0, "all newt rows must be gone"); + assertEquals( + observations.sessionsLeft, + 0, + "all newt session rows must be gone" + ); + + // The gap the issue calls out separately: cleanupSiteAssociations must + // run for newt sites (deleteSite.ts already does this), so the stale + // association cache rows for these sites must be cleared. + assertEquals( + observations.associationRowsLeft, + 0, + "newt site cleanup must clear clientSitesAssociationsCache rows" + ); + + console.log("deleteOrgById per-site cleanup test passed"); +} + +function main() { + let failed = 0; + for (const test of [testOrgCleanupCoversEverySite]) { + try { + test(); + } catch (error) { + failed++; + console.error(`FAIL: ${(error as Error).message}`); + } + } + if (failed > 0) { + process.exit(1); + } + console.log("deleteOrg tests complete"); +} + +main(); diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts index 43921e41b..e396a56ee 100644 --- a/server/lib/deleteOrg.ts +++ b/server/lib/deleteOrg.ts @@ -23,6 +23,7 @@ import { sendToClient } from "#dynamic/routers/ws"; import { deletePeer } from "@server/routers/gerbil/peers"; import { OlmErrorCodes } from "@server/routers/olm/error"; import { sendTerminateClient } from "@server/routers/client/terminate"; +import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "./billing/usageService"; import { LimitId } from "./billing"; @@ -55,8 +56,7 @@ export async function deleteOrgById( const orgSites = await db .select() .from(sites) - .where(eq(sites.orgId, orgId)) - .limit(1); + .where(eq(sites.orgId, orgId)); const orgClients = await db .select() @@ -70,23 +70,31 @@ export async function deleteOrgById( let siteCount: number | null = null; let userCount: number | null = null; let remoteExitNodeCount: number | null = null; + // billingOrgId has no FK, so the billing org may already be gone (e.g. + // a partially failed account deletion). Writing usage for it would + // violate the usage FK and abort the transaction. + let billingOrgExists = false; await db.transaction(async (trx) => { for (const site of orgSites) { - if (site.pubKey) { - if (site.type == "wireguard") { + if (site.type == "wireguard") { + if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, site.siteId)) - .returning(); - if (deletedNewt) { - deletedNewtIds.push(deletedNewt.newtId); - await trx - .delete(newtSessions) - .where(eq(newtSessions.newtId, deletedNewt.newtId)); - } + } + } else if (site.type == "newt") { + // Same per-site cleanup deleteSite.ts performs, so that + // removing an org does not leave other orgs' clients + // holding stale associations for these sites. + await cleanupSiteAssociations(site, trx); + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, site.siteId)) + .returning(); + if (deletedNewt) { + deletedNewtIds.push(deletedNewt.newtId); + await trx + .delete(newtSessions) + .where(eq(newtSessions.newtId, deletedNewt.newtId)); } } logger.info(`Deleting site ${site.siteId}`); @@ -157,7 +165,17 @@ export async function deleteOrgById( .where(inArray(domains.domainId, domainIdsToDelete)); } - await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here + const [billingOrg] = org.billingOrgId + ? await trx + .select({ orgId: orgs.orgId }) + .from(orgs) + .where(eq(orgs.orgId, org.billingOrgId)) + .limit(1) + : [org]; + billingOrgExists = !!billingOrg; + if (billingOrgExists) { + await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here + } await trx.delete(orgs).where(eq(orgs.orgId, orgId)); @@ -201,7 +219,11 @@ export async function deleteOrgById( } }); - if (org.billingOrgId) { + if ( + org.billingOrgId && + org.billingOrgId !== orgId && + billingOrgExists + ) { usageService.updateCount( org.billingOrgId, LimitId.DOMAINS, diff --git a/server/lib/exitNodes/getUniqueSubnetForExitNode.ts b/server/lib/exitNodes/getUniqueSubnetForExitNode.ts index 728049eb1..1c5f0f660 100644 --- a/server/lib/exitNodes/getUniqueSubnetForExitNode.ts +++ b/server/lib/exitNodes/getUniqueSubnetForExitNode.ts @@ -1,41 +1,62 @@ -import { db, ExitNode, Transaction, sites, clients } from "@server/db"; +import { ExitNode, primaryDb, sites, clients } from "@server/db"; import { eq } from "drizzle-orm"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; import { lockManager } from "#dynamic/lib/lock"; +/** + * Picks the next free site/client subnet on the given exit node. This acquires + * a per-exit-node lock that the caller MUST release (via the returned + * `release`) only after the chosen subnet has been persisted to the database + * (or the operation has failed) — otherwise a concurrent caller on another + * node could pick the same subnet. If no subnet is available the lock is + * released before returning and `value` is null. + */ export async function getUniqueSubnetForExitNode( - exitNode: ExitNode, - trx: Transaction | typeof db = db -): Promise { + exitNode: ExitNode +): Promise<{ value: string | null; release: () => Promise }> { const lockKey = `subnet-allocation:${exitNode.exitNodeId}`; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + const release = () => lockManager.releaseLock(lockKey, acquired); - return await lockManager.withLock( - lockKey, - async () => { - const [sitesQuery, clientsQuery] = await Promise.all([ - trx - .select({ subnet: sites.exitNodeSubnet }) - .from(sites) - .where(eq(sites.exitNodeId, exitNode.exitNodeId)), - trx - .select({ subnet: clients.exitNodeSubnet }) - .from(clients) - .where(eq(clients.exitNodeId, exitNode.exitNodeId)) - ]); + try { + // read from the primary so we see subnets committed by the previous lock holder + const [sitesQuery, clientsQuery] = await Promise.all([ + primaryDb + .select({ subnet: sites.exitNodeSubnet }) + .from(sites) + .where(eq(sites.exitNodeId, exitNode.exitNodeId)), + primaryDb + .select({ subnet: clients.exitNodeSubnet }) + .from(clients) + .where(eq(clients.exitNodeId, exitNode.exitNodeId)) + ]); - const blockSize = config.getRawConfig().gerbil.site_block_size; - const subnets = [...sitesQuery, ...clientsQuery] - .map((row) => row.subnet) - .filter( - (subnet): subnet is string => - !!subnet && - /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) - ); - subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); + const blockSize = config.getRawConfig().gerbil.site_block_size; + const subnets = [...sitesQuery, ...clientsQuery] + .map((row) => row.subnet) + .filter( + (subnet): subnet is string => + !!subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) + ); + subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); - return findNextAvailableCidr(subnets, blockSize, exitNode.address); - }, - 5000 // 5 second lock TTL - subnet allocation should be quick - ); + const subnet = findNextAvailableCidr( + subnets, + blockSize, + exitNode.address + ); + if (!subnet) { + await release(); + return { value: null, release: async () => {} }; + } + + return { value: subnet, release }; + } catch (e) { + await release(); + throw e; + } } diff --git a/server/private/lib/certificates/index.ts b/server/private/lib/certificates/index.ts index 6097629a7..27dd24177 100644 --- a/server/private/lib/certificates/index.ts +++ b/server/private/lib/certificates/index.ts @@ -15,6 +15,23 @@ import logger from "@server/logger"; import { privateConfig } from "#private/lib/config"; import { acmeClientManager } from "./acme-client"; import { jobScheduler } from "./scheduler"; +import { LeaderElection } from "#private/lib/leaderElection"; + +// Only one instance sharing this Redis talks to the ACME server at a time. +// Every instance runs the election; whichever holds the lease runs issuance +// and renewal, and another takes over if it goes away. +const acmeLeaderElection = new LeaderElection("acme-client", { + onElected: async () => { + logger.info("Starting certificate management server..."); + + // Initialize ACME client + await acmeClientManager.initialize(); + + // Start certificate issuance/renewal jobs + await jobScheduler.start(); + }, + onDemoted: () => jobScheduler.stopCertJobs() +}); export async function startCertificateManager() { const acmeConfig = privateConfig.getRawPrivateConfig().acme; @@ -23,13 +40,7 @@ export async function startCertificateManager() { 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(); + acmeLeaderElection.start(); } if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { @@ -41,5 +52,6 @@ export async function startCertificateManager() { } export async function stopCertificateManager() { + await acmeLeaderElection.stop(); await jobScheduler.stop(); } diff --git a/server/private/lib/certificates/scheduler.ts b/server/private/lib/certificates/scheduler.ts index c46463008..4bd52a6eb 100644 --- a/server/private/lib/certificates/scheduler.ts +++ b/server/private/lib/certificates/scheduler.ts @@ -173,13 +173,21 @@ export class JobScheduler { logger.info("DNS validation job scheduler started successfully"); } - async stop(): Promise { + // Stops only issuance/renewal (e.g. when this instance stops being the + // ACME leader) and leaves the DNS jobs running. A batch already in + // flight finishes on its own; the per-domain cert locks keep it from + // overlapping with the new leader's work on the same certificate. + async stopCertJobs(): Promise { if (this.certRunning) { logger.info("Stopping certificate job scheduler"); this.certRunning = false; this.certIntervals.forEach((interval) => clearInterval(interval)); this.certIntervals = []; } + } + + async stop(): Promise { + await this.stopCertJobs(); if (this.dnsRunning) { logger.info("Stopping DNS validation job scheduler"); diff --git a/server/private/lib/leaderElection.ts b/server/private/lib/leaderElection.ts new file mode 100644 index 000000000..27f850da4 --- /dev/null +++ b/server/private/lib/leaderElection.ts @@ -0,0 +1,259 @@ +/* + * 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 { v4 as uuidv4 } from "uuid"; +import redisManager from "#private/lib/redis"; +import { withTimeout } from "@server/lib/retry"; +import logger from "@server/logger"; + +// How long a lease lasts without being renewed. If the leader dies, the +// role sits empty for at most this long before a follower can take it. +const LEASE_TTL_MS = 30_000; + +// How often the leader renews its lease and a follower tries to take it. +const TICK_INTERVAL_MS = 10_000; + +// A leader that can't confirm its lease within this time steps down. Even +// when the last successful renewal was just before a tick, it stops within +// TICK_INTERVAL_MS + REDIS_TIMEOUT_MS (15s), well before the lease can run +// out (LEASE_TTL_MS) and another instance can take it. +const REDIS_TIMEOUT_MS = 5_000; + +const RENEW_SCRIPT = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('PEXPIRE', KEYS[1], ARGV[2]) +else + return 0 +end +`; + +const RELEASE_SCRIPT = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +else + return 0 +end +`; + +type LeaderCallbacks = { + // Runs once each time this instance becomes leader. If it throws, the + // lease is given up and the next tick tries again (here or elsewhere). + onElected: () => Promise; + // Runs when leadership is lost or given up. Must be safe to call even if + // onElected only partly finished. + onDemoted: () => Promise; +}; + +/** + * Makes sure only one Pangolin instance sharing a Redis is running a given + * role at a time. + * + * Leadership is a Redis key, set with NX and a TTL, whose value is this + * instance's token. The leader renews it every tick and steps down as soon + * as a renewal fails or times out, so it has stopped before the lease + * can run out and another instance can take over. + * + * Talks to the Redis client directly rather than through lockManager: + * lockManager falls back to in-process locks while Redis is unreachable, + * which would let every instance become leader during an outage. Here an + * unreachable Redis means no instance is leader until it comes back. + * + * With Redis disabled, the deployment is assumed to be a single instance + * (Redis is required to run more than one), so this instance is always the + * leader. + */ +export class LeaderElection { + private readonly key: string; + private readonly callbacks: LeaderCallbacks; + private token: string | null = null; + private timer: NodeJS.Timeout | null = null; + private leader = false; + private running = false; + + constructor(name: string, callbacks: LeaderCallbacks) { + this.key = `leader:${name}`; + this.callbacks = callbacks; + } + + start(): void { + if (this.running) { + return; + } + this.running = true; + + if (!redisManager.getWriteClient()) { + logger.info( + `Redis is disabled - assuming a single instance and taking ${this.key}` + ); + void this.becomeLeader(); + return; + } + + logger.info(`Leader election started for ${this.key}`); + void this.tick(); + } + + async stop(): Promise { + if (!this.running) { + return; + } + this.running = false; + + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + await this.stepDown("shutting down"); + } + + isLeader(): boolean { + return this.leader; + } + + // Ticks are chained with setTimeout rather than setInterval so a slow + // acquire/renew can never overlap with the next one. + private async tick(): Promise { + if (!this.running) { + return; + } + + try { + if (this.leader) { + await this.renew(); + } else { + await this.tryAcquire(); + } + } catch (error) { + logger.error(`Leader election error for ${this.key}:`, error); + await this.stepDown("leader election error"); + } + + if (this.running) { + this.timer = setTimeout(() => void this.tick(), TICK_INTERVAL_MS); + } + } + + private async tryAcquire(): Promise { + const client = redisManager.getWriteClient()!; + const token = uuidv4(); + + const result = await withTimeout( + client.set(this.key, token, "PX", LEASE_TTL_MS, "NX"), + REDIS_TIMEOUT_MS, + `acquire ${this.key}` + ); + + if (result !== "OK") { + logger.debug( + `Not leader for ${this.key} - another instance holds the lease` + ); + return; + } + + this.token = token; + + // stop() may have run while the SET was in flight. + if (!this.running) { + await this.release(); + return; + } + + await this.becomeLeader(); + } + + private async renew(): Promise { + if (!this.token) { + throw new Error("leader has no lease token"); + } + + const client = redisManager.getWriteClient()!; + const renewed = (await withTimeout( + client.eval( + RENEW_SCRIPT, + 1, + this.key, + this.token, + LEASE_TTL_MS.toString() + ), + REDIS_TIMEOUT_MS, + `renew ${this.key}` + )) as number; + + if (renewed !== 1) { + throw new Error("lease is no longer held by this instance"); + } + } + + private async becomeLeader(): Promise { + this.leader = true; + logger.info(`Elected leader for ${this.key}`); + + try { + await this.callbacks.onElected(); + } catch (error) { + logger.error( + `Failed to start ${this.key} after being elected leader:`, + error + ); + // Without Redis there's nobody to hand over to and no retry loop, + // so keep the role, just as it was before leader election. + if (this.token) { + await this.stepDown("startup failed"); + } + } + } + + private async stepDown(reason: string): Promise { + const wasLeader = this.leader; + this.leader = false; + + if (wasLeader) { + logger.warn(`Stepping down as leader for ${this.key}: ${reason}`); + try { + await this.callbacks.onDemoted(); + } catch (error) { + logger.error( + `Error stopping ${this.key} after stepping down:`, + error + ); + } + } + + // Only after onDemoted has stopped this instance from scheduling more + // work, so the next leader doesn't overlap with it. + await this.release(); + } + + // Best effort: if Redis is unreachable the lease just runs out after + // LEASE_TTL_MS instead. + private async release(): Promise { + const token = this.token; + this.token = null; + + const client = redisManager.getWriteClient(); + if (!token || !client) { + return; + } + + try { + await withTimeout( + client.eval(RELEASE_SCRIPT, 1, this.key, token), + REDIS_TIMEOUT_MS, + `release ${this.key}` + ); + } catch (error) { + logger.warn(`Failed to release ${this.key}:`, error); + } + } +} diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index 0a1f74f3e..5892e4ada 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -182,7 +182,14 @@ export async function deleteMyAccount( const allDeletedNewtIds: string[] = []; const allOlmsToTerminate: string[] = []; - for (const row of ownedOrgsRows) { + // Delete billing orgs last: deleting a child org decrements usage on + // its billing org, which fails (and aborts the transaction) if the + // billing org is already gone. + const orgsInDeleteOrder = [...ownedOrgsRows].sort( + (a, b) => Number(!!a.isBillingOrg) - Number(!!b.isBillingOrg) + ); + + for (const row of orgsInDeleteOrder) { try { const result = await deleteOrgById(row.orgId); allDeletedNewtIds.push(...result.deletedNewtIds); diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 4d90aa0aa..db21a6661 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -112,7 +112,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { return; } - const newSubnet = await getUniqueSubnetForExitNode(exitNode); + const { value: newSubnet, release: releaseSubnetLock } = + await getUniqueSubnetForExitNode(exitNode); if (!newSubnet) { logger.error( @@ -124,15 +125,20 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { siteSubnet = newSubnet; - await db - .update(sites) - .set({ - pubKey: publicKey, - exitNodeId: exitNodeId, - exitNodeSubnet: newSubnet - }) - .where(eq(sites.siteId, siteId)) - .returning(); + try { + await db + .update(sites) + .set({ + pubKey: publicKey, + exitNodeId: exitNodeId, + exitNodeSubnet: newSubnet + }) + .where(eq(sites.siteId, siteId)) + .returning(); + } finally { + // only release once the subnet is persisted so no other node can pick it + await releaseSubnetLock(); + } } else { await db .update(sites) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index c74b82c6b..13bf44070 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -313,6 +313,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } let clientSubnet = client.exitNodeSubnet; + let releaseSubnetLock: (() => Promise) | null = null; if ( exitNodeId && (client.exitNodeId !== exitNodeId || !client.exitNodeSubnet) @@ -340,7 +341,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT? - const newSubnet = await getUniqueSubnetForExitNode(exitNode); + const { value: newSubnet, release } = + await getUniqueSubnetForExitNode(exitNode); if (!newSubnet) { logger.error( @@ -352,6 +354,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } clientSubnet = newSubnet; + releaseSubnetLock = release; } if ( @@ -364,16 +367,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", { orgId: client.orgId, clientId: client.clientId } ); - // Update the client's public key - await db - .update(clients) - .set({ - pubKey: publicKey, - archived: false, - exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it - exitNodeSubnet: clientSubnet - }) - .where(eq(clients.clientId, client.clientId)); + try { + // Update the client's public key + await db + .update(clients) + .set({ + pubKey: publicKey, + archived: false, + exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it + exitNodeSubnet: clientSubnet + }) + .where(eq(clients.clientId, client.clientId)); + } finally { + // only release once the subnet is persisted so no other node can pick it + await releaseSubnetLock?.(); + } // set isRelay to false for all of the client's sites to reset the connection metadata await db @@ -396,6 +404,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); } + // safety net in case a subnet was locked but the update above was skipped (release is idempotent) + await releaseSubnetLock?.(); + if (client.pubKey && client.pubKey !== publicKey && client.exitNodeId) { // test the old client to see if its different then remove logger.info("Public key mismatch. Deleting old peer...");