mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 03:59:07 +02:00
Merge branch 'main' into dev
This commit is contained in:
+6
-4
@@ -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 server/db/mac_models.json ./dist/mac_models.json
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
# Copy MaxMind databases (empty directory for non-saas builds)
|
RUN mkdir -p ./maxmind
|
||||||
ARG BUILD=oss
|
|
||||||
|
|
||||||
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
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
ARG VERSION="dev"
|
ARG VERSION="dev"
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ build-saas:
|
|||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
--tag $(AWS_IMAGE):$(tag) \
|
--tag $(AWS_IMAGE):$(tag) \
|
||||||
--push .
|
--push .
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=enterprise \
|
||||||
|
--build-arg DATABASE=pg \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--tag $(AWS_IMAGE):$(tag)-ee \
|
||||||
|
--push .
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg BUILD=enterprise \
|
--build-arg BUILD=enterprise \
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.26.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // 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
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -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/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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
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 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
Generated
+118
-118
@@ -1830,9 +1830,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1848,13 +1848,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1870,20 +1870,20 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -1893,9 +1893,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1909,9 +1909,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1925,9 +1925,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1944,9 +1944,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1963,9 +1963,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1982,9 +1982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2001,9 +2001,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2020,9 +2020,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2039,9 +2039,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2058,9 +2058,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2077,9 +2077,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2098,13 +2098,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2123,13 +2123,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2148,13 +2148,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2173,13 +2173,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2198,13 +2198,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2223,13 +2223,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2248,13 +2248,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2273,13 +2273,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2303,16 +2303,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -2322,9 +2322,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2341,9 +2341,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2360,9 +2360,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -16397,9 +16397,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -16414,31 +16414,31 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.35.4",
|
"@img/sharp-darwin-arm64": "0.35.5",
|
||||||
"@img/sharp-darwin-x64": "0.35.4",
|
"@img/sharp-darwin-x64": "0.35.5",
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||||
"@img/sharp-linux-arm": "0.35.4",
|
"@img/sharp-linux-arm": "0.35.5",
|
||||||
"@img/sharp-linux-arm64": "0.35.4",
|
"@img/sharp-linux-arm64": "0.35.5",
|
||||||
"@img/sharp-linux-ppc64": "0.35.4",
|
"@img/sharp-linux-ppc64": "0.35.5",
|
||||||
"@img/sharp-linux-riscv64": "0.35.4",
|
"@img/sharp-linux-riscv64": "0.35.5",
|
||||||
"@img/sharp-linux-s390x": "0.35.4",
|
"@img/sharp-linux-s390x": "0.35.5",
|
||||||
"@img/sharp-linux-x64": "0.35.4",
|
"@img/sharp-linux-x64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||||
"@img/sharp-win32-arm64": "0.35.4",
|
"@img/sharp-win32-arm64": "0.35.5",
|
||||||
"@img/sharp-win32-ia32": "0.35.4",
|
"@img/sharp-win32-ia32": "0.35.5",
|
||||||
"@img/sharp-win32-x64": "0.35.4"
|
"@img/sharp-win32-x64": "0.35.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
+39
-17
@@ -23,6 +23,7 @@ import { sendToClient } from "#dynamic/routers/ws";
|
|||||||
import { deletePeer } from "@server/routers/gerbil/peers";
|
import { deletePeer } from "@server/routers/gerbil/peers";
|
||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||||
|
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
|
||||||
import { usageService } from "./billing/usageService";
|
import { usageService } from "./billing/usageService";
|
||||||
import { LimitId } from "./billing";
|
import { LimitId } from "./billing";
|
||||||
|
|
||||||
@@ -55,8 +56,7 @@ export async function deleteOrgById(
|
|||||||
const orgSites = await db
|
const orgSites = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.orgId, orgId))
|
.where(eq(sites.orgId, orgId));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const orgClients = await db
|
const orgClients = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -70,23 +70,31 @@ export async function deleteOrgById(
|
|||||||
let siteCount: number | null = null;
|
let siteCount: number | null = null;
|
||||||
let userCount: number | null = null;
|
let userCount: number | null = null;
|
||||||
let remoteExitNodeCount: 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) => {
|
await db.transaction(async (trx) => {
|
||||||
for (const site of orgSites) {
|
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);
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
} else if (site.type == "newt") {
|
}
|
||||||
const [deletedNewt] = await trx
|
} else if (site.type == "newt") {
|
||||||
.delete(newts)
|
// Same per-site cleanup deleteSite.ts performs, so that
|
||||||
.where(eq(newts.siteId, site.siteId))
|
// removing an org does not leave other orgs' clients
|
||||||
.returning();
|
// holding stale associations for these sites.
|
||||||
if (deletedNewt) {
|
await cleanupSiteAssociations(site, trx);
|
||||||
deletedNewtIds.push(deletedNewt.newtId);
|
const [deletedNewt] = await trx
|
||||||
await trx
|
.delete(newts)
|
||||||
.delete(newtSessions)
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
.returning();
|
||||||
}
|
if (deletedNewt) {
|
||||||
|
deletedNewtIds.push(deletedNewt.newtId);
|
||||||
|
await trx
|
||||||
|
.delete(newtSessions)
|
||||||
|
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info(`Deleting site ${site.siteId}`);
|
logger.info(`Deleting site ${site.siteId}`);
|
||||||
@@ -157,7 +165,17 @@ export async function deleteOrgById(
|
|||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
.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));
|
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(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.DOMAINS,
|
LimitId.DOMAINS,
|
||||||
|
|||||||
@@ -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 { eq } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
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(
|
export async function getUniqueSubnetForExitNode(
|
||||||
exitNode: ExitNode,
|
exitNode: ExitNode
|
||||||
trx: Transaction | typeof db = db
|
): Promise<{ value: string | null; release: () => Promise<void> }> {
|
||||||
): Promise<string | null> {
|
|
||||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
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(
|
try {
|
||||||
lockKey,
|
// read from the primary so we see subnets committed by the previous lock holder
|
||||||
async () => {
|
const [sitesQuery, clientsQuery] = await Promise.all([
|
||||||
const [sitesQuery, clientsQuery] = await Promise.all([
|
primaryDb
|
||||||
trx
|
.select({ subnet: sites.exitNodeSubnet })
|
||||||
.select({ subnet: sites.exitNodeSubnet })
|
.from(sites)
|
||||||
.from(sites)
|
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
|
||||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
|
primaryDb
|
||||||
trx
|
.select({ subnet: clients.exitNodeSubnet })
|
||||||
.select({ subnet: clients.exitNodeSubnet })
|
.from(clients)
|
||||||
.from(clients)
|
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
|
||||||
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||||
const subnets = [...sitesQuery, ...clientsQuery]
|
const subnets = [...sitesQuery, ...clientsQuery]
|
||||||
.map((row) => row.subnet)
|
.map((row) => row.subnet)
|
||||||
.filter(
|
.filter(
|
||||||
(subnet): subnet is string =>
|
(subnet): subnet is string =>
|
||||||
!!subnet &&
|
!!subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
);
|
||||||
);
|
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
|
||||||
|
|
||||||
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
|
const subnet = findNextAvailableCidr(
|
||||||
},
|
subnets,
|
||||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
blockSize,
|
||||||
);
|
exitNode.address
|
||||||
|
);
|
||||||
|
if (!subnet) {
|
||||||
|
await release();
|
||||||
|
return { value: null, release: async () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ import logger from "@server/logger";
|
|||||||
import { privateConfig } from "#private/lib/config";
|
import { privateConfig } from "#private/lib/config";
|
||||||
import { acmeClientManager } from "./acme-client";
|
import { acmeClientManager } from "./acme-client";
|
||||||
import { jobScheduler } from "./scheduler";
|
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() {
|
export async function startCertificateManager() {
|
||||||
const acmeConfig = privateConfig.getRawPrivateConfig().acme;
|
const acmeConfig = privateConfig.getRawPrivateConfig().acme;
|
||||||
@@ -23,13 +40,7 @@ export async function startCertificateManager() {
|
|||||||
acmeConfig.cert_mode === "pangolin" &&
|
acmeConfig.cert_mode === "pangolin" &&
|
||||||
acmeConfig.enable_acme_client
|
acmeConfig.enable_acme_client
|
||||||
) {
|
) {
|
||||||
logger.info("Starting certificate management server...");
|
acmeLeaderElection.start();
|
||||||
|
|
||||||
// Initialize ACME client
|
|
||||||
await acmeClientManager.initialize();
|
|
||||||
|
|
||||||
// Start certificate issuance/renewal jobs
|
|
||||||
await jobScheduler.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
@@ -41,5 +52,6 @@ export async function startCertificateManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function stopCertificateManager() {
|
export async function stopCertificateManager() {
|
||||||
|
await acmeLeaderElection.stop();
|
||||||
await jobScheduler.stop();
|
await jobScheduler.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,13 +173,21 @@ export class JobScheduler {
|
|||||||
logger.info("DNS validation job scheduler started successfully");
|
logger.info("DNS validation job scheduler started successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
// 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<void> {
|
||||||
if (this.certRunning) {
|
if (this.certRunning) {
|
||||||
logger.info("Stopping certificate job scheduler");
|
logger.info("Stopping certificate job scheduler");
|
||||||
this.certRunning = false;
|
this.certRunning = false;
|
||||||
this.certIntervals.forEach((interval) => clearInterval(interval));
|
this.certIntervals.forEach((interval) => clearInterval(interval));
|
||||||
this.certIntervals = [];
|
this.certIntervals = [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await this.stopCertJobs();
|
||||||
|
|
||||||
if (this.dnsRunning) {
|
if (this.dnsRunning) {
|
||||||
logger.info("Stopping DNS validation job scheduler");
|
logger.info("Stopping DNS validation job scheduler");
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
// Runs when leadership is lost or given up. Must be safe to call even if
|
||||||
|
// onElected only partly finished.
|
||||||
|
onDemoted: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,7 +182,14 @@ export async function deleteMyAccount(
|
|||||||
const allDeletedNewtIds: string[] = [];
|
const allDeletedNewtIds: string[] = [];
|
||||||
const allOlmsToTerminate: 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 {
|
try {
|
||||||
const result = await deleteOrgById(row.orgId);
|
const result = await deleteOrgById(row.orgId);
|
||||||
allDeletedNewtIds.push(...result.deletedNewtIds);
|
allDeletedNewtIds.push(...result.deletedNewtIds);
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
const { value: newSubnet, release: releaseSubnetLock } =
|
||||||
|
await getUniqueSubnetForExitNode(exitNode);
|
||||||
|
|
||||||
if (!newSubnet) {
|
if (!newSubnet) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -124,15 +125,20 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
siteSubnet = newSubnet;
|
siteSubnet = newSubnet;
|
||||||
|
|
||||||
await db
|
try {
|
||||||
.update(sites)
|
await db
|
||||||
.set({
|
.update(sites)
|
||||||
pubKey: publicKey,
|
.set({
|
||||||
exitNodeId: exitNodeId,
|
pubKey: publicKey,
|
||||||
exitNodeSubnet: newSubnet
|
exitNodeId: exitNodeId,
|
||||||
})
|
exitNodeSubnet: newSubnet
|
||||||
.where(eq(sites.siteId, siteId))
|
})
|
||||||
.returning();
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.returning();
|
||||||
|
} finally {
|
||||||
|
// only release once the subnet is persisted so no other node can pick it
|
||||||
|
await releaseSubnetLock();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await db
|
await db
|
||||||
.update(sites)
|
.update(sites)
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let clientSubnet = client.exitNodeSubnet;
|
let clientSubnet = client.exitNodeSubnet;
|
||||||
|
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
||||||
if (
|
if (
|
||||||
exitNodeId &&
|
exitNodeId &&
|
||||||
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
|
(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?
|
// 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) {
|
if (!newSubnet) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -352,6 +354,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clientSubnet = newSubnet;
|
clientSubnet = newSubnet;
|
||||||
|
releaseSubnetLock = release;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -364,16 +367,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||||
{ orgId: client.orgId, clientId: client.clientId }
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
);
|
);
|
||||||
// Update the client's public key
|
try {
|
||||||
await db
|
// Update the client's public key
|
||||||
.update(clients)
|
await db
|
||||||
.set({
|
.update(clients)
|
||||||
pubKey: publicKey,
|
.set({
|
||||||
archived: false,
|
pubKey: publicKey,
|
||||||
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
|
archived: false,
|
||||||
exitNodeSubnet: clientSubnet
|
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));
|
})
|
||||||
|
.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
|
// set isRelay to false for all of the client's sites to reset the connection metadata
|
||||||
await db
|
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) {
|
if (client.pubKey && client.pubKey !== publicKey && client.exitNodeId) {
|
||||||
// test the old client to see if its different then remove
|
// test the old client to see if its different then remove
|
||||||
logger.info("Public key mismatch. Deleting old peer...");
|
logger.info("Public key mismatch. Deleting old peer...");
|
||||||
|
|||||||
Reference in New Issue
Block a user