main(init): Initial commit.
This commit is contained in:
commit
5700ea0051
73 changed files with 9764 additions and 0 deletions
7
.env
Normal file
7
.env
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
MAXMIND_ACCOUNT_ID=1027646
|
||||||
|
MAXMIND_LICENSE_KEY=https://download.maxmind.com/app/geoip_download_by_token?date=20260911&edition_id=GeoLite2-City-CSV&suffix=zip&token=v2.local.aD8lesxw-3-bc77b9_IDDKdEAqhHQ0H4O5ohC1dpy5J5UmGtim3s4bw7sYJAQBy0xd54tMuhprtebRU9PbCpBLslYS5YDXHTHUf3DByyjwgH_X7mBZpZ-UWsFsF4OUqopwA8Xog1g45e17uROIPvfzXAdfz6xH-WdgxUU8v0Pd8u3XiP4b8oRWh7jUQD50zg5jQUTIA
|
||||||
|
|
||||||
|
PEER_API_TOKEN=4642627935
|
||||||
|
GAPMIND_SSH_PASSWORD=4642627935
|
||||||
|
|
||||||
|
GAPMIND_API_KEY=EonEBmSwnKSN0dmjkgzN6AH825DqtuAjahmOwakWIiw
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
[._]*.s[a-v][a-z]
|
||||||
|
[._]*.sw[a-p]
|
||||||
|
[._]s[a-rt-v][a-z]
|
||||||
|
[._]ss[a-gi-z]
|
||||||
|
[._]sw[a-p]
|
||||||
|
|
||||||
|
services/dht/dht
|
||||||
|
services/orchestra/orchestra
|
||||||
|
services/pathfinder/pathfinder
|
||||||
|
services/replica/replica
|
||||||
|
|
||||||
|
HelveticaWorld.txt
|
||||||
|
gapmind.sqlite
|
||||||
|
|
||||||
|
.agents/
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
9
.gitmodules
vendored
Normal file
9
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[submodule "socmind"]
|
||||||
|
path = socmind
|
||||||
|
url = https://ai.crit.rip/mind/socmind
|
||||||
|
[submodule "mindmount"]
|
||||||
|
path = mindmount
|
||||||
|
url = https://ai.crit.rip/mind/mindmount
|
||||||
|
[submodule "mindless"]
|
||||||
|
path = mindless
|
||||||
|
url = https://ai.crit.rip/mind/mindless
|
||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Gapmind
|
||||||
|
|
||||||
|
IP resolution service.
|
||||||
724
bun.lock
Normal file
724
bun.lock
Normal file
|
|
@ -0,0 +1,724 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "gapmind",
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^12.0.1",
|
||||||
|
"@nestjs/core": "^12.0.1",
|
||||||
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
|
"@nestjs/platform-fastify": "^12.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^12.0.1",
|
||||||
|
"@nestjs/swagger": "^12.0.1",
|
||||||
|
"@nestjs/typeorm": "^12.0.1",
|
||||||
|
"@nestjs/websockets": "^12.0.1",
|
||||||
|
"@tensorflow/tfjs": "^4.22.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"nodemon": "^3.1.14",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"sql.js": "^1.14.2",
|
||||||
|
"typeorm": "^1.1.1",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"core-js",
|
||||||
|
"@scarf/scarf",
|
||||||
|
],
|
||||||
|
"packages": {
|
||||||
|
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||||
|
|
||||||
|
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.6", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0" } }, "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA=="],
|
||||||
|
|
||||||
|
"@fastify/cors": ["@fastify/cors@11.3.0", "", { "dependencies": { "fastify-plugin": "^6.0.0", "toad-cache": "^3.7.0" } }, "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw=="],
|
||||||
|
|
||||||
|
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||||
|
|
||||||
|
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.1.0", "", { "dependencies": { "fast-json-stringify": "^7.0.0" } }, "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw=="],
|
||||||
|
|
||||||
|
"@fastify/formbody": ["@fastify/formbody@9.0.0", "", { "dependencies": { "fast-querystring": "^1.1.2", "fastify-plugin": "^6.0.0" } }, "sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA=="],
|
||||||
|
|
||||||
|
"@fastify/forwarded": ["@fastify/forwarded@3.0.2", "", {}, "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA=="],
|
||||||
|
|
||||||
|
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||||
|
|
||||||
|
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||||
|
|
||||||
|
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
|
||||||
|
|
||||||
|
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
|
||||||
|
|
||||||
|
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
|
||||||
|
|
||||||
|
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
|
||||||
|
|
||||||
|
"@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="],
|
||||||
|
|
||||||
|
"@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="],
|
||||||
|
|
||||||
|
"@nestjs/common": ["@nestjs/common@12.0.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "file-type": "22.0.2", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": ">=0.4.1", "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-v0zTaRCTV2K2xSnb3GnJoQKtaR6VxouJtm+P2gpr5w61dlXeWpXl1WVknCSzUqx2QwTV10tBkHETxvQvkf2Exg=="],
|
||||||
|
|
||||||
|
"@nestjs/core": ["@nestjs/core@12.0.1", "", { "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^12.0.0", "@nestjs/microservices": "^12.0.0", "@nestjs/platform-express": "^12.0.0", "@nestjs/websockets": "^12.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/websockets"] }, "sha512-rU6tAi8vDdyzHgN0iW0J4UJvziroOqzHqzlqL7phOSPizaXVEePfv+OUOsdJEOh0Qd14mslc3udOheLrAEcZow=="],
|
||||||
|
|
||||||
|
"@nestjs/mapped-types": ["@nestjs/mapped-types@12.0.0", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-aX7lxZcqXldtnSr0bgT2ZPQvpTpIwBMW1GMX29o4s3q02vmq6JoTYuPW0GwYXa8BiFMRX7WbGZVDA703pNqOMQ=="],
|
||||||
|
|
||||||
|
"@nestjs/platform-express": ["@nestjs/platform-express@12.0.1", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0" } }, "sha512-nF8bRgROptMyixYBfiJUAsjj2o9k13zxAjUKYaqHTPk8mTSd+gk7hXz6NykXNrfHWmMEYC9RttQlMUdMHNTbuA=="],
|
||||||
|
|
||||||
|
"@nestjs/platform-fastify": ["@nestjs/platform-fastify@12.0.1", "", { "dependencies": { "@fastify/cors": "11.3.0", "@fastify/formbody": "9.0.0", "fast-querystring": "1.1.2", "fastify": "5.12.1", "fastify-plugin": "6.0.0", "find-my-way": "9.9.0", "light-my-request": "6.6.0", "path-to-regexp": "8.4.2", "reusify": "1.1.0", "tslib": "2.8.1" }, "peerDependencies": { "@fastify/static": "^10.1.2", "@fastify/view": "^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0" }, "optionalPeers": ["@fastify/static", "@fastify/view"] }, "sha512-LqR0kOahuZAei3wJK+SJfHc9o9H/OSdA7gMSMaW+Kfjy6GtpTNPdfpHeq3w2PuiPgUGk2QIX/dqUAYntJ5arSw=="],
|
||||||
|
|
||||||
|
"@nestjs/platform-socket.io": ["@nestjs/platform-socket.io@12.0.1", "", { "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^12.0.0", "@nestjs/websockets": "^12.0.0", "rxjs": "^7.1.0" } }, "sha512-1lNTp0tjlRp+5rMX5b3G0l9bt1CH4ejH4ZMn7Qi7dJc536UpweNVWmL5zm8EHFN71Q3CxP1PbqL6aw+QrtS+8w=="],
|
||||||
|
|
||||||
|
"@nestjs/swagger": ["@nestjs/swagger@12.0.1", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "12.0.0", "@standard-schema/spec": "1.1.0", "es-toolkit": "1.51.0", "js-yaml": "5.4.1", "path-to-regexp": "8.4.2", "swagger-ui-dist": "5.32.14" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["@fastify/static", "class-transformer", "class-validator"] }, "sha512-fQ0WyutSSHedO9HzSkOFKFaRygYNGBno5EggcZljs+KL8tHzPPTO5oyJpaf3BoOKFGGsN/0B13EbIpH2pkPx8g=="],
|
||||||
|
|
||||||
|
"@nestjs/typeorm": ["@nestjs/typeorm@12.0.1", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0", "reflect-metadata": "^0.1.13 || ^0.2.0", "rxjs": "^7.2.0", "typeorm": "^0.3.0 || ^1.0.0-dev" } }, "sha512-7dsdaD/PSiwCAAhv78+ScS3e6lW41BOMZ8LB7/v5p/hovSUc1wUH1DBXhLgqYx0ChXYLzDybBIADN6lvBhiM6w=="],
|
||||||
|
|
||||||
|
"@nestjs/websockets": ["@nestjs/websockets@12.0.1", "", { "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0", "@nestjs/platform-socket.io": "^12.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/platform-socket.io"] }, "sha512-TxyfPg726saaaRihjBc5izQjQ7bRV1VJrZiEH9KO1LztJQyFbMDVztUI8ZXRgMYDuv8ZY0ENUaDb2JkpCkny0Q=="],
|
||||||
|
|
||||||
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
|
"@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="],
|
||||||
|
|
||||||
|
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||||
|
|
||||||
|
"@sqltools/formatter": ["@sqltools/formatter@1.2.5", "", {}, "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs": ["@tensorflow/tfjs@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@tensorflow/tfjs-backend-webgl": "4.22.0", "@tensorflow/tfjs-converter": "4.22.0", "@tensorflow/tfjs-core": "4.22.0", "@tensorflow/tfjs-data": "4.22.0", "@tensorflow/tfjs-layers": "4.22.0", "argparse": "^1.0.10", "chalk": "^4.1.0", "core-js": "3.29.1", "regenerator-runtime": "^0.13.5", "yargs": "^16.0.3" }, "bin": { "tfjs-custom-module": "dist/tools/custom_module/cli.js" } }, "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-backend-cpu": ["@tensorflow/tfjs-backend-cpu@4.22.0", "", { "dependencies": { "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-backend-webgl": ["@tensorflow/tfjs-backend-webgl@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@types/offscreencanvas": "~2019.3.0", "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-converter": ["@tensorflow/tfjs-converter@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-core": ["@tensorflow/tfjs-core@4.22.0", "", { "dependencies": { "@types/long": "^4.0.1", "@types/offscreencanvas": "~2019.7.0", "@types/seedrandom": "^2.4.28", "@webgpu/types": "0.1.38", "long": "4.0.0", "node-fetch": "~2.6.1", "seedrandom": "^3.0.5" } }, "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-data": ["@tensorflow/tfjs-data@4.22.0", "", { "dependencies": { "@types/node-fetch": "^2.1.2", "node-fetch": "~2.6.1", "string_decoder": "^1.3.0" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0", "seedrandom": "^3.0.5" } }, "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-layers": ["@tensorflow/tfjs-layers@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA=="],
|
||||||
|
|
||||||
|
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||||
|
|
||||||
|
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||||
|
|
||||||
|
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||||
|
|
||||||
|
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="],
|
||||||
|
|
||||||
|
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||||
|
|
||||||
|
"@types/offscreencanvas": ["@types/offscreencanvas@2019.3.0", "", {}, "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="],
|
||||||
|
|
||||||
|
"@types/seedrandom": ["@types/seedrandom@2.4.34", "", {}, "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="],
|
||||||
|
|
||||||
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
|
"@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="],
|
||||||
|
|
||||||
|
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||||
|
|
||||||
|
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
|
||||||
|
|
||||||
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
|
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
|
"avvio": ["avvio@9.3.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
|
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||||
|
|
||||||
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||||
|
|
||||||
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||||
|
|
||||||
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
|
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
|
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
||||||
|
|
||||||
|
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||||
|
|
||||||
|
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||||
|
|
||||||
|
"core-js": ["core-js@3.29.1", "", {}, "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw=="],
|
||||||
|
|
||||||
|
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||||
|
|
||||||
|
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
|
||||||
|
|
||||||
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"engine.io": ["engine.io@6.6.10", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q=="],
|
||||||
|
|
||||||
|
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||||
|
|
||||||
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"es-toolkit": ["es-toolkit@1.51.0", "", {}, "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
|
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||||
|
|
||||||
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-stringify": ["fast-json-stringify@7.0.1", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA=="],
|
||||||
|
|
||||||
|
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||||
|
|
||||||
|
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||||
|
|
||||||
|
"fast-uri": ["fast-uri@4.1.4", "", {}, "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ=="],
|
||||||
|
|
||||||
|
"fastify": ["fastify@5.12.1", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^7.0.0", "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q=="],
|
||||||
|
|
||||||
|
"fastify-plugin": ["fastify-plugin@6.0.0", "", {}, "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg=="],
|
||||||
|
|
||||||
|
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-type": ["file-type@22.0.2", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-0H8TsCUGBLx+V5adH3EY52hTAcyLKbV1D4gq5cIOJ6DnQAHeV9Z2Hhuc5CoBX4YmvB2oL+JIC84z0qO7JsCoNw=="],
|
||||||
|
|
||||||
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
|
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"find-my-way": ["find-my-way@9.9.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA=="],
|
||||||
|
|
||||||
|
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||||
|
|
||||||
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
|
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||||
|
|
||||||
|
"iterare": ["iterare@1.2.1", "", {}, "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@5.4.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ=="],
|
||||||
|
|
||||||
|
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
|
||||||
|
|
||||||
|
"load-esm": ["load-esm@1.0.3", "", {}, "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA=="],
|
||||||
|
|
||||||
|
"long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
|
||||||
|
|
||||||
|
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"multer": ["multer@2.2.0", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "type-is": "^1.6.18" } }, "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="],
|
||||||
|
|
||||||
|
"nodemon": ["nodemon@3.1.14", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw=="],
|
||||||
|
|
||||||
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||||
|
|
||||||
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||||
|
|
||||||
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
|
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||||
|
|
||||||
|
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||||
|
|
||||||
|
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||||
|
|
||||||
|
"process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="],
|
||||||
|
|
||||||
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
|
"pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="],
|
||||||
|
|
||||||
|
"qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="],
|
||||||
|
|
||||||
|
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||||
|
|
||||||
|
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
||||||
|
|
||||||
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||||
|
|
||||||
|
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||||
|
|
||||||
|
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||||
|
|
||||||
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
|
||||||
|
|
||||||
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||||
|
|
||||||
|
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
|
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="],
|
||||||
|
|
||||||
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||||
|
|
||||||
|
"seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
|
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
|
|
||||||
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "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" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
|
||||||
|
|
||||||
|
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||||
|
|
||||||
|
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||||
|
|
||||||
|
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||||
|
|
||||||
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||||
|
|
||||||
|
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
|
||||||
|
|
||||||
|
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
|
||||||
|
|
||||||
|
"socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="],
|
||||||
|
|
||||||
|
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"sql-highlight": ["sql-highlight@6.1.0", "", {}, "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA=="],
|
||||||
|
|
||||||
|
"sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="],
|
||||||
|
|
||||||
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||||
|
|
||||||
|
"swagger-ui-dist": ["swagger-ui-dist@5.32.14", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-nOA2pSQhcmODMUQZpJHYKNuwniDUqcOWGNaSCOoZv12FdOSJ9JxV95HtyRGNMqEBj6h6lCNTy20TgZDYTSuUIg=="],
|
||||||
|
|
||||||
|
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="],
|
||||||
|
|
||||||
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
|
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||||
|
|
||||||
|
"touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="],
|
||||||
|
|
||||||
|
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|
||||||
|
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
|
||||||
|
|
||||||
|
"typeorm": ["typeorm@1.1.1", "", { "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.3.1", "dayjs": "^1.11.21", "debug": "^4.4.3", "dedent": "^1.7.2", "reflect-metadata": "^0.2.2", "sql-highlight": "^6.1.0", "tinyglobby": "^0.2.17", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@google-cloud/spanner": "^8.0.0", "@sap/hana-client": "^2.14.22", "better-sqlite3": "^12.0.0", "ioredis": "^5.0.4", "mongodb": "^7.0.0", "mssql": "^12.0.0", "mysql2": "^3.15.3", "oracledb": "^6.3.0 || ^7.0.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", "redis": "^5.0.0 || ^6.0.0", "sql.js": "^1.4.0", "ts-node": "^10.9.2", "typeorm-aurora-data-api-driver": "^3.0.0" }, "optionalPeers": ["@google-cloud/spanner", "@sap/hana-client", "better-sqlite3", "ioredis", "mongodb", "mssql", "mysql2", "oracledb", "pg", "pg-native", "pg-query-stream", "redis", "sql.js", "ts-node", "typeorm-aurora-data-api-driver"], "bin": { "typeorm": "cli.js", "typeorm-ts-node-esm": "cli-ts-node-esm.js", "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js" } }, "sha512-og9mG4Lwlvj6MfvYd8yRCzXy73gETtwgTKQ/ISLYvEOpJPy4IrjU/89HK4ZAFmRIbEg1hkIk04v8phTrxfgPmQ=="],
|
||||||
|
|
||||||
|
"uid": ["uid@2.0.2", "", { "dependencies": { "@lukeed/csprng": "^1.0.0" } }, "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g=="],
|
||||||
|
|
||||||
|
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||||
|
|
||||||
|
"undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
|
"yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="],
|
||||||
|
|
||||||
|
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||||
|
|
||||||
|
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="],
|
||||||
|
|
||||||
|
"@tensorflow/tfjs-core/@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
|
||||||
|
|
||||||
|
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"ajv/fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="],
|
||||||
|
|
||||||
|
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||||
|
|
||||||
|
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
|
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||||
|
|
||||||
|
"multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||||
|
|
||||||
|
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
|
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||||
|
|
||||||
|
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||||
|
|
||||||
|
"typeorm/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||||
|
|
||||||
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"express/accepts/negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="],
|
||||||
|
|
||||||
|
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"multer/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||||
|
|
||||||
|
"multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||||
|
|
||||||
|
"express/accepts/negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||||
|
|
||||||
|
"multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"typeorm/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
9
clients/rust/Cargo.toml
Normal file
9
clients/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "gapmind-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
72
clients/rust/src/lib.rs
Normal file
72
clients/rust/src/lib.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Auto-generated by scripts/generate-bindings.mjs — do not edit.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GapmindClient { base_url: String, token: Option<String>, http: reqwest::Client }
|
||||||
|
|
||||||
|
impl GapmindClient {
|
||||||
|
pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
|
||||||
|
Self { base_url: base_url.into().trim_end_matches('/').to_string(), token, http: reqwest::Client::new() }
|
||||||
|
}
|
||||||
|
fn auth(&self, b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
match &self.token { Some(t) => b.bearer_auth(t), None => b }
|
||||||
|
}
|
||||||
|
pub async fn get_json(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let url = format!("{}{}", self.base_url, path);
|
||||||
|
let res = self.auth(self.http.get(&url)).send().await?.error_for_status()?;
|
||||||
|
res.json().await
|
||||||
|
}
|
||||||
|
pub async fn post_json(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let url = format!("{}{}", self.base_url, path);
|
||||||
|
let res = self.auth(self.http.post(&url).json(body)).send().await?.error_for_status()?;
|
||||||
|
res.json().await
|
||||||
|
}
|
||||||
|
pub async fn health(&self) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/health"));
|
||||||
|
self.get_json(&url[self.base_url.len()..]).await
|
||||||
|
}
|
||||||
|
pub async fn routes(&self) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/api/routes"));
|
||||||
|
self.get_json(&url[self.base_url.len()..]).await
|
||||||
|
}
|
||||||
|
pub async fn ips_get(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/ips/{}", path));
|
||||||
|
self.get_json(&url[self.base_url.len()..]).await
|
||||||
|
}
|
||||||
|
pub async fn ips_post(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/ips/{}", path));
|
||||||
|
self.post_json(&url[self.base_url.len()..], body).await
|
||||||
|
}
|
||||||
|
pub async fn ips_put(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/ips/{}", path));
|
||||||
|
{ let res = self.auth(self.http.request(reqwest::Method::PUT, &url)).send().await?.error_for_status()?; res.json().await }
|
||||||
|
}
|
||||||
|
pub async fn ips_delete(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/ips/{}", path));
|
||||||
|
{ let res = self.auth(self.http.request(reqwest::Method::DELETE, &url)).send().await?.error_for_status()?; res.json().await }
|
||||||
|
}
|
||||||
|
pub async fn ips_patch(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/ips/{}", path));
|
||||||
|
{ let res = self.auth(self.http.request(reqwest::Method::PATCH, &url)).send().await?.error_for_status()?; res.json().await }
|
||||||
|
}
|
||||||
|
pub async fn socials_get(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/socials/{}", path));
|
||||||
|
self.get_json(&url[self.base_url.len()..]).await
|
||||||
|
}
|
||||||
|
pub async fn socials_post(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/socials/{}", path));
|
||||||
|
self.post_json(&url[self.base_url.len()..], body).await
|
||||||
|
}
|
||||||
|
pub async fn socials_put(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/socials/{}", path));
|
||||||
|
{ let res = self.auth(self.http.request(reqwest::Method::PUT, &url)).send().await?.error_for_status()?; res.json().await }
|
||||||
|
}
|
||||||
|
pub async fn socials_delete(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/socials/{}", path));
|
||||||
|
{ let res = self.auth(self.http.request(reqwest::Method::DELETE, &url)).send().await?.error_for_status()?; res.json().await }
|
||||||
|
}
|
||||||
|
pub async fn socials_patch(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {
|
||||||
|
let mut url = format!("{}{}", self.base_url, format!("/socials/{}", path));
|
||||||
|
{ let res = self.auth(self.http.request(reqwest::Method::PATCH, &url)).send().await?.error_for_status()?; res.json().await }
|
||||||
|
}
|
||||||
|
}
|
||||||
71
clients/typescript/gapmind.ts
Normal file
71
clients/typescript/gapmind.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Auto-generated by scripts/generate-bindings.mjs — do not edit.
|
||||||
|
|
||||||
|
export interface GapmindClientConfig { baseUrl: string; token?: string; }
|
||||||
|
|
||||||
|
export class GapmindClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private token?: string;
|
||||||
|
constructor(config: GapmindClientConfig) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||||
|
this.token = config.token;
|
||||||
|
}
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (this.token) h["Authorization"] = "Bearer " + this.token;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(this.baseUrl + path, { method, headers: this.headers(), body: body ? JSON.stringify(body) : undefined });
|
||||||
|
if (!res.ok) throw new Error("API error " + res.status + ": " + (await res.text()));
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(): Promise<unknown> {
|
||||||
|
return this.request("GET", `/health`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async routes(): Promise<unknown> {
|
||||||
|
return this.request("GET", `/api/routes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ips_get(path: string): Promise<unknown> {
|
||||||
|
return this.request("GET", `/ips/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ips_post(path: string): Promise<unknown> {
|
||||||
|
return this.request("POST", `/ips/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ips_put(path: string): Promise<unknown> {
|
||||||
|
return this.request("PUT", `/ips/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ips_delete(path: string): Promise<unknown> {
|
||||||
|
return this.request("DELETE", `/ips/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ips_patch(path: string): Promise<unknown> {
|
||||||
|
return this.request("PATCH", `/ips/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async socials_get(path: string): Promise<unknown> {
|
||||||
|
return this.request("GET", `/socials/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async socials_post(path: string): Promise<unknown> {
|
||||||
|
return this.request("POST", `/socials/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async socials_put(path: string): Promise<unknown> {
|
||||||
|
return this.request("PUT", `/socials/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async socials_delete(path: string): Promise<unknown> {
|
||||||
|
return this.request("DELETE", `/socials/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async socials_patch(path: string): Promise<unknown> {
|
||||||
|
return this.request("PATCH", `/socials/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
8
ginit.sh
Executable file
8
ginit.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
git init
|
||||||
|
git remote add origin https://ai.crit.rip/mind/gapmind
|
||||||
|
git branch -M main
|
||||||
|
git add --all
|
||||||
|
git commit -m "main(init): Initial commit."
|
||||||
|
git push -u origin main
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module crit.rip/gapmind
|
||||||
|
|
||||||
|
go 1.26.7
|
||||||
8
go.work
Normal file
8
go.work
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
go 1.26.7
|
||||||
|
|
||||||
|
use (
|
||||||
|
./services/orchestra
|
||||||
|
./services/pathfinder
|
||||||
|
./services/replica
|
||||||
|
./services/dht
|
||||||
|
)
|
||||||
22
justfile
Normal file
22
justfile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
tidy-svc:
|
||||||
|
awk '/use \(/,/\)/ {if ($1 != "use" && $1 != ")") print $1} /use [^\(]/ {print $2}' go.work | xargs -I {} sh -c 'cd "{}" && echo "Tidying {}..." && go mod tidy'
|
||||||
|
go work edit -json | grep -o '"DiskPath": "[^"]*' | cut -d'"' -f4 | xargs -I {} sh -c 'cd "{}" && go fmt ./...'
|
||||||
|
|
||||||
|
tidy-web:
|
||||||
|
bunx prettier --write .
|
||||||
|
|
||||||
|
build:
|
||||||
|
npm run build
|
||||||
|
cd services/pathfinder && GOCACHE=/tmp/gapmind-go-cache go build -buildvcs=false -o pathfinder .
|
||||||
|
cd services/replica && GOCACHE=/tmp/gapmind-go-cache go build -buildvcs=false -o replica .
|
||||||
|
cd services/orchestra && GOCACHE=/tmp/gapmind-go-cache go build -buildvcs=false -o orchestra .
|
||||||
|
cd services/dht && GOCACHE=/tmp/gapmind-go-cache go build -buildvcs=false -o dht .
|
||||||
|
|
||||||
|
deploy config='services/orchestra/orchestra.json': build
|
||||||
|
services/orchestra/orchestra -config {{config}}
|
||||||
|
|
||||||
|
sync config='services/orchestra/orchestra.json': build
|
||||||
|
services/orchestra/orchestra -config {{config}} sync
|
||||||
|
|
||||||
|
nuke *ARGS: build
|
||||||
|
services/orchestra/orchestra -config services/orchestra/orchestra.json nuke {{ARGS}}
|
||||||
1
mindless
Submodule
1
mindless
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 552ec26321109bcc68cc46d21044fa53c52eebe1
|
||||||
1
mindmount
Submodule
1
mindmount
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 71eabae6f95edb852146361bc3e35a67bef9327b
|
||||||
19
nest-cli.json
Normal file
19
nest-cli.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"monorepo": false,
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"entryFile": "main",
|
||||||
|
"language": "ts",
|
||||||
|
"generateOptions": {
|
||||||
|
"spec": true
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "./tsconfig.build.json",
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": [],
|
||||||
|
"watchAssets": false,
|
||||||
|
"plugins": []
|
||||||
|
},
|
||||||
|
"exclude": ["**/entry-client.tsx"]
|
||||||
|
}
|
||||||
2747
package-lock.json
generated
Normal file
2747
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
52
package.json
Normal file
52
package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"name": "gapmind",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Open-source IP intelligence platform",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "nodemon --watch 'src/**/*.ts' --exec 'bun run build && node dist/main.js'",
|
||||||
|
"start:production": "NODE_ENV=production node dist/main.js",
|
||||||
|
"generate:bindings": "node scripts/generate-bindings.mjs",
|
||||||
|
"test": "tsx --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ip",
|
||||||
|
"intelligence",
|
||||||
|
"shodan",
|
||||||
|
"censys",
|
||||||
|
"dht",
|
||||||
|
"geolocation"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^12.0.1",
|
||||||
|
"@nestjs/core": "^12.0.1",
|
||||||
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
|
"@nestjs/platform-fastify": "^12.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^12.0.1",
|
||||||
|
"@nestjs/swagger": "^12.0.1",
|
||||||
|
"@nestjs/typeorm": "^12.0.1",
|
||||||
|
"@nestjs/websockets": "^12.0.1",
|
||||||
|
"@tensorflow/tfjs": "^4.22.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"nodemon": "^3.1.14",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"sql.js": "^1.14.2",
|
||||||
|
"typeorm": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.23.13"
|
||||||
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@scarf/scarf",
|
||||||
|
"core-js",
|
||||||
|
"esbuild",
|
||||||
|
"pnpm"
|
||||||
|
]
|
||||||
|
}
|
||||||
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
allowBuilds:
|
||||||
|
'@scarf/scarf': set this to true or false
|
||||||
|
core-js: set this to true or false
|
||||||
|
esbuild: set this to true or false
|
||||||
|
pnpm: set this to true or false
|
||||||
|
sqlite3: set this to true or false
|
||||||
250
scripts/generate-bindings.mjs
Normal file
250
scripts/generate-bindings.mjs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(__dirname, '..');
|
||||||
|
|
||||||
|
async function loadSpec(cliPath) {
|
||||||
|
if (cliPath) return JSON.parse(readFileSync(cliPath, 'utf-8'));
|
||||||
|
const res = await fetch('http://localhost:3000/api/openapi.json');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOpId(opId) {
|
||||||
|
if (!opId || typeof opId !== 'string') return 'unnamed';
|
||||||
|
return opId
|
||||||
|
.replace(/^[A-Za-z0-9]+Controller_?/, '')
|
||||||
|
.replace(/^Resolution_?/, '')
|
||||||
|
.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '') || 'unnamed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tsType(def) {
|
||||||
|
if (!def || typeof def !== 'object') return 'unknown';
|
||||||
|
if (typeof def.$ref === 'string') return sanitizeTsName(String(def.$ref.split('/').pop()));
|
||||||
|
if (def.type === 'string') return 'string';
|
||||||
|
if (def.type === 'integer' || def.type === 'number') return 'number';
|
||||||
|
if (def.type === 'boolean') return 'boolean';
|
||||||
|
if (def.type === 'array') {
|
||||||
|
const items = def.items && typeof def.items === 'object' ? def.items : { type: 'unknown' };
|
||||||
|
return tsType(items) + '[]';
|
||||||
|
}
|
||||||
|
if (Array.isArray(def.oneOf)) return def.oneOf.map(tsType).join(' | ');
|
||||||
|
if (Array.isArray(def.anyOf)) return def.anyOf.map(tsType).join(' | ');
|
||||||
|
if (def.additionalProperties && typeof def.additionalProperties === 'object')
|
||||||
|
return 'Record<string, ' + tsType(def.additionalProperties) + '>';
|
||||||
|
if (def.type === 'object' || def.properties) return 'Record<string, unknown>';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTsName(name) {
|
||||||
|
const s = String(name == null ? 'unnamed' : name).replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, '');
|
||||||
|
return s || 'Unnamed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToTemplate(path) {
|
||||||
|
return String(path).replace(/\{([^}]+)\}/g, (_, p) => '${' + sanitizeTsName(p).charAt(0).toLowerCase() + sanitizeTsName(p).slice(1) + '}');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathParamNames(path) {
|
||||||
|
const out = [];
|
||||||
|
const re = /\{([^}]+)\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(String(path)))) out.push(m[1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTypeScript(spec) {
|
||||||
|
const L = [];
|
||||||
|
L.push('// Auto-generated by scripts/generate-bindings.mjs — do not edit.');
|
||||||
|
L.push('');
|
||||||
|
const schemas = (spec.components && spec.components.schemas) || {};
|
||||||
|
for (const [name, schema] of Object.entries(schemas)) {
|
||||||
|
if (!schema || typeof schema !== 'object') continue;
|
||||||
|
L.push('export interface ' + sanitizeTsName(name) + ' {');
|
||||||
|
const props = schema.properties || {};
|
||||||
|
const required = new Set(schema.required || []);
|
||||||
|
for (const [prop, def] of Object.entries(props)) {
|
||||||
|
const opt = required.has(prop) ? '' : '?';
|
||||||
|
L.push(' ' + prop + opt + ': ' + tsType(def) + ';');
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
L.push('');
|
||||||
|
}
|
||||||
|
L.push('export interface GapmindClientConfig { baseUrl: string; token?: string; }');
|
||||||
|
L.push('');
|
||||||
|
L.push('export class GapmindClient {');
|
||||||
|
L.push(' private baseUrl: string;');
|
||||||
|
L.push(' private token?: string;');
|
||||||
|
L.push(' constructor(config: GapmindClientConfig) {');
|
||||||
|
L.push(" this.baseUrl = config.baseUrl.replace(/\\/$/, '');");
|
||||||
|
L.push(' this.token = config.token;');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' private headers(): Record<string, string> {');
|
||||||
|
L.push(" const h: Record<string, string> = { 'Content-Type': 'application/json' };");
|
||||||
|
L.push(' if (this.token) h["Authorization"] = "Bearer " + this.token;');
|
||||||
|
L.push(' return h;');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' private async request<T>(method: string, path: string, body?: unknown): Promise<T> {');
|
||||||
|
L.push(' const res = await fetch(this.baseUrl + path, { method, headers: this.headers(), body: body ? JSON.stringify(body) : undefined });');
|
||||||
|
L.push(' if (!res.ok) throw new Error("API error " + res.status + ": " + (await res.text()));');
|
||||||
|
L.push(' return res.json() as Promise<T>;');
|
||||||
|
L.push(' }');
|
||||||
|
L.push('');
|
||||||
|
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||||
|
if (!methods || typeof methods !== 'object') continue;
|
||||||
|
for (const [method, op] of Object.entries(methods)) {
|
||||||
|
if (!op || typeof op !== 'object' || !op.operationId) continue;
|
||||||
|
if (!['get','post','put','patch','delete'].includes(method)) continue;
|
||||||
|
const name = cleanOpId(op.operationId);
|
||||||
|
const http = method.toUpperCase();
|
||||||
|
const qps = ((op.parameters || []).filter((p) => p && p.in === 'query'));
|
||||||
|
const tpl = pathToTemplate(path);
|
||||||
|
const pNames = pathParamNames(path);
|
||||||
|
const params = [];
|
||||||
|
for (const pn of pNames) params.push(sanitizeTsName(pn).charAt(0).toLowerCase() + sanitizeTsName(pn).slice(1) + ': string');
|
||||||
|
for (const qp of qps) {
|
||||||
|
const qn = String(qp.name);
|
||||||
|
params.push(qn + (qp.required ? ': string' : '?: string'));
|
||||||
|
}
|
||||||
|
const hasBody = !!(op.requestBody && op.requestBody.content);
|
||||||
|
if (hasBody) params.push('body: unknown');
|
||||||
|
L.push(' async ' + name + '(' + params.join(', ') + '): Promise<unknown> {');
|
||||||
|
if (qps.length) {
|
||||||
|
L.push(' const qs = new URLSearchParams();');
|
||||||
|
for (const qp of qps) {
|
||||||
|
const qn = String(qp.name);
|
||||||
|
L.push(' if (' + qn + ' !== undefined) qs.set(' + JSON.stringify(qn) + ', String(' + qn + '));');
|
||||||
|
}
|
||||||
|
const suffix = ' + (qs.toString() ? "?" + qs.toString() : "")';
|
||||||
|
L.push(' return this.request("' + http + '", `' + tpl + '`' + suffix + (hasBody ? ', body' : '') + ');');
|
||||||
|
} else {
|
||||||
|
L.push(' return this.request("' + http + '", `' + tpl + '`' + (hasBody ? ', body' : '') + ');');
|
||||||
|
}
|
||||||
|
L.push(' }');
|
||||||
|
L.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
return L.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRustName(name) {
|
||||||
|
const parts = String(name == null ? 'unnamed' : name).split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||||
|
const s = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') || 'Unnamed';
|
||||||
|
return s.replace(/^[^A-Za-z_]/, '_');
|
||||||
|
}
|
||||||
|
function sanitizeRustField(name) {
|
||||||
|
const reserved = ['type','match','struct','enum','fn','pub','use','mod','crate','self','super','move','ref'];
|
||||||
|
let s = String(name).replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
if (!s) s = 'field';
|
||||||
|
// camelCase -> snake_case
|
||||||
|
s = s.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
|
||||||
|
if (/^[0-9]/.test(s)) s = '_' + s;
|
||||||
|
s = s.toLowerCase();
|
||||||
|
if (reserved.includes(s)) s += '_';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function rustType(def, optional) {
|
||||||
|
let inner = 'serde_json::Value';
|
||||||
|
if (def && typeof def === 'object') {
|
||||||
|
if (typeof def.$ref === 'string') inner = sanitizeRustName(String(def.$ref.split('/').pop()));
|
||||||
|
else if (def.type === 'string') inner = 'String';
|
||||||
|
else if (def.type === 'integer') inner = 'i64';
|
||||||
|
else if (def.type === 'number') inner = 'f64';
|
||||||
|
else if (def.type === 'boolean') inner = 'bool';
|
||||||
|
else if (def.type === 'array') inner = 'Vec<' + rustType(def.items || {}, false) + '>';
|
||||||
|
else if (def.type === 'object' || def.properties) inner = 'serde_json::Value';
|
||||||
|
}
|
||||||
|
return optional ? 'Option<' + inner + '>' : inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRust(spec) {
|
||||||
|
const L = [];
|
||||||
|
L.push('// Auto-generated by scripts/generate-bindings.mjs — do not edit.');
|
||||||
|
L.push('use serde::{Deserialize, Serialize};');
|
||||||
|
L.push('');
|
||||||
|
const schemas = (spec.components && spec.components.schemas) || {};
|
||||||
|
for (const [name, schema] of Object.entries(schemas)) {
|
||||||
|
if (!schema || typeof schema !== 'object') continue;
|
||||||
|
const props = schema.properties || {};
|
||||||
|
if (schema.type !== 'object' && !Object.keys(props).length) continue;
|
||||||
|
const sn = sanitizeRustName(name);
|
||||||
|
const required = new Set(schema.required || []);
|
||||||
|
L.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
|
||||||
|
L.push('pub struct ' + sn + ' {');
|
||||||
|
for (const [prop, def] of Object.entries(props)) {
|
||||||
|
const f = sanitizeRustField(prop);
|
||||||
|
if (f !== prop) L.push(' #[serde(rename = "' + prop + '")]');
|
||||||
|
if (!required.has(prop)) L.push(' #[serde(default, skip_serializing_if = "Option::is_none")]');
|
||||||
|
L.push(' pub ' + f + ': ' + rustType(def, !required.has(prop)) + ',');
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
L.push('');
|
||||||
|
}
|
||||||
|
L.push('#[derive(Debug, Clone)]');
|
||||||
|
L.push('pub struct GapmindClient { base_url: String, token: Option<String>, http: reqwest::Client }');
|
||||||
|
L.push('');
|
||||||
|
L.push('impl GapmindClient {');
|
||||||
|
L.push(' pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {');
|
||||||
|
L.push(' Self { base_url: base_url.into().trim_end_matches(\'/\').to_string(), token, http: reqwest::Client::new() }');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' fn auth(&self, b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {');
|
||||||
|
L.push(' match &self.token { Some(t) => b.bearer_auth(t), None => b }');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' pub async fn get_json(&self, path: &str) -> Result<serde_json::Value, reqwest::Error> {');
|
||||||
|
L.push(' let url = format!("{}{}", self.base_url, path);');
|
||||||
|
L.push(' let res = self.auth(self.http.get(&url)).send().await?.error_for_status()?;');
|
||||||
|
L.push(' res.json().await');
|
||||||
|
L.push(' }');
|
||||||
|
L.push(' pub async fn post_json(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value, reqwest::Error> {');
|
||||||
|
L.push(' let url = format!("{}{}", self.base_url, path);');
|
||||||
|
L.push(' let res = self.auth(self.http.post(&url).json(body)).send().await?.error_for_status()?;');
|
||||||
|
L.push(' res.json().await');
|
||||||
|
L.push(' }');
|
||||||
|
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||||
|
if (!methods || typeof methods !== 'object') continue;
|
||||||
|
for (const [method, op] of Object.entries(methods)) {
|
||||||
|
if (!op || typeof op !== 'object' || !op.operationId) continue;
|
||||||
|
if (!['get','post','put','patch','delete'].includes(method)) continue;
|
||||||
|
const fnName = sanitizeRustField(cleanOpId(op.operationId));
|
||||||
|
const pNames = pathParamNames(path);
|
||||||
|
const qps = ((op.parameters || []).filter((p) => p && p.in === 'query')).map((p) => String(p.name));
|
||||||
|
const args = [...pNames.map((p) => sanitizeRustField(p) + ': &str'), ...qps.map((q) => sanitizeRustField(q) + ': Option<&str>')];
|
||||||
|
if (op.requestBody) args.push('body: &serde_json::Value');
|
||||||
|
L.push(' pub async fn ' + fnName + '(&self' + (args.length ? ', ' + args.join(', ') : '') + ') -> Result<serde_json::Value, reqwest::Error> {');
|
||||||
|
let rsPath = String(path);
|
||||||
|
for (const p of pNames) rsPath = rsPath.split('{' + p + '}').join('{}');
|
||||||
|
// build format string with positional args
|
||||||
|
const fmtArgs = pNames.map((p) => sanitizeRustField(p)).join(', ');
|
||||||
|
L.push(' let mut url = format!("{}{}", self.base_url, format!("' + rsPath.replace(/"/g, '') + '"' + (fmtArgs ? ', ' + fmtArgs : '') + '));');
|
||||||
|
if (qps.length) {
|
||||||
|
L.push(' let mut q: Vec<(&str, String)> = Vec::new();');
|
||||||
|
for (const q of qps) {
|
||||||
|
const f = sanitizeRustField(q);
|
||||||
|
L.push(' if let Some(v) = ' + f + ' { q.push(("' + q + '", v.to_string())); }');
|
||||||
|
}
|
||||||
|
L.push(' if (!q.is_empty()) { let s: Vec<String> = q.iter().map(|(k,v)| format!("{}={}", k, v)).collect(); url.push_str(&format!("?{}", s.join("&"))); }');
|
||||||
|
}
|
||||||
|
if (method === 'get') L.push(' self.get_json(&url[self.base_url.len()..]).await');
|
||||||
|
else if (method === 'post') L.push(' self.post_json(&url[self.base_url.len()..], body).await');
|
||||||
|
else L.push(' { let res = self.auth(self.http.request(reqwest::Method::' + method.toUpperCase() + ', &url)).send().await?.error_for_status()?; res.json().await }');
|
||||||
|
L.push(' }');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
L.push('}');
|
||||||
|
return L.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = await loadSpec(process.argv[2]);
|
||||||
|
console.log('Loaded: ' + spec.info?.title + ' v' + spec.info?.version + ' paths=' + Object.keys(spec.paths || {}).length);
|
||||||
|
mkdirSync(join(ROOT, 'clients', 'typescript'), { recursive: true });
|
||||||
|
writeFileSync(join(ROOT, 'clients', 'typescript', 'gapmind.ts'), generateTypeScript(spec));
|
||||||
|
mkdirSync(join(ROOT, 'clients', 'rust', 'src'), { recursive: true });
|
||||||
|
writeFileSync(join(ROOT, 'clients', 'rust', 'src', 'lib.rs'), generateRust(spec));
|
||||||
|
writeFileSync(join(ROOT, 'clients', 'rust', 'Cargo.toml'), '[package]\nname = "gapmind-client"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nserde = { version = "1", features = ["derive"] }\nserde_json = "1"\nreqwest = { version = "0.12", features = ["json"] }\n');
|
||||||
|
console.log('Done.');
|
||||||
294
services/dht/dht_node.go
Normal file
294
services/dht/dht_node.go
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SniffEvent struct {
|
||||||
|
InfoHash string
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
Query string
|
||||||
|
NodeID string
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DHTNode struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
selfID []byte
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]time.Time
|
||||||
|
onSniff func(SniffEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bootstrapNodes = []string{
|
||||||
|
"router.bittorrent.com:6881",
|
||||||
|
"router.utorrent.com:6881",
|
||||||
|
"dht.transmissionbt.com:6881",
|
||||||
|
"router.bitcomet.com:6881",
|
||||||
|
}
|
||||||
|
|
||||||
|
func bEncode(v any) []byte {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return []byte(fmt.Sprintf("%d:%s", len(t), t))
|
||||||
|
case []byte:
|
||||||
|
return []byte(fmt.Sprintf("%d:%s", len(t), string(t)))
|
||||||
|
case int:
|
||||||
|
return []byte(fmt.Sprintf("i%de", t))
|
||||||
|
case int64:
|
||||||
|
return []byte(fmt.Sprintf("i%de", t))
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(t))
|
||||||
|
for k := range t {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteByte('d')
|
||||||
|
for _, k := range keys {
|
||||||
|
b.Write(bEncode(k))
|
||||||
|
b.Write(bEncode(t[k]))
|
||||||
|
}
|
||||||
|
b.WriteByte('e')
|
||||||
|
return b.Bytes()
|
||||||
|
default:
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type decoder struct {
|
||||||
|
b []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func bDecodeDict(b []byte) (map[string]any, bool) {
|
||||||
|
d := &decoder{b: b}
|
||||||
|
v, ok := d.value()
|
||||||
|
m, ok2 := v.(map[string]any)
|
||||||
|
return m, ok && ok2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) value() (any, bool) {
|
||||||
|
if d.pos >= len(d.b) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
c := d.b[d.pos]
|
||||||
|
switch {
|
||||||
|
case c == 'd':
|
||||||
|
d.pos++
|
||||||
|
m := map[string]any{}
|
||||||
|
for d.pos < len(d.b) && d.b[d.pos] != 'e' {
|
||||||
|
ks, ok := d.bytes()
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
v, ok := d.value()
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
m[string(ks)] = v
|
||||||
|
}
|
||||||
|
if d.pos >= len(d.b) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
d.pos++
|
||||||
|
return m, true
|
||||||
|
case c == 'l':
|
||||||
|
d.pos++
|
||||||
|
var l []any
|
||||||
|
for d.pos < len(d.b) && d.b[d.pos] != 'e' {
|
||||||
|
v, ok := d.value()
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
l = append(l, v)
|
||||||
|
}
|
||||||
|
if d.pos >= len(d.b) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
d.pos++
|
||||||
|
return l, true
|
||||||
|
case c == 'i':
|
||||||
|
d.pos++
|
||||||
|
end := bytes.IndexByte(d.b[d.pos:], 'e')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
_, _ = fmt.Sscanf(string(d.b[d.pos:d.pos+end]), "%d", &n)
|
||||||
|
d.pos += end + 1
|
||||||
|
return n, true
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
b, ok := d.bytes()
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) bytes() ([]byte, bool) {
|
||||||
|
colon := bytes.IndexByte(d.b[d.pos:], ':')
|
||||||
|
if colon < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(string(d.b[d.pos:d.pos+colon]), "%d", &n)
|
||||||
|
d.pos += colon + 1
|
||||||
|
if n < 0 || d.pos+n > len(d.b) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, d.b[d.pos:d.pos+n])
|
||||||
|
d.pos += n
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dictStr(m map[string]any, key string) []byte {
|
||||||
|
if a, ok := m[key].(map[string]any); ok {
|
||||||
|
_ = a
|
||||||
|
}
|
||||||
|
if b, ok := m[key].([]byte); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListenDHT(listen string, onSniff func(SniffEvent)) (*DHTNode, error) {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", listen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.ListenUDP("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := make([]byte, 20)
|
||||||
|
_, _ = rand.Read(id)
|
||||||
|
n := &DHTNode{conn: conn, selfID: id, seen: map[string]time.Time{}, onSniff: onSniff}
|
||||||
|
go n.loop()
|
||||||
|
go n.bootstrap()
|
||||||
|
go n.crawlLoop()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) loop() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
for {
|
||||||
|
m, addr, err := n.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkt := make([]byte, m)
|
||||||
|
copy(pkt, buf[:m])
|
||||||
|
go n.handle(pkt, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) handle(pkt []byte, addr *net.UDPAddr) {
|
||||||
|
msg, ok := bDecodeDict(pkt)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
y, _ := msg["y"].([]byte)
|
||||||
|
if string(y) != "q" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q, _ := msg["q"].([]byte)
|
||||||
|
a, _ := msg["a"].(map[string]any)
|
||||||
|
t := dictStr(msg, "t")
|
||||||
|
if len(t) == 0 {
|
||||||
|
t = []byte("aa")
|
||||||
|
}
|
||||||
|
querierID := ""
|
||||||
|
if a != nil {
|
||||||
|
if id := dictStr(a, "id"); len(id) == 20 {
|
||||||
|
querierID = fmt.Sprintf("%x", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply := func(extra map[string]any) {
|
||||||
|
r := map[string]any{"id": n.selfID}
|
||||||
|
for k, v := range extra {
|
||||||
|
r[k] = v
|
||||||
|
}
|
||||||
|
resp := map[string]any{"t": string(t), "y": "r", "r": r}
|
||||||
|
_, _ = n.conn.WriteToUDP(bEncode(resp), addr)
|
||||||
|
}
|
||||||
|
switch string(q) {
|
||||||
|
case "ping":
|
||||||
|
reply(map[string]any{})
|
||||||
|
case "find_node":
|
||||||
|
reply(map[string]any{"nodes": []byte{}})
|
||||||
|
|
||||||
|
go n.findNode(addr, dictStr(a, "target"))
|
||||||
|
case "get_peers":
|
||||||
|
ih := dictStr(a, "info_hash")
|
||||||
|
if len(ih) == 20 {
|
||||||
|
n.sniff("get_peers", ih, querierID, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply(map[string]any{"nodes": []byte{}, "token": "gapmind", "values": []any{}})
|
||||||
|
case "announce_peer":
|
||||||
|
ih := dictStr(a, "info_hash")
|
||||||
|
if len(ih) == 20 {
|
||||||
|
n.sniff("announce_peer", ih, querierID, addr)
|
||||||
|
}
|
||||||
|
reply(map[string]any{})
|
||||||
|
case "sample_infohashes":
|
||||||
|
reply(map[string]any{"interval": 180, "nodes": []byte{}, "samples": []byte{}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) sniff(query string, infoHash []byte, querier string, addr *net.UDPAddr) {
|
||||||
|
hexHash := fmt.Sprintf("%x", infoHash)
|
||||||
|
n.mu.Lock()
|
||||||
|
last, dup := n.seen[hexHash]
|
||||||
|
if !dup || time.Since(last) > 5*time.Minute {
|
||||||
|
n.seen[hexHash] = time.Now()
|
||||||
|
dup = false
|
||||||
|
}
|
||||||
|
n.mu.Unlock()
|
||||||
|
if dup {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.onSniff != nil {
|
||||||
|
n.onSniff(SniffEvent{InfoHash: hexHash, IP: addr.IP.String(), Port: addr.Port, Query: query, NodeID: querier, At: time.Now()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) bootstrap() {
|
||||||
|
for _, b := range bootstrapNodes {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", b)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.findNode(addr, n.selfID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) crawlLoop() {
|
||||||
|
t := time.NewTicker(60 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
n.bootstrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) findNode(addr *net.UDPAddr, target []byte) {
|
||||||
|
if len(target) != 20 {
|
||||||
|
target = n.selfID
|
||||||
|
}
|
||||||
|
msg := map[string]any{"t": "fn", "y": "q", "q": "find_node",
|
||||||
|
"a": map[string]any{"id": n.selfID, "target": string(target)}}
|
||||||
|
_, _ = n.conn.WriteToUDP(bEncode(msg), addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DHTNode) Close() { _ = n.conn.Close() }
|
||||||
|
|
||||||
|
var _ = binary.BigEndian
|
||||||
3
services/dht/go.mod
Normal file
3
services/dht/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module gapmind/dht
|
||||||
|
|
||||||
|
go 1.23
|
||||||
320
services/dht/main.go
Normal file
320
services/dht/main.go
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var builtInTrackers = []string{
|
||||||
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
|
"udp://open.stealth.si:80/announce",
|
||||||
|
"udp://tracker.torrent.eu.org:451/announce",
|
||||||
|
}
|
||||||
|
|
||||||
|
var builtInRSS = []string{
|
||||||
|
"https://eztv.re/showlist/",
|
||||||
|
"https://nyaa.si/?page=rss",
|
||||||
|
"https://www.torrentdownloads.me/rss.xml",
|
||||||
|
"https://fosstorrents.com/feed/rss.xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryList(envURL, fallbackEnv string, builtIn []string) []string {
|
||||||
|
if u := os.Getenv(envURL); u != "" {
|
||||||
|
if req, err := http.NewRequest(http.MethodGet, u, nil); err == nil {
|
||||||
|
if resp, err := http.DefaultClient.Do(req); err == nil {
|
||||||
|
var list []string
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&list) == nil && len(list) > 0 {
|
||||||
|
resp.Body.Close()
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := os.Getenv(fallbackEnv); raw != "" {
|
||||||
|
return strings.Split(raw, ",")
|
||||||
|
}
|
||||||
|
return builtIn
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
central := flag.String("central", envOr("CENTRAL_URL", "http://127.0.0.1:3000"), "central gapmind URL")
|
||||||
|
interval := flag.Duration("interval", 10*time.Minute, "RSS re-ping interval")
|
||||||
|
listen := flag.String("listen", envOr("DHT_LISTEN", ":6881"), "UDP listen addr for passive DHT node")
|
||||||
|
passive := flag.Bool("passive", true, "run passive DHT get_peers/announce_peer sniffer")
|
||||||
|
flag.Parse()
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
names := map[string]string{}
|
||||||
|
|
||||||
|
pingRSS := func() []string {
|
||||||
|
rss := registryList("RSS_REGISTRY_URL", "RSS_FEEDS", builtInRSS)
|
||||||
|
found := scrapeRSSNames(client, rss, names)
|
||||||
|
trackers := registryList("TRACKER_REGISTRY_URL", "TRACKERS", builtInTrackers)
|
||||||
|
|
||||||
|
raw := make([][]byte, 0, len(found))
|
||||||
|
for _, h := range found {
|
||||||
|
if b, err := hex.DecodeString(h); err == nil {
|
||||||
|
raw = append(raw, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta := map[string]TrackerMeta{}
|
||||||
|
for _, t := range trackers {
|
||||||
|
if u, err := url.Parse(t); err == nil && u.Scheme == "udp" {
|
||||||
|
for k, v := range udpScrape(u.Host, raw) {
|
||||||
|
meta[k] = v
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range found {
|
||||||
|
m := meta[h]
|
||||||
|
postJSON(client, *central+"/api/dht/torrents", map[string]any{
|
||||||
|
"infoHash": h, "name": names[h], "source": "rss-ping+tracker-scrape",
|
||||||
|
"seeders": m.Seeders, "leechers": m.Leechers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
if *passive {
|
||||||
|
node, err := ListenDHT(*listen, func(ev SniffEvent) {
|
||||||
|
|
||||||
|
trackers := registryList("TRACKER_REGISTRY_URL", "TRACKERS", builtInTrackers)
|
||||||
|
raw, _ := hex.DecodeString(ev.InfoHash)
|
||||||
|
counts := TrackerMeta{}
|
||||||
|
for _, t := range trackers {
|
||||||
|
if u, err := url.Parse(t); err == nil && u.Scheme == "udp" {
|
||||||
|
if m, ok := udpScrape(u.Host, [][]byte{raw})[ev.InfoHash]; ok {
|
||||||
|
counts = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postJSON(client, *central+"/api/dht/torrents", map[string]any{
|
||||||
|
"infoHash": ev.InfoHash, "name": names[ev.InfoHash],
|
||||||
|
"source": "dht-sniff:" + ev.Query,
|
||||||
|
"seeders": counts.Seeders, "leechers": counts.Leechers,
|
||||||
|
})
|
||||||
|
postJSON(client, *central+"/api/dht/peers", map[string]any{
|
||||||
|
"infoHash": ev.InfoHash,
|
||||||
|
"peers": []map[string]any{{"address": ev.IP, "port": ev.Port, "client": "dht-" + ev.Query}},
|
||||||
|
})
|
||||||
|
fmt.Printf("sniff %s %s %s:%d seeders=%d\n", ev.Query, ev.InfoHash, ev.IP, ev.Port, counts.Seeders)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("dht listen failed:", err)
|
||||||
|
} else {
|
||||||
|
defer node.Close()
|
||||||
|
fmt.Println("dht passive listener on", *listen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
hashes := pingRSS()
|
||||||
|
_ = hashes
|
||||||
|
time.Sleep(*interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrapeRSSNames(client *http.Client, feeds []string, names map[string]string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, f := range feeds {
|
||||||
|
resp, err := client.Get(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
resp.Body.Close()
|
||||||
|
text := strings.ReplaceAll(string(body), "\"", " ")
|
||||||
|
for _, tok := range strings.Fields(text) {
|
||||||
|
lower := strings.ToLower(tok)
|
||||||
|
if i := strings.Index(lower, "btih:"); i >= 0 {
|
||||||
|
h := lower[i+5 : min(i+45, len(lower))]
|
||||||
|
if len(h) == 40 && isHex(h) && !seen[h] {
|
||||||
|
seen[h] = true
|
||||||
|
|
||||||
|
if j := strings.Index(tok, "dn="); j >= 0 {
|
||||||
|
if name, err := url.QueryUnescape(strings.TrimRight(tok[j+3:], "&")); err == nil && name != "" && names[h] == "" {
|
||||||
|
names[h] = name[:min(len(name), 200)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tok) == 40 && isHex(strings.ToLower(tok)) {
|
||||||
|
seen[strings.ToLower(tok)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for h := range seen {
|
||||||
|
out = append(out, h)
|
||||||
|
if len(out) >= 50 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHex(s string) bool { _, err := hex.DecodeString(s); return err == nil }
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
func envOr(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func crawlTrackers(infoHash string, trackers []string) []map[string]any {
|
||||||
|
var peers []map[string]any
|
||||||
|
raw, _ := hex.DecodeString(infoHash)
|
||||||
|
for _, t := range trackers {
|
||||||
|
u, err := url.Parse(t)
|
||||||
|
if err != nil || u.Scheme != "udp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got := udpAnnounce(u.Host, raw); len(got) > 0 {
|
||||||
|
peers = append(peers, got...)
|
||||||
|
}
|
||||||
|
if len(peers) >= 100 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|
||||||
|
func udpAnnounce(host string, infoHash []byte) []map[string]any {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", host)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, err := net.DialUDP("udp", nil, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
transID := uint32(time.Now().UnixNano())
|
||||||
|
var req bytes.Buffer
|
||||||
|
_ = binary.Write(&req, binary.BigEndian, uint64(0x41727101980))
|
||||||
|
_ = binary.Write(&req, binary.BigEndian, uint32(0))
|
||||||
|
_ = binary.Write(&req, binary.BigEndian, transID)
|
||||||
|
if _, err := conn.Write(req.Bytes()); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp := make([]byte, 16)
|
||||||
|
n, err := conn.Read(resp)
|
||||||
|
if err != nil || n < 16 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
connID := resp[8:16]
|
||||||
|
peerID := make([]byte, 20)
|
||||||
|
copy(peerID, []byte("-GM0001-123456789012"))
|
||||||
|
var ann bytes.Buffer
|
||||||
|
ann.Write(connID)
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint32(1))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, transID+1)
|
||||||
|
ann.Write(infoHash)
|
||||||
|
ann.Write(peerID)
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint64(0))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint64(0))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint64(0))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, int32(-1))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint32(0))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint32(0))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, int32(-1))
|
||||||
|
_ = binary.Write(&ann, binary.BigEndian, uint16(6881))
|
||||||
|
if _, err := conn.Write(ann.Bytes()); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
n, err = conn.Read(buf)
|
||||||
|
if err != nil || n < 20 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []map[string]any
|
||||||
|
for i := 20; i+6 <= n; i += 6 {
|
||||||
|
ip := net.IP(buf[i : i+4])
|
||||||
|
port := int(binary.BigEndian.Uint16(buf[i+4 : i+6]))
|
||||||
|
out = append(out, map[string]any{"address": ip.String(), "port": port})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func handshakeAll(infoHash string, peers []map[string]any) []map[string]any {
|
||||||
|
raw, _ := hex.DecodeString(infoHash)
|
||||||
|
var out []map[string]any
|
||||||
|
for _, p := range peers {
|
||||||
|
host, _ := p["address"].(string)
|
||||||
|
var port int
|
||||||
|
switch v := p["port"].(type) {
|
||||||
|
case int:
|
||||||
|
port = v
|
||||||
|
case float64:
|
||||||
|
port = int(v)
|
||||||
|
}
|
||||||
|
client := handshakeOne(host, port, raw)
|
||||||
|
entry := map[string]any{"address": host, "port": port}
|
||||||
|
if client != "" {
|
||||||
|
entry["client"] = client
|
||||||
|
}
|
||||||
|
out = append(out, entry)
|
||||||
|
if len(out) >= 100 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func handshakeOne(host string, port int, infoHash []byte) string {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 4*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
peerID := []byte("-GM0001-123456789012")
|
||||||
|
hs := append([]byte{19}, []byte("BitTorrent protocol")...)
|
||||||
|
hs = append(hs, []byte{0, 0, 0, 0, 0, 0x10, 0, 0}...)
|
||||||
|
hs = append(hs, infoHash...)
|
||||||
|
hs = append(hs, peerID...)
|
||||||
|
if _, err := conn.Write(hs); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
resp := make([]byte, 68+256)
|
||||||
|
n, err := conn.Read(resp)
|
||||||
|
if err != nil || n < 68 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
peerStr := string(resp[48:68])
|
||||||
|
return strings.Trim(peerStr, "\x00 ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(client *http.Client, endpoint string, body any) {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if tok := os.Getenv("DHT_API_TOKEN"); tok != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
80
services/dht/tracker.go
Normal file
80
services/dht/tracker.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackerMeta struct {
|
||||||
|
Seeders int
|
||||||
|
Leechers int
|
||||||
|
Completed int
|
||||||
|
}
|
||||||
|
|
||||||
|
func udpScrape(host string, infoHashes [][]byte) map[string]TrackerMeta {
|
||||||
|
out := map[string]TrackerMeta{}
|
||||||
|
if len(infoHashes) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", host)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
conn, err := net.DialUDP("udp", nil, addr)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(6 * time.Second))
|
||||||
|
transID := uint32(time.Now().UnixNano() & 0x7fffffff)
|
||||||
|
var req bytes.Buffer
|
||||||
|
_ = binary.Write(&req, binary.BigEndian, uint64(0x41727101980))
|
||||||
|
_ = binary.Write(&req, binary.BigEndian, uint32(0))
|
||||||
|
_ = binary.Write(&req, binary.BigEndian, transID)
|
||||||
|
if _, err := conn.Write(req.Bytes()); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
resp := make([]byte, 16)
|
||||||
|
n, err := conn.Read(resp)
|
||||||
|
if err != nil || n < 16 || binary.BigEndian.Uint32(resp[4:8]) != transID {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
connID := resp[8:16]
|
||||||
|
if len(infoHashes) > 70 {
|
||||||
|
infoHashes = infoHashes[:70]
|
||||||
|
}
|
||||||
|
var sc bytes.Buffer
|
||||||
|
sc.Write(connID)
|
||||||
|
_ = binary.Write(&sc, binary.BigEndian, uint32(2))
|
||||||
|
_ = binary.Write(&sc, binary.BigEndian, transID+1)
|
||||||
|
for _, h := range infoHashes {
|
||||||
|
if len(h) == 20 {
|
||||||
|
sc.Write(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(sc.Bytes()); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
buf := make([]byte, 8+12*74)
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(6 * time.Second))
|
||||||
|
n, err = conn.Read(buf)
|
||||||
|
if err != nil || n < 8 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
off := 8
|
||||||
|
for i, h := range infoHashes {
|
||||||
|
if off+12 > n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = i
|
||||||
|
seeders := int(binary.BigEndian.Uint32(buf[off : off+4]))
|
||||||
|
done := int(binary.BigEndian.Uint32(buf[off+4 : off+8]))
|
||||||
|
leech := int(binary.BigEndian.Uint32(buf[off+8 : off+12]))
|
||||||
|
out[hex.EncodeToString(h)] = TrackerMeta{Seeders: seeders, Leechers: leech, Completed: done}
|
||||||
|
off += 12
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
1
services/orchestra/.gitignore
vendored
Normal file
1
services/orchestra/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
orchestra.json
|
||||||
3
services/orchestra/go.mod
Normal file
3
services/orchestra/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module crit.rip/gapmind/services/orchestra
|
||||||
|
|
||||||
|
go 1.26.7
|
||||||
451
services/orchestra/main.go
Normal file
451
services/orchestra/main.go
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crit.rip/gapmind/services/orchestra/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Target struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Binary string `json:"binary"`
|
||||||
|
RemoteDir string `json:"remoteDir"`
|
||||||
|
CentralURL string `json:"centralURL"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
SSHPassword string `json:"sshPassword"`
|
||||||
|
PublicURL string `json:"publicURL"`
|
||||||
|
Latitude string `json:"latitude"`
|
||||||
|
Longitude string `json:"longitude"`
|
||||||
|
TimeoutSec int `json:"timeoutSeconds"`
|
||||||
|
DHTListenPort int `json:"dhtListenPort"`
|
||||||
|
DHTPassive *bool `json:"dhtPassive"`
|
||||||
|
RSSInterval string `json:"rssInterval"`
|
||||||
|
Trackers string `json:"trackers"`
|
||||||
|
RSSFeeds string `json:"rssFeeds"`
|
||||||
|
DHTAPIToken string `json:"dhtApiToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Target) nodeId() string {
|
||||||
|
if t.Service == "dht" {
|
||||||
|
port := t.DHTListenPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 6881
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", t.Host, port)
|
||||||
|
}
|
||||||
|
return t.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Target) resolvedNodeId() string {
|
||||||
|
host := sshHostname(t)
|
||||||
|
if host == "" {
|
||||||
|
host = t.Host
|
||||||
|
}
|
||||||
|
if t.Service == "dht" {
|
||||||
|
port := t.DHTListenPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 6881
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", host, port)
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshClientFor(t Target) (*service.SSHClient, error) {
|
||||||
|
remote := t.Host
|
||||||
|
if t.User != "" {
|
||||||
|
remote = t.User + "@" + t.Host
|
||||||
|
}
|
||||||
|
password := t.SSHPassword
|
||||||
|
if password == "" {
|
||||||
|
password = t.Token
|
||||||
|
}
|
||||||
|
client, err := service.NewSSHClient(remote, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t.TimeoutSec > 0 {
|
||||||
|
client.SetTimeout(time.Duration(t.TimeoutSec) * time.Second)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshHostname(t Target) string {
|
||||||
|
client, err := sshClientFor(t)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
out, err := client.Run("ssh", client.Remote(), "hostname", "-s")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(string(out))
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Targets []Target `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerUpdate struct {
|
||||||
|
NodeId string `json:"nodeId"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) Config {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
var config Config
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterTargets(targets []Target, only string) []Target {
|
||||||
|
if only == "" {
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
filter := map[string]bool{}
|
||||||
|
for _, s := range strings.Split(only, ",") {
|
||||||
|
filter[strings.TrimSpace(s)] = true
|
||||||
|
}
|
||||||
|
var out []Target
|
||||||
|
for _, t := range targets {
|
||||||
|
if filter[t.Service] {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "orchestra.json", "path to deployment config")
|
||||||
|
only := flag.String("service", "", "filter to services, comma-separated (e.g. dht,pathfinder)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
args := flag.Args()
|
||||||
|
if len(args) == 0 {
|
||||||
|
fail(fmt.Errorf("usage: orchestra [flags] deploy|sync|nuke"))
|
||||||
|
}
|
||||||
|
subcommand := args[0]
|
||||||
|
config := loadConfig(*configPath)
|
||||||
|
targets := filterTargets(config.Targets, *only)
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "deploy":
|
||||||
|
cmdDeploy(targets)
|
||||||
|
case "sync":
|
||||||
|
fs := flag.NewFlagSet("sync", flag.ExitOnError)
|
||||||
|
restart := fs.Bool("restart", false, "restart remote services after metadata sync")
|
||||||
|
fs.Parse(args[1:])
|
||||||
|
cmdSync(targets, *restart)
|
||||||
|
case "nuke":
|
||||||
|
fs := flag.NewFlagSet("nuke", flag.ExitOnError)
|
||||||
|
confirm := fs.Bool("yes", false, "confirm destructive nuke without prompting")
|
||||||
|
redeploy := fs.Bool("redeploy", true, "redeploy all targets after nuking")
|
||||||
|
fs.Parse(args[1:])
|
||||||
|
cmdNuke(targets, *confirm, *redeploy)
|
||||||
|
default:
|
||||||
|
fail(fmt.Errorf("unknown subcommand %q: use deploy, sync, or nuke", subcommand))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdDeploy(targets []Target) {
|
||||||
|
for _, target := range targets {
|
||||||
|
deploy(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdNuke(targets []Target, confirm, redeploy bool) {
|
||||||
|
if len(targets) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "no targets to nuke")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !confirm {
|
||||||
|
fmt.Fprintf(os.Stderr, "nuke: will stop + wipe %d targets and local caches. Re-run with --yes to confirm.\n", len(targets))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
nukeRemote(t)
|
||||||
|
}
|
||||||
|
nukeLocal()
|
||||||
|
if redeploy {
|
||||||
|
fmt.Println("nuke: redeploying network")
|
||||||
|
cmdDeploy(targets)
|
||||||
|
}
|
||||||
|
fmt.Println("nuke: done")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nukeRemote(t Target) {
|
||||||
|
client, err := sshClientFor(t)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "nuke: SSH connect failed for %s: %v\n", t.Host, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
unit := "gapmind-" + t.Service + ".service"
|
||||||
|
remoteDir := t.RemoteDir
|
||||||
|
if remoteDir == "" {
|
||||||
|
remoteDir = ".local/share/gapmind"
|
||||||
|
}
|
||||||
|
remoteBinary := strings.TrimSuffix(remoteDir, "/") + "/gapmind-" + t.Service
|
||||||
|
|
||||||
|
|
||||||
|
script := fmt.Sprintf(
|
||||||
|
`set +e; runtime=/run/user/$(id -u); export XDG_RUNTIME_DIR=$runtime DBUS_SESSION_BUS_ADDRESS=unix:path=$runtime/bus; `+
|
||||||
|
`systemctl --user stop %[1]q >/dev/null 2>&1; systemctl --user disable %[1]q >/dev/null 2>&1; `+
|
||||||
|
`rm -f .config/systemd/user/%[1]q; systemctl --user daemon-reload >/dev/null 2>&1; `+
|
||||||
|
`rm -f %[2]q %[2]q.upload; rm -rf %[3]q; rm -rf .local/share/gapmind/%[4]q*; echo NUKED`,
|
||||||
|
unit, remoteBinary, strings.TrimSuffix(remoteDir, "/"), t.Service,
|
||||||
|
)
|
||||||
|
out, err := client.Run("ssh", client.Remote(), "sh", "-lc", script)
|
||||||
|
fmt.Printf("nuke: %s@%s %s\n", t.Service, t.Host, strings.TrimSpace(string(out)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "nuke: remote wipe had errors on %s: %v\n", t.Host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nukeLocal() {
|
||||||
|
|
||||||
|
paths := []string{"gapmind.sqlite", "gapmind.sqlite-wal", "gapmind.sqlite-shm", "gapmind.sqlite-journal", "dist", "clients/typescript/gapmind.ts", "clients/rust/src/lib.rs"}
|
||||||
|
for _, p := range paths {
|
||||||
|
if err := os.RemoveAll(p); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "nuke: could not remove %s: %v\n", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("nuke: removed %s\n", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deploy(target Target) {
|
||||||
|
if target.Host == "" || target.Service == "" || target.Binary == "" {
|
||||||
|
fail(fmt.Errorf("each target needs host, service, and binary"))
|
||||||
|
}
|
||||||
|
if target.Token == "" && target.Service != "dht" {
|
||||||
|
fail(fmt.Errorf("target %s has no shared peer token", target.Host))
|
||||||
|
}
|
||||||
|
if target.Service != "replica" && target.Service != "pathfinder" && target.Service != "dht" {
|
||||||
|
fail(fmt.Errorf("unknown service %q", target.Service))
|
||||||
|
}
|
||||||
|
remoteDir := target.RemoteDir
|
||||||
|
if remoteDir == "" {
|
||||||
|
remoteDir = ".local/share/gapmind"
|
||||||
|
}
|
||||||
|
remoteBinary := strings.TrimSuffix(remoteDir, "/") + "/gapmind-" + target.Service
|
||||||
|
execStart := remoteBinary
|
||||||
|
if target.Service == "dht" {
|
||||||
|
port := target.DHTListenPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 6881
|
||||||
|
}
|
||||||
|
passive := true
|
||||||
|
if target.DHTPassive != nil {
|
||||||
|
passive = *target.DHTPassive
|
||||||
|
}
|
||||||
|
interval := target.RSSInterval
|
||||||
|
if interval == "" {
|
||||||
|
interval = "10m"
|
||||||
|
}
|
||||||
|
execStart = fmt.Sprintf("%s --listen :%d --passive=%t --interval %s", remoteBinary, port, passive, interval)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(execStart, "/") {
|
||||||
|
execStart = "%h/" + strings.TrimPrefix(execStart, "./")
|
||||||
|
}
|
||||||
|
remote := target.Host
|
||||||
|
if target.User != "" {
|
||||||
|
remote = target.User + "@" + target.Host
|
||||||
|
}
|
||||||
|
environment := map[string]string{"CENTRAL_URL": target.CentralURL, "PEER_API_TOKEN": target.Token, "PEER_PUBLIC_URL": target.PublicURL}
|
||||||
|
if target.Service == "pathfinder" {
|
||||||
|
environment["NODE_LATITUDE"] = target.Latitude
|
||||||
|
environment["NODE_LONGITUDE"] = target.Longitude
|
||||||
|
}
|
||||||
|
if target.Service == "dht" {
|
||||||
|
port := target.DHTListenPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 6881
|
||||||
|
}
|
||||||
|
environment["DHT_LISTEN"] = fmt.Sprintf(":%d", port)
|
||||||
|
if target.Trackers != "" {
|
||||||
|
environment["TRACKERS"] = target.Trackers
|
||||||
|
}
|
||||||
|
if target.RSSFeeds != "" {
|
||||||
|
environment["RSS_FEEDS"] = target.RSSFeeds
|
||||||
|
}
|
||||||
|
if target.DHTAPIToken != "" {
|
||||||
|
environment["DHT_API_TOKEN"] = target.DHTAPIToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
password := target.SSHPassword
|
||||||
|
if password == "" {
|
||||||
|
password = target.Token
|
||||||
|
}
|
||||||
|
unit := service.UserUnit{Description: "Gapmind " + target.Service, ExecStart: execStart, Environment: environment}
|
||||||
|
client, err := service.NewSSHClient(remote, password)
|
||||||
|
if err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if target.TimeoutSec > 0 {
|
||||||
|
client.SetTimeout(time.Duration(target.TimeoutSec) * time.Second)
|
||||||
|
}
|
||||||
|
fmt.Printf("Deploying %s to %s\n", target.Service, remote)
|
||||||
|
if err := service.CopyAndInstall(client, target.Binary, remoteBinary, "gapmind-"+target.Service+".service", unit); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdSync(targets []Target, restart bool) {
|
||||||
|
if len(targets) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "no targets to sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
|
||||||
|
var updates []peerUpdate
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.Service != "pathfinder" && t.Service != "dht" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta := map[string]interface{}{}
|
||||||
|
if t.Latitude != "" {
|
||||||
|
meta["latitude"] = t.Latitude
|
||||||
|
}
|
||||||
|
if t.Longitude != "" {
|
||||||
|
meta["longitude"] = t.Longitude
|
||||||
|
}
|
||||||
|
if t.Service == "pathfinder" {
|
||||||
|
meta["transport"] = "outbound-relay"
|
||||||
|
}
|
||||||
|
if t.Service == "dht" {
|
||||||
|
port := t.DHTListenPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 6881
|
||||||
|
}
|
||||||
|
meta["dhtListenPort"] = port
|
||||||
|
meta["dhtPassive"] = true
|
||||||
|
if t.DHTPassive != nil {
|
||||||
|
meta["dhtPassive"] = *t.DHTPassive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta["assembled"] = true
|
||||||
|
meta["assembledAt"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
updates = append(updates, peerUpdate{
|
||||||
|
NodeId: t.resolvedNodeId(),
|
||||||
|
Metadata: meta,
|
||||||
|
Kind: t.Service,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
fmt.Println("nothing to sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.CentralURL != "" {
|
||||||
|
syncDB(client, t.CentralURL, t.Token, updates)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if restart {
|
||||||
|
for _, t := range targets {
|
||||||
|
restartRemote(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncDB(client *http.Client, centralURL, token string, updates []peerUpdate) {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"updates": updates})
|
||||||
|
req, _ := http.NewRequest("POST", strings.TrimRight(centralURL, "/")+"/api/peers/metadata", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "sync: central API request failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
fmt.Fprintf(os.Stderr, "sync: central API returned HTTP %d: %s\n", resp.StatusCode, string(respBody))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Applied int `json:"applied"`
|
||||||
|
Rejected []struct {
|
||||||
|
NodeId string `json:"nodeId"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
} `json:"rejected"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "sync: could not parse response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("sync: %d peers updated, %d rejected\n", result.Applied, len(result.Rejected))
|
||||||
|
for _, r := range result.Rejected {
|
||||||
|
fmt.Fprintf(os.Stderr, " rejected %s: %s\n", r.NodeId, r.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RestartUnit(client *service.SSHClient, unitName string) error {
|
||||||
|
if _, err := client.Run("sudo systemctl daemon-reload"); err != nil {
|
||||||
|
return fmt.Errorf("daemon-reload failed: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := client.Run("sudo systemctl restart " + unitName); err != nil {
|
||||||
|
return fmt.Errorf("restart failed: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := client.Run("sudo systemctl enable " + unitName); err != nil {
|
||||||
|
return fmt.Errorf("enable failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartRemote(target Target) {
|
||||||
|
remote := target.Host
|
||||||
|
if target.User != "" {
|
||||||
|
remote = target.User + "@" + target.Host
|
||||||
|
}
|
||||||
|
password := target.SSHPassword
|
||||||
|
if password == "" {
|
||||||
|
password = target.Token
|
||||||
|
}
|
||||||
|
client, err := service.NewSSHClient(remote, password)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "restart: SSH connect failed for %s: %v\n", target.Host, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if target.TimeoutSec > 0 {
|
||||||
|
client.SetTimeout(time.Duration(target.TimeoutSec) * time.Second)
|
||||||
|
}
|
||||||
|
serviceName := "gapmind-" + target.Service + ".service"
|
||||||
|
fmt.Printf("restart: restarting %s on %s\n", serviceName, target.Host)
|
||||||
|
if err := RestartUnit(client, serviceName); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "restart: %s failed on %s: %v\n", serviceName, target.Host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) { fmt.Fprintln(os.Stderr, "orchestra:", err); os.Exit(1) }
|
||||||
62
services/orchestra/orchestra.example.json
Normal file
62
services/orchestra/orchestra.example.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"host": "192.168.1.1",
|
||||||
|
"user": "gapmind",
|
||||||
|
"service": "pathfinder",
|
||||||
|
"binary": "services/pathfinder/pathfinder",
|
||||||
|
"remoteDir": ".local/share/gapmind",
|
||||||
|
"centralURL": "https://reg.crit.rip",
|
||||||
|
"publicURL": "",
|
||||||
|
"latitude": "45.5229",
|
||||||
|
"longitude": "-122.9898",
|
||||||
|
"timeoutSeconds": 90,
|
||||||
|
"token": "abcxyz",
|
||||||
|
"sshPassword": "xyzabc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"host": "192.168.1.2",
|
||||||
|
"user": "gapmind",
|
||||||
|
"service": "pathfinder",
|
||||||
|
"binary": "services/pathfinder/pathfinder",
|
||||||
|
"remoteDir": ".local/share/gapmind",
|
||||||
|
"centralURL": "https://reg.crit.rip",
|
||||||
|
"publicURL": "",
|
||||||
|
"latitude": "43.8041",
|
||||||
|
"longitude": "-120.5542",
|
||||||
|
"timeoutSeconds": 90,
|
||||||
|
"token": "abcxyz",
|
||||||
|
"sshPassword": "xyzabc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"host": "192.168.1.3",
|
||||||
|
"user": "gapmind",
|
||||||
|
"service": "dht",
|
||||||
|
"binary": "services/dht/dht",
|
||||||
|
"remoteDir": ".local/share/gapmind",
|
||||||
|
"centralURL": "https://reg.crit.rip",
|
||||||
|
"timeoutSeconds": 90,
|
||||||
|
"token": "",
|
||||||
|
"sshPassword": "xyzabc",
|
||||||
|
"dhtListenPort": 6881,
|
||||||
|
"dhtPassive": true,
|
||||||
|
"rssInterval": "10m",
|
||||||
|
"trackers": "udp://tracker.opentrackr.org:1337/announce,udp://open.stealth.si:80/announce",
|
||||||
|
"rssFeeds": "https://nyaa.si/?page=rss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"host": "192.168.1.4",
|
||||||
|
"user": "gapmind",
|
||||||
|
"service": "dht",
|
||||||
|
"binary": "services/dht/dht",
|
||||||
|
"remoteDir": ".local/share/gapmind",
|
||||||
|
"centralURL": "https://reg.crit.rip",
|
||||||
|
"timeoutSeconds": 90,
|
||||||
|
"token": "",
|
||||||
|
"sshPassword": "xyzabc",
|
||||||
|
"dhtListenPort": 6882,
|
||||||
|
"dhtPassive": true,
|
||||||
|
"rssInterval": "10m"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
services/orchestra/service/daemon.go
Normal file
7
services/orchestra/service/daemon.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
type UserUnit struct {
|
||||||
|
Description string
|
||||||
|
ExecStart string
|
||||||
|
Environment map[string]string
|
||||||
|
}
|
||||||
145
services/orchestra/service/systemd.go
Normal file
145
services/orchestra/service/systemd.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultCommandTimeout = 45 * time.Second
|
||||||
|
|
||||||
|
type SSHClient struct {
|
||||||
|
remote string
|
||||||
|
askPassPath string
|
||||||
|
password string
|
||||||
|
sshConfig string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSHClient(remote, password string) (*SSHClient, error) {
|
||||||
|
if remote == "" {
|
||||||
|
return nil, fmt.Errorf("remote host is required")
|
||||||
|
}
|
||||||
|
sshConfig := "/dev/null"
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
candidate := filepath.Join(home, ".ssh", "config")
|
||||||
|
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
|
||||||
|
sshConfig = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := &SSHClient{remote: remote, password: password, sshConfig: sshConfig, timeout: defaultCommandTimeout}
|
||||||
|
if password == "" {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp("", "gapmind-ssh-askpass-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create SSH password helper: %w", err)
|
||||||
|
}
|
||||||
|
client.askPassPath = file.Name()
|
||||||
|
if _, err := file.WriteString("#!/bin/sh\nprintf '%s\\n' \"$GAPMIND_SSH_PASSWORD\"\n"); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(client.askPassPath, 0700); err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHClient) Close() {
|
||||||
|
if c.askPassPath != "" {
|
||||||
|
_ = os.Remove(c.askPassPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHClient) SetTimeout(timeout time.Duration) {
|
||||||
|
if timeout > 0 {
|
||||||
|
c.timeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHClient) Remote() string {
|
||||||
|
return c.remote
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHClient) command(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||||
|
connectionOptions := []string{
|
||||||
|
"-F", c.sshConfig,
|
||||||
|
"-o", "ConnectTimeout=12",
|
||||||
|
"-o", "ConnectionAttempts=1",
|
||||||
|
"-o", "ServerAliveInterval=10",
|
||||||
|
"-o", "ServerAliveCountMax=2",
|
||||||
|
"-o", "NumberOfPasswordPrompts=1",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ClearAllForwardings=yes",
|
||||||
|
}
|
||||||
|
args = append(connectionOptions, args...)
|
||||||
|
if c.password != "" {
|
||||||
|
args = append([]string{"-o", "BatchMode=no", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no"}, args...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
if c.password != "" {
|
||||||
|
cmd.Env = append(os.Environ(), "SSH_ASKPASS="+c.askPassPath, "SSH_ASKPASS_REQUIRE=force", "DISPLAY=gapmind:0", "GAPMIND_SSH_PASSWORD="+c.password)
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHClient) Run(name string, args ...string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
output, err := c.command(ctx, name, args...).CombinedOutput()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return output, fmt.Errorf("%s timed out after %s", name, c.timeout)
|
||||||
|
}
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u UserUnit) Render() string {
|
||||||
|
lines := []string{"[Unit]", "Description=" + u.Description, "After=network-online.target", "", "[Service]", "Type=simple", "Restart=on-failure", "RestartSec=5"}
|
||||||
|
keys := make([]string, 0, len(u.Environment))
|
||||||
|
for key := range u.Environment {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := u.Environment[key]; value != "" {
|
||||||
|
lines = append(lines, "Environment="+key+"="+systemdQuote(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, "ExecStart="+u.ExecStart, "", "[Install]", "WantedBy=default.target", "")
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemdQuote(value string) string {
|
||||||
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", "").Replace(value) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyAndInstall(client *SSHClient, localBinary, remoteBinary, unitName string, unit UserUnit) error {
|
||||||
|
if output, err := client.Run("ssh", client.remote, "mkdir", "-p", path.Dir(remoteBinary), ".config/systemd/user"); err != nil {
|
||||||
|
return fmt.Errorf("create remote directories: %w: %s", err, output)
|
||||||
|
}
|
||||||
|
temporaryBinary := remoteBinary + ".upload"
|
||||||
|
if output, err := client.Run("scp", localBinary, client.remote+":"+temporaryBinary); err != nil {
|
||||||
|
return fmt.Errorf("copy binary: %w: %s", err, output)
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(unit.Render()))
|
||||||
|
remoteUnit := ".config/systemd/user/" + unitName
|
||||||
|
command := fmt.Sprintf("set -eu; user=$(id -un); loginctl enable-linger $user >/dev/null 2>&1 || true; runtime=/run/user/$(id -u); export XDG_RUNTIME_DIR=$runtime DBUS_SESSION_BUS_ADDRESS=unix:path=$runtime/bus; for attempt in 1 2 3 4 5; do test -S $runtime/bus && break; sleep 1; done; test -S $runtime/bus || { echo 'systemd user bus is unavailable after enabling lingering' >&2; exit 1; }; test ! -d %q || { echo 'deployment target is a directory: %s' >&2; exit 1; }; chmod +x %q; mv -f %q %q; printf %%s %q | base64 -d > %q; systemctl --user daemon-reload; systemctl --user enable --now %q; systemctl --user --no-pager is-active %q", remoteBinary, remoteBinary, temporaryBinary, temporaryBinary, remoteBinary, encoded, remoteUnit, unitName, unitName)
|
||||||
|
if output, err := client.Run("ssh", client.remote, "sh", "-lc", command); err != nil {
|
||||||
|
return fmt.Errorf("install systemd unit: %w: %s", err, output)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
3
services/pathfinder/go.mod
Normal file
3
services/pathfinder/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module crit.rip/gapmind/services/pathfinder
|
||||||
|
|
||||||
|
go 1.26.7
|
||||||
196
services/pathfinder/main.go
Normal file
196
services/pathfinder/main.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Probe struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Milliseconds float64 `json:"milliseconds"`
|
||||||
|
}
|
||||||
|
type Estimate struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
Samples int `json:"samples"`
|
||||||
|
}
|
||||||
|
type relayCommand struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
}
|
||||||
|
type relayEnvelope struct {
|
||||||
|
Command *relayCommand `json:"command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("listen", "127.0.0.1:8092", "HTTP listen address")
|
||||||
|
flag.Parse()
|
||||||
|
go heartbeat()
|
||||||
|
go relay()
|
||||||
|
http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||||
|
http.HandleFunc("/probe", probe)
|
||||||
|
http.HandleFunc("/triangulate", triangulate)
|
||||||
|
fmt.Println(http.ListenAndServe(*listen, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func probe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.URL.Query().Get("host")
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, "host is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port := r.URL.Query().Get("port")
|
||||||
|
if port == "" {
|
||||||
|
port = "443"
|
||||||
|
}
|
||||||
|
milliseconds, err := measure(host, port)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"host": host, "milliseconds": milliseconds})
|
||||||
|
}
|
||||||
|
|
||||||
|
func measure(host, port string) (float64, error) {
|
||||||
|
started := time.Now()
|
||||||
|
connection, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
_ = connection.Close()
|
||||||
|
return float64(time.Since(started).Microseconds()) / 1000, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func triangulate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var probes []Probe
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&probes); err != nil || len(probes) < 2 {
|
||||||
|
http.Error(w, "at least two valid probes are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
valid := make([]Probe, 0, len(probes))
|
||||||
|
for _, probe := range probes {
|
||||||
|
if probe.Latitude >= -90 && probe.Latitude <= 90 && probe.Longitude >= -180 && probe.Longitude <= 180 && probe.Milliseconds > 0 {
|
||||||
|
valid = append(valid, probe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(valid) < 2 {
|
||||||
|
http.Error(w, "at least two valid probes are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(weighted(valid))
|
||||||
|
}
|
||||||
|
|
||||||
|
func weighted(probes []Probe) Estimate {
|
||||||
|
var latitude, longitude, totalWeight float64
|
||||||
|
values := make([]float64, len(probes))
|
||||||
|
for index, probe := range probes {
|
||||||
|
weight := 1 / math.Max(probe.Milliseconds, 1)
|
||||||
|
latitude += probe.Latitude * weight
|
||||||
|
longitude += probe.Longitude * weight
|
||||||
|
totalWeight += weight
|
||||||
|
values[index] = probe.Milliseconds
|
||||||
|
}
|
||||||
|
sort.Float64s(values)
|
||||||
|
median, variance := values[len(values)/2], 0.0
|
||||||
|
for _, value := range values {
|
||||||
|
variance += math.Pow(value-median, 2)
|
||||||
|
}
|
||||||
|
confidence := math.Max(0, math.Min(1, 1-math.Sqrt(variance/float64(len(values)))/math.Max(median, 1)))
|
||||||
|
return Estimate{Latitude: latitude / totalWeight, Longitude: longitude / totalWeight, Confidence: confidence, Samples: len(probes)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func heartbeat() {
|
||||||
|
central := os.Getenv("CENTRAL_URL")
|
||||||
|
if central == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
latitude, _ := strconv.ParseFloat(os.Getenv("NODE_LATITUDE"), 64)
|
||||||
|
longitude, _ := strconv.ParseFloat(os.Getenv("NODE_LONGITUDE"), 64)
|
||||||
|
for {
|
||||||
|
body, _ := json.Marshal(map[string]any{"nodeId": host, "kind": "pathfinder", "address": os.Getenv("PEER_PUBLIC_URL"), "version": "0.2.0", "status": "online", "metadata": map[string]any{"transport": "outbound-relay", "latitude": latitude, "longitude": longitude}})
|
||||||
|
request, _ := http.NewRequest(http.MethodPost, central+"/api/peers/heartbeat", bytes.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
if token := os.Getenv("PEER_API_TOKEN"); token != "" {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, response.Body)
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func relay() {
|
||||||
|
central := strings.TrimRight(os.Getenv("CENTRAL_URL"), "/")
|
||||||
|
if central == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
client := &http.Client{Timeout: 35 * time.Second}
|
||||||
|
for {
|
||||||
|
request, err := http.NewRequest(http.MethodGet, central+"/api/peers/commands?nodeId="+url.QueryEscape(host), nil)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token := os.Getenv("PEER_API_TOKEN"); token != "" {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil || response.StatusCode != http.StatusOK {
|
||||||
|
if response != nil {
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var envelope relayEnvelope
|
||||||
|
err = json.NewDecoder(response.Body).Decode(&envelope)
|
||||||
|
_ = response.Body.Close()
|
||||||
|
if err != nil || envelope.Command == nil || envelope.Command.Type != "probe" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
milliseconds, probeErr := measure(envelope.Command.Host, envelope.Command.Port)
|
||||||
|
result := map[string]any{"id": envelope.Command.ID}
|
||||||
|
if probeErr != nil {
|
||||||
|
result["error"] = probeErr.Error()
|
||||||
|
} else {
|
||||||
|
result["milliseconds"] = milliseconds
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(result)
|
||||||
|
request, err = http.NewRequest(http.MethodPost, central+"/api/peers/results", bytes.NewReader(body))
|
||||||
|
if err == nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
if token := os.Getenv("PEER_API_TOKEN"); token != "" {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
if response, err := client.Do(request); err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, response.Body)
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
services/replica/go.mod
Normal file
3
services/replica/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module crit.rip/gapmind/services/replica
|
||||||
|
|
||||||
|
go 1.26.7
|
||||||
79
services/replica/main.go
Normal file
79
services/replica/main.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type report struct {
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]any `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := flag.String("listen", ":8091", "HTTP listen address")
|
||||||
|
source := flag.String("source", ".", "directory permitted for replication")
|
||||||
|
flag.Parse()
|
||||||
|
root, err := filepath.Abs(*source)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
go heartbeat(root)
|
||||||
|
|
||||||
|
http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||||
|
http.HandleFunc("/replicate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Host == "" || request.Destination == "" || filepath.IsAbs(request.Destination) || filepath.Clean(request.Destination) == ".." {
|
||||||
|
http.Error(w, "host and relative destination required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output, err := exec.Command("scp", "-r", root+"/.", request.Host+":"+request.Destination).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, string(output), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
})
|
||||||
|
fmt.Println(http.ListenAndServe(*listen, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func heartbeat(root string) {
|
||||||
|
central := os.Getenv("CENTRAL_URL")
|
||||||
|
if central == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
for {
|
||||||
|
body, _ := json.Marshal(report{NodeID: host, Kind: "replica", Address: os.Getenv("PEER_PUBLIC_URL"), Version: "0.1.0", Status: "online", Metadata: map[string]any{"source": root}})
|
||||||
|
request, _ := http.NewRequest(http.MethodPost, central+"/api/peers/heartbeat", bytes.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
if token := os.Getenv("PEER_API_TOKEN"); token != "" {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, response.Body)
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
socmind
Submodule
1
socmind
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit ed929f6c3dabcf6c74078293c101e3bc7e72fd38
|
||||||
116
src/app.module.ts
Normal file
116
src/app.module.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ResolutionController } from './resolution.controller';
|
||||||
|
import { ResolutionService } from './services/resolution.service';
|
||||||
|
import { BgpService } from './services/routing/bgp.service';
|
||||||
|
import { NccService } from './services/routing/ncc.service';
|
||||||
|
import { AfrinicService } from './services/registry/afrinic.service';
|
||||||
|
import { ApnicService } from './services/registry/apnic.service';
|
||||||
|
import { ArinService } from './services/registry/arin.service';
|
||||||
|
import { LacnicService } from './services/registry/lacnic.service';
|
||||||
|
import { RipeService } from './services/registry/ripe.service';
|
||||||
|
import { GeonamesService } from './services/geonames.service';
|
||||||
|
import { HttpService } from './services/http.service';
|
||||||
|
import {
|
||||||
|
Account,
|
||||||
|
DhtPeer,
|
||||||
|
DhtTorrent,
|
||||||
|
GeoCalibration,
|
||||||
|
PeerNode,
|
||||||
|
ResolutionSnapshot,
|
||||||
|
ScanObservation,
|
||||||
|
FaviconFingerprint,
|
||||||
|
SweepJob,
|
||||||
|
} from './services/entities';
|
||||||
|
import { TriangulationService } from './services/triangulation.service';
|
||||||
|
import { MlGeolocationService } from './services/ml-geolocation.service';
|
||||||
|
import { AccountService } from './services/account.service';
|
||||||
|
import { GeoProvidersService } from './services/providers.service';
|
||||||
|
import { PeerRelayService } from './services/peer-relay.service';
|
||||||
|
import { BannerService } from './services/banner.service';
|
||||||
|
import { FaviconService } from './services/favicon.service';
|
||||||
|
import { CalibrationCollectorService } from './services/calibration-collector.service';
|
||||||
|
import { BackgroundInventoryService } from './services/background-inventory.service';
|
||||||
|
import { ScanGateway } from './services/scan.gateway';
|
||||||
|
import { SweepService } from './services/sweep.service';
|
||||||
|
import { SearchLanguageService } from './services/search-language.service';
|
||||||
|
import { DhtService } from './services/dht.service';
|
||||||
|
import { LiveFeedService } from './services/live-feed.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forRoot({
|
||||||
|
type: 'sqljs',
|
||||||
|
location: process.env.DATABASE_PATH || 'gapmind.sqlite',
|
||||||
|
autoSave: true,
|
||||||
|
synchronize: process.env.NODE_ENV !== 'production',
|
||||||
|
entities: [
|
||||||
|
PeerNode,
|
||||||
|
ResolutionSnapshot,
|
||||||
|
GeoCalibration,
|
||||||
|
Account,
|
||||||
|
ScanObservation,
|
||||||
|
FaviconFingerprint,
|
||||||
|
SweepJob,
|
||||||
|
DhtTorrent,
|
||||||
|
DhtPeer,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
PeerNode,
|
||||||
|
ResolutionSnapshot,
|
||||||
|
ScanObservation,
|
||||||
|
FaviconFingerprint,
|
||||||
|
GeoCalibration,
|
||||||
|
Account,
|
||||||
|
SweepJob,
|
||||||
|
DhtTorrent,
|
||||||
|
DhtPeer,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [ResolutionController],
|
||||||
|
providers: [
|
||||||
|
ResolutionService,
|
||||||
|
{
|
||||||
|
provide: 'REGISTRY_SERVICES',
|
||||||
|
inject: [
|
||||||
|
ArinService,
|
||||||
|
RipeService,
|
||||||
|
ApnicService,
|
||||||
|
LacnicService,
|
||||||
|
AfrinicService,
|
||||||
|
],
|
||||||
|
useFactory: (
|
||||||
|
arin: ArinService,
|
||||||
|
ripe: RipeService,
|
||||||
|
apnic: ApnicService,
|
||||||
|
lacnic: LacnicService,
|
||||||
|
afrinic: AfrinicService,
|
||||||
|
) => [arin, ripe, apnic, lacnic, afrinic],
|
||||||
|
},
|
||||||
|
BgpService,
|
||||||
|
NccService,
|
||||||
|
AfrinicService,
|
||||||
|
ApnicService,
|
||||||
|
ArinService,
|
||||||
|
LacnicService,
|
||||||
|
RipeService,
|
||||||
|
GeonamesService,
|
||||||
|
HttpService,
|
||||||
|
TriangulationService,
|
||||||
|
MlGeolocationService,
|
||||||
|
AccountService,
|
||||||
|
GeoProvidersService,
|
||||||
|
PeerRelayService,
|
||||||
|
BannerService,
|
||||||
|
FaviconService,
|
||||||
|
CalibrationCollectorService,
|
||||||
|
BackgroundInventoryService,
|
||||||
|
ScanGateway,
|
||||||
|
SweepService,
|
||||||
|
SearchLanguageService,
|
||||||
|
DhtService,
|
||||||
|
LiveFeedService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
236
src/dto.ts
Normal file
236
src/dto.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ResolveQueryDto {
|
||||||
|
@ApiProperty({ description: 'IPv4 or IPv6 address to resolve', example: '8.8.8.8' })
|
||||||
|
ip!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated expand flags', example: 'registry,bgp,dns,geo,probes,dht,banners' })
|
||||||
|
expand?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScanQueryDto {
|
||||||
|
@ApiProperty({ description: 'Comma-separated IP addresses', example: '8.8.8.8,1.1.1.1' })
|
||||||
|
targets!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated expand flags' })
|
||||||
|
expand?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BannerQueryDto {
|
||||||
|
@ApiProperty({ description: 'IP address to scan', example: '8.8.8.8' })
|
||||||
|
ip!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated port numbers', example: '22,80,443' })
|
||||||
|
ports?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SweepBodyDto {
|
||||||
|
@ApiPropertyOptional({ description: 'CIDR to sweep', example: '8.8.8.0/24' })
|
||||||
|
cidr?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Target (CIDR, ASN, or prefix)', example: 'AS15169' })
|
||||||
|
target?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['cidr', 'asn', 'prefix'], description: 'Sweep kind' })
|
||||||
|
kind?: 'cidr' | 'asn' | 'prefix';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Ports to scan', example: [80, 443] })
|
||||||
|
ports?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackgroundIndexBodyDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Specific ASN to index. Omit for all RIR allocations.', example: 'AS15169' })
|
||||||
|
asn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaviconUploadBodyDto {
|
||||||
|
@ApiProperty({ description: 'Base64-encoded image data' })
|
||||||
|
image!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Label for this favicon (usually an IP or domain)' })
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Source URL of the favicon' })
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaviconSearchQueryDto {
|
||||||
|
@ApiProperty({ description: 'Perceptual hash to search for', example: 'a1b2c3d4' })
|
||||||
|
phash!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum Hamming distance', example: '8' })
|
||||||
|
distance?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaviconMmh3QueryDto {
|
||||||
|
@ApiProperty({ description: 'MurmurHash3 hash to search', example: '-1234567890' })
|
||||||
|
hash!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdvancedSearchQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Shodan-style query string', example: 'port:80 product:nginx' })
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by ASN', example: '15169' })
|
||||||
|
asn?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by RIR', example: 'ripe' })
|
||||||
|
rir?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by port', example: '443' })
|
||||||
|
port?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by open status' })
|
||||||
|
open?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Max results', example: '200' })
|
||||||
|
take?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DhtTorrentsQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Max torrents to return', example: '100' })
|
||||||
|
take?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DhtPeersQueryDto {
|
||||||
|
@ApiProperty({ description: 'Info hash to look up', example: 'a1b2c3d4e5f6...' })
|
||||||
|
infoHash!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DhtReverseQueryDto {
|
||||||
|
@ApiProperty({ description: 'IP address for reverse lookup', example: '8.8.8.8' })
|
||||||
|
ip!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DhtByAddressQueryDto {
|
||||||
|
@ApiProperty({ description: 'IP address', example: '8.8.8.8' })
|
||||||
|
ip!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TorrentIngestBodyDto {
|
||||||
|
@ApiProperty({ description: 'Info hash (hex)' })
|
||||||
|
infoHash!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Torrent name' })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Source identifier' })
|
||||||
|
source?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Seeder count' })
|
||||||
|
seeders?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Leecher count' })
|
||||||
|
leechers?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Tracker URLs' })
|
||||||
|
trackers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerIngestBodyDto {
|
||||||
|
@ApiProperty({ description: 'Info hash' })
|
||||||
|
infoHash!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Array of peer addresses', type: [Object] })
|
||||||
|
peers!: Array<{ address: string; port: number; peerId?: string; client?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceScanBodyDto {
|
||||||
|
@ApiProperty({ description: 'IP address to scan', example: '8.8.8.8' })
|
||||||
|
ip!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Ports to scan', example: [22, 80, 443] })
|
||||||
|
ports?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetworkIndexQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by ASN' })
|
||||||
|
asn?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by RIR' })
|
||||||
|
rir?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by prefix' })
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TriangulateQueryDto {
|
||||||
|
@ApiProperty({ description: 'Host to triangulate', example: '8.8.8.8' })
|
||||||
|
host!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelatedNodesQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by ASN' })
|
||||||
|
asn?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Center latitude' })
|
||||||
|
latitude?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Center longitude' })
|
||||||
|
longitude?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Radius in km', example: '5' })
|
||||||
|
radius?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalibrationBodyDto {
|
||||||
|
@ApiProperty() target!: string;
|
||||||
|
@ApiProperty() latitude!: number;
|
||||||
|
@ApiProperty() longitude!: number;
|
||||||
|
@ApiProperty() geoliteLatitude!: number;
|
||||||
|
@ApiProperty() geoliteLongitude!: number;
|
||||||
|
@ApiProperty() pennsylvaniaMs!: number;
|
||||||
|
@ApiProperty() oregonMs!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MlPredictBodyDto {
|
||||||
|
@ApiProperty() geoliteLatitude!: number;
|
||||||
|
@ApiProperty() geoliteLongitude!: number;
|
||||||
|
@ApiProperty() pennsylvaniaMs!: number;
|
||||||
|
@ApiProperty() oregonMs!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignupBodyDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' }) email!: string;
|
||||||
|
@ApiProperty({ example: 'securepassword' }) password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NonfreeOptOutBodyDto {
|
||||||
|
@ApiPropertyOptional() enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerHeartbeatBodyDto {
|
||||||
|
@ApiProperty() nodeId!: string;
|
||||||
|
@ApiProperty({ enum: ['replica', 'pathfinder'] }) kind!: string;
|
||||||
|
@ApiPropertyOptional() address?: string;
|
||||||
|
@ApiPropertyOptional() version?: string;
|
||||||
|
@ApiPropertyOptional() metadata?: Record<string, unknown>;
|
||||||
|
@ApiPropertyOptional() status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerMetadataBodyDto {
|
||||||
|
@ApiProperty({ type: [Object] })
|
||||||
|
updates!: Array<{
|
||||||
|
nodeId: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
address?: string;
|
||||||
|
version?: string;
|
||||||
|
status?: string;
|
||||||
|
kind?: 'replica' | 'pathfinder';
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerResultBodyDto {
|
||||||
|
@ApiProperty() id!: string;
|
||||||
|
@ApiPropertyOptional() milliseconds?: number;
|
||||||
|
@ApiPropertyOptional() error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerCommandsQueryDto {
|
||||||
|
@ApiProperty({ description: 'Node ID' }) nodeId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LiveFeedQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Number of recent events to return', example: '50' })
|
||||||
|
count?: string;
|
||||||
|
}
|
||||||
47
src/main.ts
Normal file
47
src/main.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule);
|
||||||
|
|
||||||
|
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('gapmind')
|
||||||
|
.setDescription(
|
||||||
|
'IP intelligence.',
|
||||||
|
)
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('Resolution', 'IP address resolution and enrichment')
|
||||||
|
.addTag('Sweep', 'CIDR/ASN scanning and background indexing')
|
||||||
|
.addTag('DHT', 'Passive DHT torrent and peer tracking')
|
||||||
|
.addTag('Search', 'Advanced query-based observation search')
|
||||||
|
.addTag('Favicon', 'Favicon perceptual hash fingerprinting')
|
||||||
|
.addTag('Geolocation', 'Triangulation and ML-corrected geolocation')
|
||||||
|
.addTag('Accounts', 'User account management')
|
||||||
|
.addTag('Peers', 'Replica and Pathfinder node network')
|
||||||
|
.addTag('Live Feed', 'Real-time WebSocket feed of new data')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('/', app, document);
|
||||||
|
|
||||||
|
app.getHttpServer().on('request', (req: any, res: any) => {
|
||||||
|
if (req.url === '/api/openapi.json' && req.method === 'GET') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(document));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT || 3002);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
751
src/resolution.controller.ts
Normal file
751
src/resolution.controller.ts
Normal file
|
|
@ -0,0 +1,751 @@
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Ip,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { parseExpand, ResolutionService } from "./services/resolution.service";
|
||||||
|
import {
|
||||||
|
FaviconFingerprint,
|
||||||
|
PeerNode,
|
||||||
|
ScanObservation,
|
||||||
|
} from "./services/entities";
|
||||||
|
import { TriangulationService } from "./services/triangulation.service";
|
||||||
|
import { GeoCalibration } from "./services/entities";
|
||||||
|
import {
|
||||||
|
GeoFeatures,
|
||||||
|
MlGeolocationService,
|
||||||
|
} from "./services/ml-geolocation.service";
|
||||||
|
import { AccountService } from "./services/account.service";
|
||||||
|
import { PeerRelayService } from "./services/peer-relay.service";
|
||||||
|
import { BannerService } from "./services/banner.service";
|
||||||
|
import { FaviconService } from "./services/favicon.service";
|
||||||
|
import { SweepService } from "./services/sweep.service";
|
||||||
|
import { SearchLanguageService } from "./services/search-language.service";
|
||||||
|
import { DhtService } from "./services/dht.service";
|
||||||
|
import { LiveFeedService } from "./services/live-feed.service";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import { promises as dns } from "node:dns";
|
||||||
|
import {
|
||||||
|
AdvancedSearchQueryDto,
|
||||||
|
BackgroundIndexBodyDto,
|
||||||
|
BannerQueryDto,
|
||||||
|
CalibrationBodyDto,
|
||||||
|
DhtByAddressQueryDto,
|
||||||
|
DhtPeersQueryDto,
|
||||||
|
DhtReverseQueryDto,
|
||||||
|
DhtTorrentsQueryDto,
|
||||||
|
FaviconMmh3QueryDto,
|
||||||
|
FaviconSearchQueryDto,
|
||||||
|
FaviconUploadBodyDto,
|
||||||
|
LiveFeedQueryDto,
|
||||||
|
MlPredictBodyDto,
|
||||||
|
NetworkIndexQueryDto,
|
||||||
|
NonfreeOptOutBodyDto,
|
||||||
|
PeerCommandsQueryDto,
|
||||||
|
PeerHeartbeatBodyDto,
|
||||||
|
PeerIngestBodyDto,
|
||||||
|
PeerMetadataBodyDto,
|
||||||
|
PeerResultBodyDto,
|
||||||
|
RelatedNodesQueryDto,
|
||||||
|
ResolveQueryDto,
|
||||||
|
ScanQueryDto,
|
||||||
|
ServiceScanBodyDto,
|
||||||
|
SignupBodyDto,
|
||||||
|
SweepBodyDto,
|
||||||
|
TorrentIngestBodyDto,
|
||||||
|
TriangulateQueryDto,
|
||||||
|
} from "./dto";
|
||||||
|
|
||||||
|
@ApiTags("Resolution")
|
||||||
|
@Controller()
|
||||||
|
export class ResolutionController {
|
||||||
|
constructor(
|
||||||
|
private readonly resolution: ResolutionService,
|
||||||
|
private readonly triangulation: TriangulationService,
|
||||||
|
private readonly ml: MlGeolocationService,
|
||||||
|
private readonly accounts: AccountService,
|
||||||
|
private readonly relay: PeerRelayService,
|
||||||
|
private readonly banners: BannerService,
|
||||||
|
private readonly favicons: FaviconService,
|
||||||
|
private readonly sweeps: SweepService,
|
||||||
|
private readonly searchLang: SearchLanguageService,
|
||||||
|
private readonly dht: DhtService,
|
||||||
|
private readonly liveFeedService: LiveFeedService,
|
||||||
|
@InjectRepository(PeerNode) private readonly peers: Repository<PeerNode>,
|
||||||
|
@InjectRepository(ScanObservation) private readonly observations:
|
||||||
|
Repository<ScanObservation>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("api/resolve")
|
||||||
|
@ApiOperation({ summary: "Resolve an IP address" })
|
||||||
|
@ApiQuery({ name: "ip", required: true, description: "IPv4 or IPv6 address" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "expand",
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
"Comma-separated expand flags: registry,bgp,dns,geo,providers,probes,raw,dht,banners",
|
||||||
|
})
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Full resolution payload" })
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid IP" })
|
||||||
|
async resolve(
|
||||||
|
@Query() query: ResolveQueryDto,
|
||||||
|
@Headers("authorization") authorization?: string,
|
||||||
|
) {
|
||||||
|
const account = await this.accounts.fromAuthorization(authorization);
|
||||||
|
return this.resolution.resolve(
|
||||||
|
query.ip,
|
||||||
|
!account?.nonfreeProvidersOptOut,
|
||||||
|
parseExpand(query.expand),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/scan")
|
||||||
|
@ApiOperation({ summary: "Resolve multiple IP addresses (quick scan)" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "targets",
|
||||||
|
required: true,
|
||||||
|
description: "Comma-separated IPs",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "expand", required: false })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Array of resolution payloads" })
|
||||||
|
async scan(
|
||||||
|
@Query() query: ScanQueryDto,
|
||||||
|
@Headers("authorization") authorization?: string,
|
||||||
|
) {
|
||||||
|
const addresses = [
|
||||||
|
...new Set((query.targets || "").split(/[\s,]+/).filter(Boolean)),
|
||||||
|
].slice(0, 12);
|
||||||
|
if (!addresses.length) {
|
||||||
|
throw new BadRequestException("Provide one or more IP addresses.");
|
||||||
|
}
|
||||||
|
const account = await this.accounts.fromAuthorization(authorization);
|
||||||
|
return Promise.all(
|
||||||
|
addresses.map((address) =>
|
||||||
|
this.resolution.resolve(
|
||||||
|
address,
|
||||||
|
!account?.nonfreeProvidersOptOut,
|
||||||
|
parseExpand(query.expand),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/whoami")
|
||||||
|
@ApiOperation({ summary: "Get the caller's public IP address" })
|
||||||
|
@ApiResponse({ status: 200, description: "{ address: string }" })
|
||||||
|
whoami(
|
||||||
|
@Ip() address: string,
|
||||||
|
@Headers("x-forwarded-for") forwarded?: string,
|
||||||
|
) {
|
||||||
|
const candidate = forwarded?.split(",")[0]?.trim();
|
||||||
|
return { address: candidate && isIP(candidate) ? candidate : address };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/banner")
|
||||||
|
@ApiOperation({ summary: "Grab live banners from an IP" })
|
||||||
|
@ApiQuery({ name: "ip", required: true })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "ports",
|
||||||
|
required: false,
|
||||||
|
description: "Comma-separated ports",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Array of port/banner results" })
|
||||||
|
banner(@Query() query: BannerQueryDto) {
|
||||||
|
if (!query.ip) {
|
||||||
|
throw new BadRequestException('Query parameter "ip" is required.');
|
||||||
|
}
|
||||||
|
const requested = (query.ports || "")
|
||||||
|
.split(",")
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter(Number.isInteger);
|
||||||
|
return this.banners.inspect(query.ip, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/scans")
|
||||||
|
@ApiOperation({ summary: "Full service scan: resolve + banner grab + index" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 201, description: "Scan results with banners" })
|
||||||
|
async serviceScan(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: ServiceScanBodyDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
if (!body.ip) throw new BadRequestException("An IP address is required.");
|
||||||
|
const [network, banners] = await Promise.all([
|
||||||
|
this.resolution.resolve(body.ip, false, new Set(["registry", "bgp"])),
|
||||||
|
this.banners.inspect(
|
||||||
|
body.ip,
|
||||||
|
Array.isArray(body.ports) ? body.ports : [],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const records = banners.map((banner) => {
|
||||||
|
const item = banner as { port: number; open: boolean; banner?: string };
|
||||||
|
return this.observations.create({
|
||||||
|
address: body.ip!,
|
||||||
|
port: item.port,
|
||||||
|
open: item.open,
|
||||||
|
banner: item.banner?.slice(0, 1024),
|
||||||
|
asn: network.bgp?.asn,
|
||||||
|
prefix: network.bgp?.prefix,
|
||||||
|
rir: network.registry?.registry,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await this.observations.save(records);
|
||||||
|
return {
|
||||||
|
address: body.ip,
|
||||||
|
network: {
|
||||||
|
asn: network.bgp?.asn,
|
||||||
|
prefix: network.bgp?.prefix,
|
||||||
|
rir: network.registry?.registry,
|
||||||
|
holder: network.bgp?.holder,
|
||||||
|
},
|
||||||
|
banners,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/sweeps")
|
||||||
|
@ApiOperation({ summary: "Start a CIDR/ASN/prefix sweep" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 201, description: "Sweep job queued" })
|
||||||
|
async startSweep(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: SweepBodyDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
const target = body.target ?? body.cidr;
|
||||||
|
if (!target) throw new BadRequestException("CIDR is required.");
|
||||||
|
return this.sweeps.start(
|
||||||
|
body.kind ?? "cidr",
|
||||||
|
target,
|
||||||
|
Array.isArray(body.ports) ? body.ports : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/background-index")
|
||||||
|
@ApiOperation({ summary: "Start background CIDR indexing from BGP prefixes" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 201, description: "Background job started" })
|
||||||
|
async startBackgroundIndex(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: BackgroundIndexBodyDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
return this.sweeps.backgroundIndex(body.asn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/background-index/stop")
|
||||||
|
@ApiOperation({ summary: "Stop background indexing" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Stop signal sent" })
|
||||||
|
async stopBackgroundIndex(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
return this.sweeps.stopBackgroundIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/background-index/status")
|
||||||
|
@ApiOperation({ summary: "Get background indexing status" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Background index status" })
|
||||||
|
async backgroundIndexStatus(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
return this.sweeps.backgroundIndexStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/live-feed")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get recent live feed events (DHT + scan observations)",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "count",
|
||||||
|
required: false,
|
||||||
|
description: "Number of events (default 50)",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Recent feed events" })
|
||||||
|
liveFeed(@Query() query: LiveFeedQueryDto) {
|
||||||
|
return this.liveFeedService.recent(Number(query.count) || 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/search")
|
||||||
|
@ApiOperation({ summary: "Advanced search with Shodan-style query language" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Matching scan observations" })
|
||||||
|
async advancedSearch(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Query() query: AdvancedSearchQueryDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
const qb = this.observations
|
||||||
|
.createQueryBuilder("obs")
|
||||||
|
.orderBy("obs.observedAt", "DESC")
|
||||||
|
.take(Math.min(Number(query.take) || 200, 1000));
|
||||||
|
if (query.q) this.searchLang.apply(qb, this.observations, query.q);
|
||||||
|
else {
|
||||||
|
if (query.asn) qb.andWhere("obs.asn = :asn", { asn: query.asn });
|
||||||
|
if (query.rir) qb.andWhere("obs.rir = :rir", { rir: query.rir });
|
||||||
|
if (query.port) {
|
||||||
|
qb.andWhere("obs.port = :port", { port: Number(query.port) });
|
||||||
|
}
|
||||||
|
if (query.open === "true") {
|
||||||
|
qb.andWhere("obs.open = :open", { open: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/network-index")
|
||||||
|
@ApiOperation({ summary: "Get indexed observations for a network" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Network observations" })
|
||||||
|
async networkIndex(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Query() query: NetworkIndexQueryDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
if (!query.asn && !query.rir && !query.prefix) {
|
||||||
|
throw new BadRequestException("Provide asn, rir, or prefix.");
|
||||||
|
}
|
||||||
|
const items = await this.observations.find({
|
||||||
|
order: { observedAt: "DESC" },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
return items.filter(
|
||||||
|
(item) =>
|
||||||
|
(query.asn ? item.asn === query.asn : true) &&
|
||||||
|
(query.rir ? item.rir === query.rir : true) &&
|
||||||
|
(query.prefix ? item.prefix === query.prefix : true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/favicons")
|
||||||
|
@ApiOperation({ summary: "Upload and index a favicon" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 201, description: "Fingerprint result" })
|
||||||
|
async uploadFavicon(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: FaviconUploadBodyDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
if (!body.image) {
|
||||||
|
throw new BadRequestException("Base64 image data is required.");
|
||||||
|
}
|
||||||
|
return this.favicons.index(body.image, body.label, body.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/favicons/search")
|
||||||
|
@ApiOperation({ summary: "Search favicons by perceptual hash" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Matching favicons" })
|
||||||
|
async searchFavicon(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Query() query: FaviconSearchQueryDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
if (!query.phash) throw new BadRequestException("pHash is required.");
|
||||||
|
return this.favicons.search(query.phash, Number(query.distance) || 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/favicons/by-mmh3")
|
||||||
|
@ApiOperation({ summary: "Search favicons by MurmurHash3" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Matching favicons + associated observations",
|
||||||
|
})
|
||||||
|
async faviconByMmh3(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Query() query: FaviconMmh3QueryDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
if (!query.hash) throw new BadRequestException("hash is required.");
|
||||||
|
const fps = await this.favicons.searchMmh3(query.hash);
|
||||||
|
const labels = fps.map((f) => f.label).filter(Boolean);
|
||||||
|
const banners = labels.length
|
||||||
|
? await this.observations
|
||||||
|
.createQueryBuilder("obs")
|
||||||
|
.where("obs.address IN (:...labels)", { labels })
|
||||||
|
.take(500)
|
||||||
|
.getMany()
|
||||||
|
: [];
|
||||||
|
return { hash: query.hash, fingerprints: fps, banners };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/dht/torrents")
|
||||||
|
@ApiOperation({ summary: "Ingest a DHT torrent" })
|
||||||
|
@ApiResponse({ status: 201, description: "Torrent saved" })
|
||||||
|
ingestTorrent(@Body() body: TorrentIngestBodyDto) {
|
||||||
|
if (!body.infoHash) throw new BadRequestException("infoHash required");
|
||||||
|
return this.dht.ingestTorrent(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/dht/peers")
|
||||||
|
@ApiOperation({ summary: "Ingest DHT peers for a torrent" })
|
||||||
|
@ApiResponse({ status: 201, description: "Peers accepted" })
|
||||||
|
ingestPeers(@Body() body: PeerIngestBodyDto) {
|
||||||
|
if (!body.infoHash || !Array.isArray(body.peers)) {
|
||||||
|
throw new BadRequestException("infoHash + peers required");
|
||||||
|
}
|
||||||
|
return this.dht.ingestPeers(body.infoHash, body.peers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/dht/torrents")
|
||||||
|
@ApiOperation({ summary: "List recent DHT torrents" })
|
||||||
|
@ApiQuery({ name: "take", required: false })
|
||||||
|
@ApiResponse({ status: 200, description: "Array of torrents" })
|
||||||
|
listTorrents(@Query() query: DhtTorrentsQueryDto) {
|
||||||
|
return this.dht.torrentsList(Number(query.take) || 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/dht/peers")
|
||||||
|
@ApiOperation({ summary: "List peers for a torrent" })
|
||||||
|
@ApiQuery({ name: "infoHash", required: true })
|
||||||
|
@ApiResponse({ status: 200, description: "Array of peers" })
|
||||||
|
peersFor(@Query() query: DhtPeersQueryDto) {
|
||||||
|
if (!query.infoHash) throw new BadRequestException("infoHash required");
|
||||||
|
return this.dht.peersFor(query.infoHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/dht/reverse")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Reverse lookup: find torrents a peer is downloading",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "ip", required: true })
|
||||||
|
@ApiResponse({ status: 200, description: "Array of peers" })
|
||||||
|
reverseIp(@Query() query: DhtReverseQueryDto) {
|
||||||
|
if (!query.ip) throw new BadRequestException("ip required");
|
||||||
|
return this.dht.reverseIp(query.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/dht/by-address")
|
||||||
|
@ApiOperation({ summary: "Get DHT data for an IP address" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiQuery({ name: "ip", required: true })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "DHT torrents associated with this IP",
|
||||||
|
})
|
||||||
|
async dhtByAddress(
|
||||||
|
@Query() query: DhtByAddressQueryDto,
|
||||||
|
@Headers("authorization") authorization?: string,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
if (!query.ip) throw new BadRequestException("ip required");
|
||||||
|
return this.dht.byAddress(query.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/triangulate")
|
||||||
|
@ApiOperation({ summary: "Triangulate an IP using Pathfinder probes" })
|
||||||
|
@ApiQuery({ name: "host", required: true })
|
||||||
|
@ApiResponse({ status: 200, description: "Triangulation result" })
|
||||||
|
triangulate(@Query() query: TriangulateQueryDto) {
|
||||||
|
if (!query.host) {
|
||||||
|
throw new BadRequestException('Query parameter "host" is required.');
|
||||||
|
}
|
||||||
|
return this.triangulation.locate(query.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/related-nodes")
|
||||||
|
@ApiOperation({ summary: "Find Pathfinder nodes near a location or ASN" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Array of matching PeerNodes" })
|
||||||
|
async relatedNodes(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Query() query: RelatedNodesQueryDto,
|
||||||
|
) {
|
||||||
|
this.accounts.require(await this.accounts.fromAuthorization(authorization));
|
||||||
|
const lat = Number(query.latitude),
|
||||||
|
lon = Number(query.longitude),
|
||||||
|
max = Math.min(Math.max(Number(query.radius) || 5, 1), 50);
|
||||||
|
const peers = await this.peers.findBy({ kind: "pathfinder" });
|
||||||
|
return peers.filter((peer) => {
|
||||||
|
const metadata = peer.metadata as {
|
||||||
|
asn?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
} | undefined;
|
||||||
|
const sameAsn = query.asn ? metadata?.asn === query.asn : true;
|
||||||
|
const sameArea = Number.isFinite(lat) && Number.isFinite(lon)
|
||||||
|
? Math.hypot(
|
||||||
|
(metadata?.latitude ?? 999) - lat,
|
||||||
|
(metadata?.longitude ?? 999) - lon,
|
||||||
|
) <= max
|
||||||
|
: true;
|
||||||
|
return sameAsn && sameArea;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/calibration")
|
||||||
|
@ApiOperation({ summary: "Record a geolocation calibration sample" })
|
||||||
|
@ApiResponse({ status: 201, description: "Calibration saved" })
|
||||||
|
calibration(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() input: CalibrationBodyDto,
|
||||||
|
) {
|
||||||
|
const token = process.env.CALIBRATION_API_TOKEN;
|
||||||
|
if (token && authorization !== `Bearer ${token}`) {
|
||||||
|
throw new ForbiddenException("Invalid calibration credential.");
|
||||||
|
}
|
||||||
|
return this.ml.record(input as Omit<GeoCalibration, "id" | "createdAt">);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/ml/predict")
|
||||||
|
@ApiOperation({ summary: "Predict corrected geolocation from features" })
|
||||||
|
@ApiResponse({ status: 200, description: "ML prediction" })
|
||||||
|
predict(@Body() features: MlPredictBodyDto) {
|
||||||
|
return this.ml.predict(features as GeoFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/ml/status")
|
||||||
|
@ApiOperation({ summary: "Get ML model training status" })
|
||||||
|
@ApiResponse({ status: 200, description: "ML status" })
|
||||||
|
mlStatus() {
|
||||||
|
return this.ml.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/accounts/signup")
|
||||||
|
@ApiOperation({ summary: "Create a new account" })
|
||||||
|
@ApiResponse({ status: 201, description: "Account created" })
|
||||||
|
signup(@Body() body: SignupBodyDto) {
|
||||||
|
return this.accounts.signup(body.email || "", body.password || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/accounts/profile")
|
||||||
|
@ApiOperation({ summary: "Get current account profile" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Account profile" })
|
||||||
|
async profile(@Headers("authorization") authorization: string | undefined) {
|
||||||
|
const account = this.accounts.require(
|
||||||
|
await this.accounts.fromAuthorization(authorization),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
nonfreeProvidersOptOut: account.nonfreeProvidersOptOut,
|
||||||
|
createdAt: account.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/accounts/api-key/rotate")
|
||||||
|
@ApiOperation({ summary: "Rotate API key" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "New API key" })
|
||||||
|
async rotateApiKey(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
) {
|
||||||
|
const account = this.accounts.require(
|
||||||
|
await this.accounts.fromAuthorization(authorization),
|
||||||
|
);
|
||||||
|
return this.accounts.rotateToken(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/accounts/nonfree-opt-out")
|
||||||
|
@ApiOperation({ summary: "Toggle nonfree geolocation provider opt-out" })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: 200, description: "Updated preference" })
|
||||||
|
async nonfreeOptOut(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: NonfreeOptOutBodyDto,
|
||||||
|
) {
|
||||||
|
const account = this.accounts.require(
|
||||||
|
await this.accounts.fromAuthorization(authorization),
|
||||||
|
);
|
||||||
|
return this.accounts.setNonfreeOptOut(account, Boolean(body.enabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/peers/heartbeat")
|
||||||
|
@ApiOperation({ summary: "Heartbeat from a replica or pathfinder node" })
|
||||||
|
@ApiResponse({ status: 201, description: "Peer updated" })
|
||||||
|
async heartbeat(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: PeerHeartbeatBodyDto,
|
||||||
|
) {
|
||||||
|
const expected = process.env.PEER_API_TOKEN;
|
||||||
|
if (expected && authorization !== `Bearer ${expected}`) {
|
||||||
|
throw new ForbiddenException("Invalid peer credential.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!body.nodeId || (body.kind !== "replica" && body.kind !== "pathfinder")
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("nodeId and a valid kind are required.");
|
||||||
|
}
|
||||||
|
const existing = await this.peers.findOneBy({ nodeId: body.nodeId });
|
||||||
|
return this.peers.save(
|
||||||
|
this.peers.create({
|
||||||
|
...existing,
|
||||||
|
nodeId: body.nodeId,
|
||||||
|
kind: body.kind as any,
|
||||||
|
address: body.address,
|
||||||
|
version: body.version,
|
||||||
|
metadata: body.metadata,
|
||||||
|
status: body.status || "online",
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/peers")
|
||||||
|
@ApiOperation({ summary: "List all registered peers" })
|
||||||
|
@ApiResponse({ status: 200, description: "Array of PeerNodes" })
|
||||||
|
listPeers() {
|
||||||
|
return this.peers.find({ order: { lastSeenAt: "DESC" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/peers/metadata")
|
||||||
|
@ApiOperation({ summary: "Batch update peer metadata from orchestra sync" })
|
||||||
|
@ApiResponse({ status: 200, description: "Sync results" })
|
||||||
|
async syncMetadata(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: PeerMetadataBodyDto,
|
||||||
|
) {
|
||||||
|
this.requirePeerToken(authorization);
|
||||||
|
if (!Array.isArray(body.updates) || body.updates.length === 0) {
|
||||||
|
throw new BadRequestException("updates array is required.");
|
||||||
|
}
|
||||||
|
const results: Array<{ nodeId: string; applied: boolean; error?: string }> =
|
||||||
|
[];
|
||||||
|
for (const update of body.updates.slice(0, 50)) {
|
||||||
|
try {
|
||||||
|
if (!update.nodeId) {
|
||||||
|
results.push({
|
||||||
|
nodeId: "",
|
||||||
|
applied: false,
|
||||||
|
error: "nodeId required",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = await this.findPeer(update.nodeId);
|
||||||
|
if (!existing) {
|
||||||
|
results.push({
|
||||||
|
nodeId: update.nodeId,
|
||||||
|
applied: false,
|
||||||
|
error: "peer not found in DB",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const merged = this.peers.create({
|
||||||
|
...existing,
|
||||||
|
metadata: {
|
||||||
|
...(existing.metadata as object),
|
||||||
|
...(update.metadata || {}),
|
||||||
|
},
|
||||||
|
...(update.address && { address: update.address }),
|
||||||
|
...(update.version && { version: update.version }),
|
||||||
|
...(update.status && { status: update.status }),
|
||||||
|
...(update.kind && { kind: update.kind }),
|
||||||
|
lastSeenAt: existing.lastSeenAt,
|
||||||
|
});
|
||||||
|
await this.peers.save(merged);
|
||||||
|
results.push({ nodeId: existing.nodeId, applied: true });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
nodeId: update.nodeId,
|
||||||
|
applied: false,
|
||||||
|
error: err instanceof Error ? err.message : "unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
applied: results.filter((r) => r.applied).length,
|
||||||
|
rejected: results.filter((r) => !r.applied),
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPeer(nodeId: string) {
|
||||||
|
const exact = await this.peers.findOneBy({ nodeId });
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
const colonIdx = nodeId.lastIndexOf(":");
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const host = nodeId.slice(0, colonIdx);
|
||||||
|
const byHost = await this.peers.findOneBy({ nodeId: host });
|
||||||
|
if (byHost) return byHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = nodeId.includes(":")
|
||||||
|
? nodeId.slice(0, nodeId.lastIndexOf(":"))
|
||||||
|
: nodeId;
|
||||||
|
if (!isIP(hostname)) {
|
||||||
|
try {
|
||||||
|
const { address } = await dns.lookup(hostname);
|
||||||
|
const byAddr = await this.peers.findOneBy({ address });
|
||||||
|
if (byAddr) return byAddr;
|
||||||
|
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const port = Number(nodeId.slice(colonIdx + 1));
|
||||||
|
const portMatch = await this.peers
|
||||||
|
.createQueryBuilder("p")
|
||||||
|
.where("p.address = :address", { address })
|
||||||
|
.getOne();
|
||||||
|
if (portMatch) return portMatch;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byPrefix = await this.peers
|
||||||
|
.createQueryBuilder("p")
|
||||||
|
.where("p.nodeId LIKE :prefix", { prefix: `${hostname}%` })
|
||||||
|
.getOne();
|
||||||
|
if (byPrefix) return byPrefix;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("api/peers/commands")
|
||||||
|
@ApiOperation({ summary: "Get pending commands for a peer" })
|
||||||
|
@ApiQuery({ name: "nodeId", required: true })
|
||||||
|
@ApiResponse({ status: 200, description: "Pending command or null" })
|
||||||
|
async peerCommand(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Query() query: PeerCommandsQueryDto,
|
||||||
|
) {
|
||||||
|
this.requirePeerToken(authorization);
|
||||||
|
if (!query.nodeId) throw new BadRequestException("nodeId is required.");
|
||||||
|
return { command: await this.relay.next(query.nodeId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("api/peers/results")
|
||||||
|
@ApiOperation({ summary: "Submit probe results from a peer" })
|
||||||
|
@ApiResponse({ status: 200, description: "Result accepted" })
|
||||||
|
peerResult(
|
||||||
|
@Headers("authorization") authorization: string | undefined,
|
||||||
|
@Body() body: PeerResultBodyDto,
|
||||||
|
) {
|
||||||
|
this.requirePeerToken(authorization);
|
||||||
|
if (!body.id) throw new BadRequestException("Probe result id is required.");
|
||||||
|
return {
|
||||||
|
accepted: this.relay.complete(body.id, {
|
||||||
|
milliseconds: body.milliseconds,
|
||||||
|
error: body.error,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private requirePeerToken(authorization: string | undefined) {
|
||||||
|
const expected = process.env.PEER_API_TOKEN;
|
||||||
|
if (expected && authorization !== `Bearer ${expected}`) {
|
||||||
|
throw new ForbiddenException("Invalid peer credential.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/services/account.service.ts
Normal file
84
src/services/account.service.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
scrypt as scryptCallback,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Account } from './entities';
|
||||||
|
const scrypt = promisify(scryptCallback);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Account) private readonly accounts: Repository<Account>,
|
||||||
|
) {}
|
||||||
|
async signup(email: string, password: string) {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(normalized) || password.length < 12) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Use a valid email and a password with at least 12 characters.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (await this.accounts.findOneBy({ email: normalized })) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An account already exists for that email.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
const account = this.accounts.create({
|
||||||
|
email: normalized,
|
||||||
|
passwordHash: await this.hashPassword(password),
|
||||||
|
apiTokenHash: this.tokenHash(token),
|
||||||
|
});
|
||||||
|
await this.accounts.save(account);
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
apiToken: token,
|
||||||
|
nonfreeProvidersOptOut: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async fromAuthorization(value?: string) {
|
||||||
|
if (!value?.startsWith('Bearer ')) return undefined;
|
||||||
|
const hash = this.tokenHash(value.slice(7));
|
||||||
|
return this.accounts.findOneBy({ apiTokenHash: hash });
|
||||||
|
}
|
||||||
|
async setNonfreeOptOut(account: Account, optOut: boolean) {
|
||||||
|
account.nonfreeProvidersOptOut = optOut;
|
||||||
|
return this.accounts.save(account);
|
||||||
|
}
|
||||||
|
async rotateToken(account: Account) {
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
account.apiTokenHash = this.tokenHash(token);
|
||||||
|
await this.accounts.save(account);
|
||||||
|
return { apiToken: token };
|
||||||
|
}
|
||||||
|
private tokenHash(token: string) {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
private async hashPassword(password: string) {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
return `${salt}:${Buffer.from(
|
||||||
|
(await scrypt(password, salt, 64)) as Buffer,
|
||||||
|
).toString('hex')}`;
|
||||||
|
}
|
||||||
|
async verifyPassword(account: Account, password: string) {
|
||||||
|
const [salt, hash] = account.passwordHash.split(':');
|
||||||
|
const actual = Buffer.from((await scrypt(password, salt, 64)) as Buffer);
|
||||||
|
return timingSafeEqual(actual, Buffer.from(hash, 'hex'));
|
||||||
|
}
|
||||||
|
require(account?: Account | null): Account {
|
||||||
|
if (!account) {
|
||||||
|
throw new UnauthorizedException('A bearer API token is required.');
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/services/background-inventory.service.ts
Normal file
83
src/services/background-inventory.service.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BannerService } from "./banner.service";
|
||||||
|
import { FaviconService } from "./favicon.service";
|
||||||
|
import { ScanObservation } from "./entities";
|
||||||
|
import { ScanGateway } from "./scan.gateway";
|
||||||
|
import { LiveFeedService } from "./live-feed.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BackgroundInventoryService {
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private lastStarted = 0;
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ScanObservation) private readonly observations:
|
||||||
|
Repository<ScanObservation>,
|
||||||
|
private readonly banners: BannerService,
|
||||||
|
private readonly favicons: FaviconService,
|
||||||
|
private readonly gateway: ScanGateway,
|
||||||
|
private readonly liveFeed: LiveFeedService,
|
||||||
|
) {}
|
||||||
|
schedule(
|
||||||
|
address: string,
|
||||||
|
network: { asn?: string; prefix?: string; rir?: string },
|
||||||
|
) {
|
||||||
|
if (this.active.has(address) || Date.now() - this.lastStarted < 2000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.active.add(address);
|
||||||
|
this.lastStarted = Date.now();
|
||||||
|
void this.collect(address, network).finally(() =>
|
||||||
|
this.active.delete(address)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private async collect(
|
||||||
|
address: string,
|
||||||
|
network: { asn?: string; prefix?: string; rir?: string },
|
||||||
|
) {
|
||||||
|
this.gateway.publishLookup(address, "lookup.inventory", {
|
||||||
|
address,
|
||||||
|
status: "scanning",
|
||||||
|
});
|
||||||
|
const banners = await this.banners.inspect(address, []);
|
||||||
|
const rows = banners.map((banner: any) =>
|
||||||
|
this.observations.create({
|
||||||
|
address,
|
||||||
|
port: banner.port,
|
||||||
|
open: banner.open,
|
||||||
|
banner: banner.banner?.slice(0, 1024),
|
||||||
|
...network,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.observations.delete({ address });
|
||||||
|
await this.observations.save(rows);
|
||||||
|
for (const row of rows) {
|
||||||
|
this.liveFeed.publishObservation(row);
|
||||||
|
}
|
||||||
|
this.gateway.publishLookup(address, "lookup.inventory", {
|
||||||
|
address,
|
||||||
|
status: "indexed",
|
||||||
|
banners,
|
||||||
|
});
|
||||||
|
for (const banner of banners as any[]) {
|
||||||
|
if (banner.open && (banner.port === 80 || banner.port === 8080)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://${address}:${banner.port}/favicon.ico`,
|
||||||
|
{ signal: AbortSignal.timeout(3000) },
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
await this.favicons.indexBuffer(
|
||||||
|
Buffer.from(await response.arrayBuffer()),
|
||||||
|
address,
|
||||||
|
`http://${address}:${banner.port}/favicon.ico`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/services/banner.service.ts
Normal file
141
src/services/banner.service.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const SAFE_PORTS = new Set([
|
||||||
|
21, 22, 25, 53, 80, 110, 143, 443, 465, 587, 993, 995, 3306, 3389, 5432, 6379,
|
||||||
|
8080, 8443,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const HTTP_PORTS = new Set([80, 8080, 8000, 8888, 443, 8443]);
|
||||||
|
|
||||||
|
const PRODUCT_RULES: Array<{ product: string; service: string; re: RegExp }> = [
|
||||||
|
{ product: 'nginx', service: 'http', re: /server:\s*nginx\/?([0-9.]*)/i },
|
||||||
|
{ product: 'apache', service: 'http', re: /server:\s*Apache\/?([0-9.]*)/i },
|
||||||
|
{ product: 'caddy', service: 'http', re: /server:\s*Caddy/i },
|
||||||
|
{ product: 'lighttpd', service: 'http', re: /server:\s*lighttpd/i },
|
||||||
|
{
|
||||||
|
product: 'openssh',
|
||||||
|
service: 'ssh',
|
||||||
|
re: /SSH-2\.0-OpenSSH[_\s]?([0-9p.]*)?/i,
|
||||||
|
},
|
||||||
|
{ product: 'dropbear', service: 'ssh', re: /SSH-2\.0-dropbear/i },
|
||||||
|
{ product: 'vsftpd', service: 'ftp', re: /220.*vsFTPd/i },
|
||||||
|
{ product: 'proftpd', service: 'ftp', re: /220.*ProFTPD/i },
|
||||||
|
{ product: 'postfix', service: 'smtp', re: /220.*Postfix/i },
|
||||||
|
{ product: 'exim', service: 'smtp', re: /220.*Exim/i },
|
||||||
|
{ product: 'dovecot', service: 'imap', re: /\* OK.*dovecot/i },
|
||||||
|
{
|
||||||
|
product: 'mysql',
|
||||||
|
service: 'mysql',
|
||||||
|
re: /mysql_native_password|5\.[57]\.\d+|8\.\d+\.\d+/i,
|
||||||
|
},
|
||||||
|
{ product: 'postgres', service: 'postgres', re: /postgres/i },
|
||||||
|
{ product: 'redis', service: 'redis', re: /-DENIED.*redis|\+PONG/i },
|
||||||
|
{ product: 'rdp', service: 'rdp', re: /\x03\x00\x00/i },
|
||||||
|
{ product: 'mikrotik', service: 'http', re: /mikrotik|RouterOS/i },
|
||||||
|
{ product: 'squid', service: 'http-proxy', re: /squid\/?([0-9.]*)/i },
|
||||||
|
{ product: 'gunicorn', service: 'http', re: /gunicorn/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BannerService {
|
||||||
|
fingerprint(banner = ''): {
|
||||||
|
product?: string;
|
||||||
|
service?: string;
|
||||||
|
version?: string;
|
||||||
|
osGuess?: string;
|
||||||
|
httpTitle?: string;
|
||||||
|
} {
|
||||||
|
for (const rule of PRODUCT_RULES) {
|
||||||
|
const m = banner.match(rule.re);
|
||||||
|
if (m) {
|
||||||
|
const title = banner
|
||||||
|
.match(/<title>([^<]{1,200})<\/title>/i)?.[1]
|
||||||
|
?.trim();
|
||||||
|
let osGuess: string | undefined;
|
||||||
|
if (/ubuntu/i.test(banner)) osGuess = 'ubuntu';
|
||||||
|
else if (/debian/i.test(banner)) osGuess = 'debian';
|
||||||
|
else if (/centos|red hat|rhel/i.test(banner)) osGuess = 'rhel';
|
||||||
|
else if (/windows/i.test(banner)) osGuess = 'windows';
|
||||||
|
return {
|
||||||
|
product: rule.product,
|
||||||
|
service: rule.service,
|
||||||
|
version: m[1] || undefined,
|
||||||
|
osGuess,
|
||||||
|
httpTitle: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const title = banner.match(/<title>([^<]{1,200})<\/title>/i)?.[1]?.trim();
|
||||||
|
return { httpTitle: title };
|
||||||
|
}
|
||||||
|
|
||||||
|
async inspect(address: string, ports: number[]) {
|
||||||
|
if (!isIP(address))
|
||||||
|
throw new BadRequestException('Banner scans require an IP address.');
|
||||||
|
const requested = ports.length ? ports : [...SAFE_PORTS];
|
||||||
|
const selected = [...new Set(requested)].filter((port) =>
|
||||||
|
SAFE_PORTS.has(port),
|
||||||
|
);
|
||||||
|
if (!selected.length)
|
||||||
|
throw new BadRequestException('Use a supported inspection port.');
|
||||||
|
return Promise.all(
|
||||||
|
selected.slice(0, 18).map((port) => this.read(address, port)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(
|
||||||
|
address: string,
|
||||||
|
port: number,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let banner = '';
|
||||||
|
const finish = (result: Record<string, unknown>) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const socket = net.createConnection({ host: address, port });
|
||||||
|
socket.setTimeout(1800);
|
||||||
|
socket.once('connect', () => {
|
||||||
|
if (HTTP_PORTS.has(port))
|
||||||
|
socket.write(
|
||||||
|
`GET / HTTP/1.0\r\nHost: ${address}\r\nUser-Agent: gapmind/1.0\r\nAccept: */*\r\n\r\n`,
|
||||||
|
);
|
||||||
|
else if (port === 443 || port === 8443)
|
||||||
|
setTimeout(() => finish({ port, open: true }), 400);
|
||||||
|
else setTimeout(() => finish({ port, open: true }), 250);
|
||||||
|
});
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
banner += chunk.toString('utf8');
|
||||||
|
if (
|
||||||
|
banner.length >= 4096 ||
|
||||||
|
banner.includes('</html>') ||
|
||||||
|
(banner.includes('\n') && banner.length > 256)
|
||||||
|
)
|
||||||
|
finish({
|
||||||
|
port,
|
||||||
|
open: true,
|
||||||
|
banner: banner.slice(0, 4096).trim(),
|
||||||
|
...this.fingerprint(banner),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.once('timeout', () =>
|
||||||
|
finish({ port, open: false, error: 'Timed out' }),
|
||||||
|
);
|
||||||
|
socket.once('error', (error) =>
|
||||||
|
finish({ port, open: false, error: error.message }),
|
||||||
|
);
|
||||||
|
socket.once('close', () =>
|
||||||
|
finish({
|
||||||
|
port,
|
||||||
|
open: banner.length > 0,
|
||||||
|
banner: banner.slice(0, 1024).trim() || undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/services/calibration-collector.service.ts
Normal file
49
src/services/calibration-collector.service.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ResolutionSnapshot } from './entities';
|
||||||
|
import { MlGeolocationService } from './ml-geolocation.service';
|
||||||
|
import { ResolutionService } from './resolution.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CalibrationCollectorService
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
private running = false;
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ResolutionSnapshot)
|
||||||
|
private readonly snapshots: Repository<ResolutionSnapshot>,
|
||||||
|
private readonly resolution: ResolutionService,
|
||||||
|
private readonly ml: MlGeolocationService,
|
||||||
|
) {}
|
||||||
|
onModuleInit() {
|
||||||
|
if (process.env.AUTO_COLLECT_CALIBRATION === 'false') return;
|
||||||
|
void this.collect();
|
||||||
|
this.timer = setInterval(() => void this.collect(), 15 * 60 * 1000);
|
||||||
|
}
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
private async collect() {
|
||||||
|
if (this.running || (await this.ml.status()).samples >= 24) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const recent = await this.snapshots.find({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 80,
|
||||||
|
});
|
||||||
|
const addresses = [
|
||||||
|
...new Set(recent.map((snapshot) => snapshot.address)),
|
||||||
|
].slice(0, 8);
|
||||||
|
for (const address of addresses)
|
||||||
|
await this.resolution.resolve(
|
||||||
|
address,
|
||||||
|
true,
|
||||||
|
new Set(['geo', 'probes']),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/services/dedup.util.ts
Normal file
55
src/services/dedup.util.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Deduplication helpers. Scan observations, favicons, and DHT peers
|
||||||
|
* accumulate one row per scan, so reads collapse them to the latest
|
||||||
|
* record per natural key. Pure functions — unit tested.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Timestamped {
|
||||||
|
observedAt?: Date | string;
|
||||||
|
createdAt?: Date | string;
|
||||||
|
lastSeen?: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOf(row: Timestamped): number {
|
||||||
|
const raw = row.observedAt ?? row.createdAt ?? row.lastSeen;
|
||||||
|
const t = raw instanceof Date ? raw.getTime() : Date.parse(String(raw ?? ''));
|
||||||
|
return Number.isFinite(t) ? (t as number) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep newest row per key. Input order irrelevant. */
|
||||||
|
export function dedupBy<T extends Timestamped>(
|
||||||
|
rows: T[],
|
||||||
|
key: (row: T) => string,
|
||||||
|
): T[] {
|
||||||
|
const best = new Map<string, T>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const k = key(row);
|
||||||
|
const prev = best.get(k);
|
||||||
|
if (!prev || timeOf(row) >= timeOf(prev)) best.set(k, row);
|
||||||
|
}
|
||||||
|
return [...best.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupObservations<T extends Timestamped & { port: number }>(
|
||||||
|
rows: T[],
|
||||||
|
): T[] {
|
||||||
|
return dedupBy(rows, (r) => String(r.port));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupFavicons<T extends Timestamped & { perceptualHash: string }>(
|
||||||
|
rows: T[],
|
||||||
|
): T[] {
|
||||||
|
return dedupBy(rows, (r) => r.perceptualHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupPeersByInfoHash<T extends { infoHash: string }>(
|
||||||
|
rows: T[],
|
||||||
|
): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const k = r.infoHash.toLowerCase();
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
118
src/services/dht.service.ts
Normal file
118
src/services/dht.service.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { DhtPeer, DhtTorrent } from './entities';
|
||||||
|
import { TriangulationService } from './triangulation.service';
|
||||||
|
import { BgpService } from './routing/bgp.service';
|
||||||
|
import { LiveFeedService } from './live-feed.service';
|
||||||
|
import { dedupPeersByInfoHash } from './dedup.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DhtService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DhtTorrent)
|
||||||
|
private readonly torrents: Repository<DhtTorrent>,
|
||||||
|
@InjectRepository(DhtPeer) private readonly peers: Repository<DhtPeer>,
|
||||||
|
private readonly triangulation: TriangulationService,
|
||||||
|
private readonly bgp: BgpService,
|
||||||
|
private readonly liveFeed: LiveFeedService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ingestTorrent(t: Partial<DhtTorrent> & { infoHash: string }) {
|
||||||
|
const saved = await this.torrents.save(
|
||||||
|
this.torrents.create({
|
||||||
|
infoHash: t.infoHash.toLowerCase(),
|
||||||
|
name: t.name,
|
||||||
|
source: t.source,
|
||||||
|
seeders: t.seeders ?? 0,
|
||||||
|
leechers: t.leechers ?? 0,
|
||||||
|
trackers: t.trackers,
|
||||||
|
lastSeen: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.liveFeed.publishTorrent(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingestPeers(
|
||||||
|
infoHash: string,
|
||||||
|
peers: Array<{
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
peerId?: string;
|
||||||
|
client?: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const rows = peers.slice(0, 500).map((p) =>
|
||||||
|
this.peers.create({
|
||||||
|
infoHash: infoHash.toLowerCase(),
|
||||||
|
address: p.address,
|
||||||
|
port: p.port,
|
||||||
|
peerId: p.peerId,
|
||||||
|
client: p.client,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const saved = await this.peers.save(rows);
|
||||||
|
this.liveFeed.publishPeers(infoHash, saved);
|
||||||
|
void this.resolveBatch(saved.map((r) => r.id));
|
||||||
|
return { accepted: saved.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveBatch(ids: string[]) {
|
||||||
|
for (const id of ids.slice(0, 50)) {
|
||||||
|
try {
|
||||||
|
const peer = await this.peers.findOneBy({ id });
|
||||||
|
if (!peer) continue;
|
||||||
|
const bgp = await this.bgp.lookup(peer.address).catch(() => undefined);
|
||||||
|
const loc = await this.triangulation
|
||||||
|
.locate(peer.address)
|
||||||
|
.catch(() => undefined);
|
||||||
|
peer.asn = bgp?.asn;
|
||||||
|
peer.prefix = bgp?.prefix;
|
||||||
|
if (loc && (loc as { available?: boolean }).available) {
|
||||||
|
const est = (
|
||||||
|
loc as {
|
||||||
|
estimate: { latitude: number; longitude: number };
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
).estimate;
|
||||||
|
peer.latitude = est.latitude;
|
||||||
|
peer.longitude = est.longitude;
|
||||||
|
peer.confidence = (loc as { confidence: number }).confidence;
|
||||||
|
}
|
||||||
|
await this.peers.save(peer);
|
||||||
|
} catch {
|
||||||
|
/* yeah it's alright */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
torrentsList(take = 100) {
|
||||||
|
return this.torrents.find({
|
||||||
|
order: { lastSeen: 'DESC' },
|
||||||
|
take: Math.min(take, 500),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
peersFor(infoHash: string, take = 500) {
|
||||||
|
return this.peers.find({
|
||||||
|
where: { infoHash: infoHash.toLowerCase() },
|
||||||
|
order: { observedAt: 'DESC' },
|
||||||
|
take: Math.min(take, 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reverseIp(address: string, take = 100) {
|
||||||
|
return this.peers.find({
|
||||||
|
where: { address },
|
||||||
|
order: { observedAt: 'DESC' },
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async byAddress(address: string) {
|
||||||
|
const rows = await this.peers.find({
|
||||||
|
where: { address },
|
||||||
|
order: { observedAt: 'DESC' },
|
||||||
|
take: 500,
|
||||||
|
});
|
||||||
|
return dedupPeersByInfoHash(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/services/entities.ts
Normal file
189
src/services/entities.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('peer_nodes')
|
||||||
|
export class PeerNode {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar')
|
||||||
|
nodeId!: string;
|
||||||
|
@Column('varchar')
|
||||||
|
kind!: 'replica' | 'pathfinder';
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
address?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
version?: string;
|
||||||
|
@Column('varchar', { default: 'unknown' })
|
||||||
|
status!: string;
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
createdAt!: Date;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
lastSeenAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('resolution_snapshots')
|
||||||
|
export class ResolutionSnapshot {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
@Index()
|
||||||
|
@Column('varchar')
|
||||||
|
address!: string;
|
||||||
|
@Column({ type: 'simple-json' })
|
||||||
|
payload!: Record<string, unknown>;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('scan_observations')
|
||||||
|
@Index(['address', 'observedAt'])
|
||||||
|
@Index(['asn', 'rir'])
|
||||||
|
@Index(['port', 'open'])
|
||||||
|
@Index(['product'])
|
||||||
|
@Index(['faviconMmh3'])
|
||||||
|
@Index(['org'])
|
||||||
|
export class ScanObservation {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
@Column('varchar')
|
||||||
|
address!: string;
|
||||||
|
@Column('int')
|
||||||
|
port!: number;
|
||||||
|
@Column('boolean')
|
||||||
|
open!: boolean;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
banner?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
product?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
service?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
version?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
osGuess?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
hostname?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
org?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
country?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
httpTitle?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
tlsSubject?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
faviconMmh3?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
faviconPhash?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
asn?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
prefix?: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
rir?: string;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
observedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('sweep_jobs')
|
||||||
|
export class SweepJob {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id!: string;
|
||||||
|
@Column('varchar') kind!: 'cidr' | 'asn' | 'prefix' | 'registry';
|
||||||
|
@Column('varchar') target!: string;
|
||||||
|
@Column('varchar', { default: 'queued' }) status!: string;
|
||||||
|
@Column('int', { default: 0 }) total!: number;
|
||||||
|
@Column('int', { default: 0 }) completed!: number;
|
||||||
|
@Column({ type: 'simple-json', nullable: true }) ports?: number[];
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' }) updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('dht_torrents')
|
||||||
|
export class DhtTorrent {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id!: string;
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar')
|
||||||
|
infoHash!: string;
|
||||||
|
@Column('varchar', { nullable: true }) name?: string;
|
||||||
|
@Column('varchar', { nullable: true }) source?: string;
|
||||||
|
@Column('int', { default: 0 }) seeders!: number;
|
||||||
|
@Column('int', { default: 0 }) leechers!: number;
|
||||||
|
@Column({ type: 'simple-json', nullable: true }) trackers?: string[];
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' }) firstSeen!: Date;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' }) lastSeen!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('dht_peers')
|
||||||
|
@Index(['infoHash', 'address'])
|
||||||
|
export class DhtPeer {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id!: string;
|
||||||
|
@Column('varchar') infoHash!: string;
|
||||||
|
@Column('varchar') address!: string;
|
||||||
|
@Column('int') port!: number;
|
||||||
|
@Column('varchar', { nullable: true }) peerId?: string;
|
||||||
|
@Column('varchar', { nullable: true }) client?: string;
|
||||||
|
@Column('varchar', { nullable: true }) asn?: string;
|
||||||
|
@Column('varchar', { nullable: true }) prefix?: string;
|
||||||
|
@Column('varchar', { nullable: true }) country?: string;
|
||||||
|
@Column('float', { nullable: true }) latitude?: number;
|
||||||
|
@Column('float', { nullable: true }) longitude?: number;
|
||||||
|
@Column('float', { nullable: true }) confidence?: number;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' }) observedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('favicon_fingerprints')
|
||||||
|
@Index(['perceptualHash'])
|
||||||
|
export class FaviconFingerprint {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id!: string;
|
||||||
|
@Column('varchar') perceptualHash!: string;
|
||||||
|
@Column('varchar', { nullable: true }) mmh3?: string;
|
||||||
|
@Column('varchar', { nullable: true }) label?: string;
|
||||||
|
@Column('varchar', { nullable: true }) source?: string;
|
||||||
|
@Column('int') bytes!: number;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('geo_calibrations')
|
||||||
|
export class GeoCalibration {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
@Column('varchar', { nullable: true })
|
||||||
|
target?: string;
|
||||||
|
@Column('float')
|
||||||
|
latitude!: number;
|
||||||
|
@Column('float')
|
||||||
|
longitude!: number;
|
||||||
|
@Column('float')
|
||||||
|
geoliteLatitude!: number;
|
||||||
|
@Column('float')
|
||||||
|
geoliteLongitude!: number;
|
||||||
|
@Column('float')
|
||||||
|
pennsylvaniaMs!: number;
|
||||||
|
@Column('float')
|
||||||
|
oregonMs!: number;
|
||||||
|
@Column('varchar', { default: 'operator-verified' })
|
||||||
|
source!: string;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('accounts')
|
||||||
|
export class Account {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar')
|
||||||
|
email!: string;
|
||||||
|
@Column('varchar')
|
||||||
|
passwordHash!: string;
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar')
|
||||||
|
apiTokenHash!: string;
|
||||||
|
@Column('boolean', { default: false })
|
||||||
|
nonfreeProvidersOptOut!: boolean;
|
||||||
|
@Column('datetime', { default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
165
src/services/favicon.service.ts
Normal file
165
src/services/favicon.service.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { FaviconFingerprint } from "./entities";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FaviconService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FaviconFingerprint) private readonly fingerprints:
|
||||||
|
Repository<FaviconFingerprint>,
|
||||||
|
) {}
|
||||||
|
async index(base64: string, label?: string, source?: string) {
|
||||||
|
const data = Buffer.from(
|
||||||
|
base64.replace(/^data:[^;]+;base64,/, ""),
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
return this.indexBuffer(data, label, source);
|
||||||
|
}
|
||||||
|
async indexBuffer(data: Buffer, label?: string, source?: string) {
|
||||||
|
if (!data.length || data.length > 2_000_000) {
|
||||||
|
throw new BadRequestException("Upload an image smaller than 2 MB.");
|
||||||
|
}
|
||||||
|
let pixels: Buffer;
|
||||||
|
try {
|
||||||
|
pixels = await sharp(data, { limitInputPixels: 4_000_000 })
|
||||||
|
.resize(32, 32, { fit: "fill" })
|
||||||
|
.grayscale()
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException("The upload is not a supported image.");
|
||||||
|
}
|
||||||
|
const perceptualHash = this.phash(pixels);
|
||||||
|
const mmh3 = this.mmh3(data);
|
||||||
|
return this.fingerprints.save(
|
||||||
|
this.fingerprints.create({
|
||||||
|
perceptualHash,
|
||||||
|
mmh3,
|
||||||
|
label,
|
||||||
|
source,
|
||||||
|
bytes: data.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async search(perceptualHash: string, distance = 8) {
|
||||||
|
if (!/^[\da-f]{16}$/i.test(perceptualHash)) {
|
||||||
|
throw new BadRequestException("pHash must be 16 hexadecimal characters.");
|
||||||
|
}
|
||||||
|
const all = await this.fingerprints.find({
|
||||||
|
take: 5000,
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
return all
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
distance: this.hamming(perceptualHash, item.perceptualHash),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.distance <= Math.min(Math.max(distance, 0), 32))
|
||||||
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
mmh3(data: Buffer): string {
|
||||||
|
let h = 0;
|
||||||
|
const len = data.length;
|
||||||
|
const nblocks = Math.floor(len / 4);
|
||||||
|
for (let i = 0; i < nblocks; i++) {
|
||||||
|
let k = data.readUInt32LE(i * 4);
|
||||||
|
k = Math.imul(k, 0xcc9e2d51);
|
||||||
|
k = (k << 15) | (k >>> 17);
|
||||||
|
k = Math.imul(k, 0x1b873593);
|
||||||
|
h ^= k;
|
||||||
|
h = (h << 13) | (h >>> 19);
|
||||||
|
h = Math.imul(h, 5) + 0xe6546b64;
|
||||||
|
}
|
||||||
|
let tail = 0,
|
||||||
|
tailSize = len & 3;
|
||||||
|
for (let i = 0; i < tailSize; i++) {
|
||||||
|
tail |= data[len - tailSize + i]! << (i * 8);
|
||||||
|
}
|
||||||
|
if (tailSize) {
|
||||||
|
tail = Math.imul(tail, 0xcc9e2d51);
|
||||||
|
tail = (tail << 15) | (tail >>> 17);
|
||||||
|
tail = Math.imul(tail, 0x1b873593);
|
||||||
|
h ^= tail;
|
||||||
|
}
|
||||||
|
h ^= len;
|
||||||
|
h ^= h >>> 16;
|
||||||
|
h = Math.imul(h, 0x85ebca6b);
|
||||||
|
h ^= h >>> 13;
|
||||||
|
h = Math.imul(h, 0xc2b2ae35);
|
||||||
|
h ^= h >>> 16;
|
||||||
|
return String(h >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAndIndex(
|
||||||
|
address: string,
|
||||||
|
port = 80,
|
||||||
|
): Promise<{ mmh3: string; phash: string } | undefined> {
|
||||||
|
for (
|
||||||
|
const scheme of port === 443 || port === 8443
|
||||||
|
? ["https", "http"]
|
||||||
|
: ["http", "https"]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${scheme}://${address}:${port}/favicon.ico`, {
|
||||||
|
signal: AbortSignal.timeout(4000),
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (!buf.length || buf.length > 2_000_000) continue;
|
||||||
|
const saved = await this.indexBuffer(
|
||||||
|
buf,
|
||||||
|
address,
|
||||||
|
`${scheme}://${address}:${port}/favicon.ico`,
|
||||||
|
);
|
||||||
|
return { mmh3: this.mmh3(buf), phash: saved.perceptualHash };
|
||||||
|
} catch {
|
||||||
|
/* try next scheme */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchMmh3(hash: string) {
|
||||||
|
const all = await this.fingerprints.find({
|
||||||
|
take: 5000,
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
return all.filter(
|
||||||
|
(f) => (f as unknown as { mmh3?: string }).mmh3 === String(hash),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private phash(pixels: Buffer) {
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let u = 0; u < 8; u++) {
|
||||||
|
for (let v = 0; v < 8; v++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let x = 0; x < 32; x++) {
|
||||||
|
for (let y = 0; y < 32; y++) {
|
||||||
|
sum += pixels[y * 32 + x] *
|
||||||
|
Math.cos(((2 * x + 1) * u * Math.PI) / 64) *
|
||||||
|
Math.cos(((2 * y + 1) * v * Math.PI) / 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const median = [...values.slice(1)].sort((a, b) => a - b)[31];
|
||||||
|
let result = 0n;
|
||||||
|
for (const value of values) {
|
||||||
|
result = (result << 1n) | BigInt(value >= median ? 1 : 0);
|
||||||
|
}
|
||||||
|
return result.toString(16).padStart(16, "0");
|
||||||
|
}
|
||||||
|
private hamming(a: string, b: string) {
|
||||||
|
let value = BigInt(`0x${a}`) ^ BigInt(`0x${b}`),
|
||||||
|
count = 0;
|
||||||
|
while (value) {
|
||||||
|
count++;
|
||||||
|
value &= value - 1n;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/services/geonames.service.ts
Normal file
78
src/services/geonames.service.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from './http.service';
|
||||||
|
|
||||||
|
export interface GeoLocation {
|
||||||
|
country?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
city?: string;
|
||||||
|
region?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
timezone?: string;
|
||||||
|
asn?: string;
|
||||||
|
organization?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracyRadiusKm?: number;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GeonamesService {
|
||||||
|
constructor(private readonly http: HttpService) {}
|
||||||
|
|
||||||
|
async lookupGeoNames(ip: string): Promise<GeoLocation | undefined> {
|
||||||
|
const username = process.env.GEONAMES_USERNAME;
|
||||||
|
if (!username) return undefined;
|
||||||
|
const value = await this.http.json<{
|
||||||
|
countryName?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
city?: string;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
}>(
|
||||||
|
`https://api.geonames.org/geoipJSON?ip=${encodeURIComponent(
|
||||||
|
ip,
|
||||||
|
)}&username=${encodeURIComponent(username)}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
country: value.countryName,
|
||||||
|
countryCode: value.countryCode,
|
||||||
|
city: value.city,
|
||||||
|
latitude: value.lat,
|
||||||
|
longitude: value.lng,
|
||||||
|
source: 'GeoNames',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookupMaxMind(ip: string): Promise<GeoLocation | undefined> {
|
||||||
|
const account = process.env.MAXMIND_ACCOUNT_ID;
|
||||||
|
const key = process.env.MAXMIND_LICENSE_KEY;
|
||||||
|
if (!account || !key) return undefined;
|
||||||
|
const authorization = `Basic ${Buffer.from(`${account}:${key}`).toString(
|
||||||
|
'base64',
|
||||||
|
)}`;
|
||||||
|
const response = await fetch(
|
||||||
|
`https://geolite.info/geoip/v2.1/city/${encodeURIComponent(ip)}`,
|
||||||
|
{ headers: { authorization, accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
country?: { names?: { en?: string }; iso_code?: string };
|
||||||
|
city?: { names?: { en?: string } };
|
||||||
|
location?: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracy_radius?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
country: value.country?.names?.en,
|
||||||
|
countryCode: value.country?.iso_code,
|
||||||
|
city: value.city?.names?.en,
|
||||||
|
latitude: value.location?.latitude,
|
||||||
|
longitude: value.location?.longitude,
|
||||||
|
accuracyRadiusKm: value.location?.accuracy_radius,
|
||||||
|
source: 'MaxMind GeoLite City',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/services/http.service.ts
Normal file
20
src/services/http.service.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HttpService {
|
||||||
|
async json<T>(url: string, timeoutMs = 5000): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { accept: 'application/rdap+json, application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Remote service returned ${response.status}`);
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/services/live-feed.service.ts
Normal file
94
src/services/live-feed.service.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ScanGateway } from './scan.gateway';
|
||||||
|
import { DhtTorrent, DhtPeer, ScanObservation } from './entities';
|
||||||
|
|
||||||
|
const FEED_BUFFER_SIZE = 200;
|
||||||
|
|
||||||
|
export interface FeedEvent {
|
||||||
|
kind: 'dht.torrent' | 'dht.peer' | 'scan.observation';
|
||||||
|
data: unknown;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LiveFeedService {
|
||||||
|
private readonly logger = new Logger(LiveFeedService.name);
|
||||||
|
private readonly recentBuffer: FeedEvent[] = [];
|
||||||
|
private readonly FEED_NS = 'live';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly gateway: ScanGateway,
|
||||||
|
@InjectRepository(DhtTorrent)
|
||||||
|
private readonly torrents: Repository<DhtTorrent>,
|
||||||
|
@InjectRepository(DhtPeer)
|
||||||
|
private readonly peers: Repository<DhtPeer>,
|
||||||
|
@InjectRepository(ScanObservation)
|
||||||
|
private readonly observations: Repository<ScanObservation>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
publishTorrent(torrent: DhtTorrent) {
|
||||||
|
const event: FeedEvent = {
|
||||||
|
kind: 'dht.torrent',
|
||||||
|
data: {
|
||||||
|
id: torrent.id,
|
||||||
|
infoHash: torrent.infoHash,
|
||||||
|
name: torrent.name,
|
||||||
|
source: torrent.source,
|
||||||
|
seeders: torrent.seeders,
|
||||||
|
leechers: torrent.leechers,
|
||||||
|
lastSeen: torrent.lastSeen,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPeers(infoHash: string, peers: DhtPeer[]) {
|
||||||
|
const event: FeedEvent = {
|
||||||
|
kind: 'dht.peer',
|
||||||
|
data: {
|
||||||
|
infoHash,
|
||||||
|
count: peers.length,
|
||||||
|
sample: peers.slice(0, 5).map((p) => ({
|
||||||
|
address: p.address,
|
||||||
|
port: p.port,
|
||||||
|
client: p.client,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishObservation(obs: ScanObservation) {
|
||||||
|
const event: FeedEvent = {
|
||||||
|
kind: 'scan.observation',
|
||||||
|
data: {
|
||||||
|
address: obs.address,
|
||||||
|
port: obs.port,
|
||||||
|
open: obs.open,
|
||||||
|
product: obs.product,
|
||||||
|
service: obs.service,
|
||||||
|
version: obs.version,
|
||||||
|
asn: obs.asn,
|
||||||
|
org: obs.org,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
recent(count = 50): FeedEvent[] {
|
||||||
|
return this.recentBuffer.slice(-Math.min(count, FEED_BUFFER_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private push(event: FeedEvent) {
|
||||||
|
this.recentBuffer.push(event);
|
||||||
|
if (this.recentBuffer.length > FEED_BUFFER_SIZE) {
|
||||||
|
this.recentBuffer.shift();
|
||||||
|
}
|
||||||
|
this.gateway.publishFeed('feed.event', event);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/services/ml-geolocation.service.ts
Normal file
181
src/services/ml-geolocation.service.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import * as tf from '@tensorflow/tfjs';
|
||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { GeoCalibration } from './entities';
|
||||||
|
|
||||||
|
export type GeoFeatures = Pick<
|
||||||
|
GeoCalibration,
|
||||||
|
'geoliteLatitude' | 'geoliteLongitude' | 'pennsylvaniaMs' | 'oregonMs'
|
||||||
|
>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MlGeolocationService {
|
||||||
|
private model?: tf.LayersModel;
|
||||||
|
private trainedSamples = 0;
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GeoCalibration)
|
||||||
|
private readonly calibrations: Repository<GeoCalibration>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async record(input: Omit<GeoCalibration, 'id' | 'createdAt'>) {
|
||||||
|
this.validate(input);
|
||||||
|
const saved = await this.calibrations.save(this.calibrations.create(input));
|
||||||
|
this.model = undefined;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectProviderObservation(
|
||||||
|
input: Omit<GeoCalibration, 'id' | 'createdAt' | 'source'>,
|
||||||
|
) {
|
||||||
|
if (!input.target) throw new BadRequestException('A target is required.');
|
||||||
|
const existing = await this.calibrations.findOneBy({
|
||||||
|
target: input.target,
|
||||||
|
source: 'provider-derived',
|
||||||
|
});
|
||||||
|
if (existing) return { collected: false, record: existing };
|
||||||
|
this.validate(input);
|
||||||
|
const record = await this.calibrations.save(
|
||||||
|
this.calibrations.create({ ...input, source: 'provider-derived' }),
|
||||||
|
);
|
||||||
|
this.model = undefined;
|
||||||
|
return { collected: true, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
async predict(features: GeoFeatures) {
|
||||||
|
this.validate({ ...features, latitude: 0, longitude: 0 });
|
||||||
|
await this.trainIfReady();
|
||||||
|
if (!this.model) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'At least 25 samples are required.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const tensor = tf.tensor2d([this.featureVector(features)]);
|
||||||
|
const output = this.model.predict(tensor) as tf.Tensor;
|
||||||
|
const values = Array.from(await output.data()) as number[];
|
||||||
|
tensor.dispose();
|
||||||
|
output.dispose();
|
||||||
|
const latitude = this.clamp(
|
||||||
|
features.geoliteLatitude + values[0] * 90,
|
||||||
|
-90,
|
||||||
|
90,
|
||||||
|
);
|
||||||
|
const longitude = this.wrap(features.geoliteLongitude + values[1] * 180);
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
trainedSamples: this.trainedSamples,
|
||||||
|
methodology:
|
||||||
|
'TensorFlow.js residual calibration over GeoLite coordinates and PA/OR TCP latencies.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async status() {
|
||||||
|
const providerDerived = await this.calibrations.count({
|
||||||
|
where: { source: 'provider-derived' },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
samples: await this.calibrations.count(),
|
||||||
|
minimumSamples: 25,
|
||||||
|
trainedSamples: this.trainedSamples,
|
||||||
|
ready: Boolean(this.model),
|
||||||
|
providerDerived,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async trainIfReady() {
|
||||||
|
const rows = await this.calibrations.find({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 1000,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
rows.length < 25 ||
|
||||||
|
(this.model && this.trainedSamples === rows.length)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.model?.dispose();
|
||||||
|
const model = tf.sequential();
|
||||||
|
model.add(
|
||||||
|
tf.layers.dense({
|
||||||
|
inputShape: [5],
|
||||||
|
units: 12,
|
||||||
|
activation: 'relu',
|
||||||
|
kernelInitializer: 'glorotUniform',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
model.add(tf.layers.dense({ units: 8, activation: 'relu' }));
|
||||||
|
model.add(tf.layers.dense({ units: 2, activation: 'tanh' }));
|
||||||
|
model.compile({ optimizer: tf.train.adam(0.01), loss: 'meanSquaredError' });
|
||||||
|
const xs = tf.tensor2d(rows.map((row) => this.featureVector(row)));
|
||||||
|
const ys = tf.tensor2d(
|
||||||
|
rows.map((row) => [
|
||||||
|
(row.latitude - row.geoliteLatitude) / 90,
|
||||||
|
(row.longitude - row.geoliteLongitude) / 180,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await model.fit(xs, ys, {
|
||||||
|
epochs: 80,
|
||||||
|
batchSize: Math.min(32, rows.length),
|
||||||
|
shuffle: true,
|
||||||
|
verbose: 0,
|
||||||
|
});
|
||||||
|
xs.dispose();
|
||||||
|
ys.dispose();
|
||||||
|
this.model = model;
|
||||||
|
this.trainedSamples = rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private featureVector(input: GeoFeatures) {
|
||||||
|
return [
|
||||||
|
input.geoliteLatitude / 90,
|
||||||
|
input.geoliteLongitude / 180,
|
||||||
|
Math.min(input.pennsylvaniaMs, 1000) / 1000,
|
||||||
|
Math.min(input.oregonMs, 1000) / 1000,
|
||||||
|
Math.max(-1, Math.min(1, (input.pennsylvaniaMs - input.oregonMs) / 1000)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
private validate(input: Partial<GeoCalibration>) {
|
||||||
|
for (const key of [
|
||||||
|
'geoliteLatitude',
|
||||||
|
'geoliteLongitude',
|
||||||
|
'pennsylvaniaMs',
|
||||||
|
'oregonMs',
|
||||||
|
] as const) {
|
||||||
|
if (!Number.isFinite(input[key])) {
|
||||||
|
throw new BadRequestException(`${key} must be a finite number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(input.latitude !== undefined && !Number.isFinite(input.latitude)) ||
|
||||||
|
(input.longitude !== undefined && !Number.isFinite(input.longitude))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Verified coordinates must be finite numbers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.geoliteLatitude! < -90 ||
|
||||||
|
input.geoliteLatitude! > 90 ||
|
||||||
|
(input.latitude !== undefined &&
|
||||||
|
(input.latitude < -90 || input.latitude > 90)) ||
|
||||||
|
input.geoliteLongitude! < -180 ||
|
||||||
|
input.geoliteLongitude! > 180 ||
|
||||||
|
(input.longitude !== undefined &&
|
||||||
|
(input.longitude < -180 || input.longitude > 180))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Coordinates are out of range.');
|
||||||
|
}
|
||||||
|
if (input.pennsylvaniaMs! <= 0 || input.oregonMs! <= 0) {
|
||||||
|
throw new BadRequestException('Latency values must be positive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
private wrap(value: number) {
|
||||||
|
return ((((value + 180) % 360) + 360) % 360) - 180;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/services/peer-relay.service.ts
Normal file
86
src/services/peer-relay.service.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type RelayCommand = {
|
||||||
|
id: string;
|
||||||
|
type: 'probe';
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingProbe = {
|
||||||
|
resolve: (value: { milliseconds?: number; error?: string }) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PeerRelayService {
|
||||||
|
private readonly queues = new Map<string, RelayCommand[]>();
|
||||||
|
private readonly waiters = new Map<
|
||||||
|
string,
|
||||||
|
(command: RelayCommand | null) => void
|
||||||
|
>();
|
||||||
|
private readonly pending = new Map<string, PendingProbe>();
|
||||||
|
private readonly connections = new Map<string, number>();
|
||||||
|
|
||||||
|
next(nodeId: string, waitMs = 25000): Promise<RelayCommand | null> {
|
||||||
|
this.connections.set(nodeId, Date.now());
|
||||||
|
const queued = this.queues.get(nodeId)?.shift();
|
||||||
|
if (queued) return Promise.resolve(queued);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (this.waiters.get(nodeId) === wake) this.waiters.delete(nodeId);
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
Math.min(Math.max(waitMs, 1000), 30000),
|
||||||
|
);
|
||||||
|
const wake = (command: RelayCommand | null) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (this.waiters.get(nodeId) === wake) this.waiters.delete(nodeId);
|
||||||
|
resolve(command);
|
||||||
|
};
|
||||||
|
const prior = this.waiters.get(nodeId);
|
||||||
|
if (prior) prior(null);
|
||||||
|
this.waiters.set(nodeId, wake);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
probe(nodeId: string, host: string, port = '443') {
|
||||||
|
if (!this.isConnected(nodeId)) {
|
||||||
|
return Promise.resolve({
|
||||||
|
error: 'Pathfinder has not established its outbound relay connection.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const command: RelayCommand = { id, type: 'probe', host, port };
|
||||||
|
return new Promise<{ milliseconds?: number; error?: string }>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
resolve({
|
||||||
|
error: 'Pathfinder relay did not answer within 10 seconds.',
|
||||||
|
});
|
||||||
|
}, 10000);
|
||||||
|
this.pending.set(id, { resolve, timer });
|
||||||
|
const waiter = this.waiters.get(nodeId);
|
||||||
|
if (waiter) waiter(command);
|
||||||
|
else {
|
||||||
|
const queue = this.queues.get(nodeId) ?? [];
|
||||||
|
queue.push(command);
|
||||||
|
this.queues.set(nodeId, queue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(id: string, result: { milliseconds?: number; error?: string }) {
|
||||||
|
const pending = this.pending.get(id);
|
||||||
|
if (!pending) return false;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(id);
|
||||||
|
pending.resolve(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(nodeId: string) {
|
||||||
|
return (this.connections.get(nodeId) ?? 0) > Date.now() - 35000;
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/services/providers.service.ts
Normal file
365
src/services/providers.service.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { GeonamesService, GeoLocation } from './geonames.service';
|
||||||
|
|
||||||
|
export type ProviderClass = 'free' | 'credentialed';
|
||||||
|
export type GeoProviderResult = GeoLocation & {
|
||||||
|
provider: string;
|
||||||
|
class: ProviderClass;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class GeoProvidersService {
|
||||||
|
constructor(private readonly geonames: GeonamesService) {}
|
||||||
|
async resolve(
|
||||||
|
ip: string,
|
||||||
|
permitCredentialed: boolean,
|
||||||
|
): Promise<{
|
||||||
|
result?: GeoLocation;
|
||||||
|
providers: GeoProviderResult[];
|
||||||
|
consensus?: number;
|
||||||
|
}> {
|
||||||
|
const providers: GeoProviderResult[] = [];
|
||||||
|
const geoNames = await this.safe(() => this.geonames.lookupGeoNames(ip));
|
||||||
|
if (geoNames)
|
||||||
|
providers.push({
|
||||||
|
...geoNames,
|
||||||
|
provider: 'GeoNames',
|
||||||
|
class: 'free',
|
||||||
|
score: 0.45,
|
||||||
|
});
|
||||||
|
if (permitCredentialed) {
|
||||||
|
const maxMind = await this.safe(() => this.geonames.lookupMaxMind(ip));
|
||||||
|
if (maxMind)
|
||||||
|
providers.push({
|
||||||
|
...maxMind,
|
||||||
|
provider: 'MaxMind GeoLite City',
|
||||||
|
class: 'credentialed',
|
||||||
|
score: 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hasUsableLocation = providers.some(
|
||||||
|
(provider) =>
|
||||||
|
Number.isFinite(provider.latitude) &&
|
||||||
|
Number.isFinite(provider.longitude) &&
|
||||||
|
Boolean(provider.city),
|
||||||
|
);
|
||||||
|
if (!hasUsableLocation) {
|
||||||
|
const fallback = await this.lookupIpWhoIs(ip);
|
||||||
|
if (fallback) providers.push(fallback);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
permitCredentialed &&
|
||||||
|
!providers.some((provider) => Boolean(provider.city))
|
||||||
|
) {
|
||||||
|
const fallback = await this.lookupIpApiCo(ip);
|
||||||
|
if (fallback) providers.push(fallback);
|
||||||
|
}
|
||||||
|
if (!providers.length) return { providers };
|
||||||
|
const total = providers.reduce((sum, item) => sum + item.score, 0);
|
||||||
|
const latitude =
|
||||||
|
providers.reduce(
|
||||||
|
(sum, item) => sum + (item.latitude ?? 0) * item.score,
|
||||||
|
0,
|
||||||
|
) / total;
|
||||||
|
const longitude =
|
||||||
|
providers.reduce(
|
||||||
|
(sum, item) => sum + (item.longitude ?? 0) * item.score,
|
||||||
|
0,
|
||||||
|
) / total;
|
||||||
|
const primary = [...providers].sort((a, b) => b.score - a.score)[0];
|
||||||
|
const consensus =
|
||||||
|
providers.length === 1
|
||||||
|
? primary.score
|
||||||
|
: Math.min(
|
||||||
|
0.9,
|
||||||
|
providers.reduce(
|
||||||
|
(sum, item) =>
|
||||||
|
sum +
|
||||||
|
(Math.abs((item.latitude ?? latitude) - latitude) +
|
||||||
|
Math.abs((item.longitude ?? longitude) - longitude)),
|
||||||
|
0,
|
||||||
|
) < 0.8
|
||||||
|
? 0.88
|
||||||
|
: 0.55,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
...primary,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
source: providers.map((item) => item.provider).join(' + '),
|
||||||
|
},
|
||||||
|
providers,
|
||||||
|
consensus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupIpWhoIs(
|
||||||
|
ip: string,
|
||||||
|
): Promise<GeoProviderResult | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://ipwho.is/${encodeURIComponent(ip)}`,
|
||||||
|
{
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
success?: boolean;
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
|
city?: string;
|
||||||
|
region?: string;
|
||||||
|
postal?: string;
|
||||||
|
timezone?: { id?: string };
|
||||||
|
connection?: { asn?: number; org?: string };
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
if (value.success === false || !value.country_code) return undefined;
|
||||||
|
return {
|
||||||
|
provider: 'ipwho.is',
|
||||||
|
class: 'free',
|
||||||
|
score: 0.48,
|
||||||
|
source: 'ipwho.is',
|
||||||
|
country: value.country,
|
||||||
|
countryCode: value.country_code,
|
||||||
|
city: value.city,
|
||||||
|
region: value.region,
|
||||||
|
postalCode: value.postal,
|
||||||
|
timezone: value.timezone?.id,
|
||||||
|
asn: value.connection?.asn?.toString(),
|
||||||
|
organization: value.connection?.org,
|
||||||
|
latitude: value.latitude,
|
||||||
|
longitude: value.longitude,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupIpApiCom(
|
||||||
|
ip: string,
|
||||||
|
): Promise<GeoProviderResult | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://ip-api.com/json/${encodeURIComponent(ip)}`,
|
||||||
|
{
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
city?: string;
|
||||||
|
regionName?: string;
|
||||||
|
zip?: string;
|
||||||
|
timezone?: string;
|
||||||
|
as?: string;
|
||||||
|
org?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
};
|
||||||
|
if (value.status !== 'success' || !value.countryCode) return undefined;
|
||||||
|
return {
|
||||||
|
provider: 'ip-api.com',
|
||||||
|
class: 'free',
|
||||||
|
score: 0.5,
|
||||||
|
source: 'ip-api.com',
|
||||||
|
country: value.country,
|
||||||
|
countryCode: value.countryCode,
|
||||||
|
city: value.city,
|
||||||
|
region: value.regionName,
|
||||||
|
postalCode: value.zip,
|
||||||
|
timezone: value.timezone,
|
||||||
|
asn: value.as?.split(' ')[0],
|
||||||
|
organization: value.org,
|
||||||
|
latitude: value.lat,
|
||||||
|
longitude: value.lon,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupGeoLite(
|
||||||
|
ip: string,
|
||||||
|
): Promise<GeoProviderResult | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://geolite.info/geoip/v2.1/city/${encodeURIComponent(ip)}`,
|
||||||
|
{
|
||||||
|
headers: { accept: 'application/json', Authorization: `Basic ${btoa(`${process.env.MAXMIND_ACCOUNT_ID}:${process.env.MAXMIND_LICENSE_KEY}`)}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
country?: { names?: { en?: string }; iso_code?: string };
|
||||||
|
city?: { names?: { en?: string } };
|
||||||
|
subdivisions?: Array<{ names?: { en?: string } }>;
|
||||||
|
postal?: { code?: string };
|
||||||
|
location?: { latitude?: number; longitude?: number };
|
||||||
|
traits?: { autonomous_system_number?: number; organization?: string };
|
||||||
|
};
|
||||||
|
if (!value.country?.iso_code) return undefined;
|
||||||
|
return {
|
||||||
|
provider: 'GeoLite City',
|
||||||
|
class: 'credentialed',
|
||||||
|
score: 0.7,
|
||||||
|
source: 'GeoLite City',
|
||||||
|
country: value.country.names?.en,
|
||||||
|
countryCode: value.country.iso_code,
|
||||||
|
city: value.city?.names?.en,
|
||||||
|
region: value.subdivisions?.[0]?.names?.en,
|
||||||
|
postalCode: value.postal?.code,
|
||||||
|
timezone: undefined,
|
||||||
|
asn: value.traits?.autonomous_system_number?.toString(),
|
||||||
|
organization: value.traits?.organization,
|
||||||
|
latitude: value.location?.latitude,
|
||||||
|
longitude: value.location?.longitude,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupIpify(ip: string): Promise<GeoProviderResult | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.ipify.org?format=json&ip=${encodeURIComponent(ip)}`,
|
||||||
|
{
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
if (!value.ip) return undefined;
|
||||||
|
return {
|
||||||
|
provider: 'ipify',
|
||||||
|
class: 'free',
|
||||||
|
score: 0.5,
|
||||||
|
source: 'ipify',
|
||||||
|
country: undefined,
|
||||||
|
countryCode: undefined,
|
||||||
|
city: undefined,
|
||||||
|
region: undefined,
|
||||||
|
postalCode: undefined,
|
||||||
|
timezone: undefined,
|
||||||
|
asn: undefined,
|
||||||
|
organization: undefined,
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupFreeIpApi(ip: string): Promise<GeoProviderResult | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://freeipapi.com/api/json/${encodeURIComponent(ip)}`,
|
||||||
|
{
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
country_name?: string;
|
||||||
|
country_code?: string;
|
||||||
|
city?: string;
|
||||||
|
region_name?: string;
|
||||||
|
postal_code?: string;
|
||||||
|
timezone?: string;
|
||||||
|
asn?: string;
|
||||||
|
org?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
if (!value.country_code) return undefined;
|
||||||
|
return {
|
||||||
|
provider: 'freeipapi.com',
|
||||||
|
class: 'free',
|
||||||
|
score: 0.5,
|
||||||
|
source: 'freeipapi.com',
|
||||||
|
country: value.country_name,
|
||||||
|
countryCode: value.country_code,
|
||||||
|
city: value.city,
|
||||||
|
region: value.region_name,
|
||||||
|
postalCode: value.postal_code,
|
||||||
|
timezone: value.timezone,
|
||||||
|
asn: value.asn,
|
||||||
|
organization: value.org,
|
||||||
|
latitude: value.latitude,
|
||||||
|
longitude: value.longitude,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupIpApiCo(
|
||||||
|
ip: string,
|
||||||
|
): Promise<GeoProviderResult | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://ipapi.co/${encodeURIComponent(ip)}/json/`,
|
||||||
|
{
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const value = (await response.json()) as {
|
||||||
|
error?: boolean;
|
||||||
|
city?: string;
|
||||||
|
region?: string;
|
||||||
|
country?: string;
|
||||||
|
country_name?: string;
|
||||||
|
postal?: string;
|
||||||
|
timezone?: string;
|
||||||
|
asn?: string;
|
||||||
|
org?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
if (value.error || !value.country) return undefined;
|
||||||
|
return {
|
||||||
|
provider: 'ipapi.co',
|
||||||
|
class: 'credentialed',
|
||||||
|
score: 0.52,
|
||||||
|
source: 'ipapi.co',
|
||||||
|
country: value.country_name,
|
||||||
|
countryCode: value.country,
|
||||||
|
city: value.city,
|
||||||
|
region: value.region,
|
||||||
|
postalCode: value.postal,
|
||||||
|
timezone: value.timezone,
|
||||||
|
asn: value.asn,
|
||||||
|
organization: value.org,
|
||||||
|
latitude: value.latitude,
|
||||||
|
longitude: value.longitude,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safe<T>(operation: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/services/registry/afrinic.service.ts
Normal file
12
src/services/registry/afrinic.service.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
import { RegistryService } from './registry.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AfrinicService extends RegistryService {
|
||||||
|
protected readonly registry = 'AFRINIC';
|
||||||
|
protected readonly endpoint = 'https://rdap.afrinic.net/rdap/ip/';
|
||||||
|
constructor(http: HttpService) {
|
||||||
|
super(http);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/services/registry/apnic.service.ts
Normal file
12
src/services/registry/apnic.service.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
import { RegistryService } from './registry.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApnicService extends RegistryService {
|
||||||
|
protected readonly registry = 'APNIC';
|
||||||
|
protected readonly endpoint = 'https://rdap.apnic.net/ip/';
|
||||||
|
constructor(http: HttpService) {
|
||||||
|
super(http);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/services/registry/arin.service.ts
Normal file
12
src/services/registry/arin.service.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
import { RegistryService } from './registry.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArinService extends RegistryService {
|
||||||
|
protected readonly registry = 'ARIN';
|
||||||
|
protected readonly endpoint = 'https://rdap.arin.net/registry/ip/';
|
||||||
|
constructor(http: HttpService) {
|
||||||
|
super(http);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/services/registry/lacnic.service.ts
Normal file
12
src/services/registry/lacnic.service.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
import { RegistryService } from './registry.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LacnicService extends RegistryService {
|
||||||
|
protected readonly registry = 'LACNIC';
|
||||||
|
protected readonly endpoint = 'https://rdap.lacnic.net/rdap/ip/';
|
||||||
|
constructor(http: HttpService) {
|
||||||
|
super(http);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/services/registry/registry.service.ts
Normal file
56
src/services/registry/registry.service.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
|
||||||
|
export interface RegistryRecord {
|
||||||
|
registry: string;
|
||||||
|
handle?: string;
|
||||||
|
name?: string;
|
||||||
|
country?: string;
|
||||||
|
range?: string;
|
||||||
|
entities: string[];
|
||||||
|
raw: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rdap = {
|
||||||
|
handle?: string;
|
||||||
|
name?: string;
|
||||||
|
country?: string;
|
||||||
|
startAddress?: string;
|
||||||
|
endAddress?: string;
|
||||||
|
entities?: Array<{
|
||||||
|
handle?: string;
|
||||||
|
vcardArray?: [
|
||||||
|
string,
|
||||||
|
Array<[string, Record<string, unknown>, string, string]>,
|
||||||
|
];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export abstract class RegistryService {
|
||||||
|
protected abstract readonly registry: string;
|
||||||
|
protected abstract readonly endpoint: string;
|
||||||
|
constructor(protected readonly http: HttpService) {}
|
||||||
|
|
||||||
|
async lookup(ip: string): Promise<RegistryRecord> {
|
||||||
|
const data = await this.http.json<Rdap>(
|
||||||
|
`${this.endpoint}${encodeURIComponent(ip)}`,
|
||||||
|
);
|
||||||
|
const entities = (data.entities ?? []).map((entity) => {
|
||||||
|
const fn = entity.vcardArray?.[1]?.find(([name]) => name === 'fn')?.[3];
|
||||||
|
return fn || entity.handle || 'Unknown entity';
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
registry: this.registry,
|
||||||
|
handle: data.handle,
|
||||||
|
name: data.name,
|
||||||
|
country: data.country,
|
||||||
|
range:
|
||||||
|
data.startAddress && data.endAddress
|
||||||
|
? `${data.startAddress} - ${data.endAddress}`
|
||||||
|
: undefined,
|
||||||
|
entities,
|
||||||
|
raw: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/services/registry/ripe.service.ts
Normal file
12
src/services/registry/ripe.service.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
import { RegistryService } from './registry.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RipeService extends RegistryService {
|
||||||
|
protected readonly registry = 'RIPE NCC';
|
||||||
|
protected readonly endpoint = 'https://rdap.db.ripe.net/ip/';
|
||||||
|
constructor(http: HttpService) {
|
||||||
|
super(http);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/services/resolution.service.ts
Normal file
199
src/services/resolution.service.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BgpService } from "./routing/bgp.service";
|
||||||
|
import { NccService } from "./routing/ncc.service";
|
||||||
|
import { RegistryService } from "./registry/registry.service";
|
||||||
|
import {
|
||||||
|
FaviconFingerprint,
|
||||||
|
ResolutionSnapshot,
|
||||||
|
ScanObservation,
|
||||||
|
} from "./entities";
|
||||||
|
import { GeoProvidersService } from "./providers.service";
|
||||||
|
import { TriangulationService } from "./triangulation.service";
|
||||||
|
import { MlGeolocationService } from "./ml-geolocation.service";
|
||||||
|
import { BackgroundInventoryService } from "./background-inventory.service";
|
||||||
|
import { DhtService } from "./dht.service";
|
||||||
|
import { ScanGateway } from "./scan.gateway";
|
||||||
|
import {
|
||||||
|
dedupFavicons,
|
||||||
|
dedupObservations,
|
||||||
|
dedupPeersByInfoHash,
|
||||||
|
} from "./dedup.util";
|
||||||
|
|
||||||
|
const DEFAULT_EXPAND = new Set(["registry", "bgp", "dns", "geo", "dht"]);
|
||||||
|
export function parseExpand(value?: string): Set<string> {
|
||||||
|
if (!value) return DEFAULT_EXPAND;
|
||||||
|
const requested = new Set(
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return requested.has("all")
|
||||||
|
? new Set([
|
||||||
|
"registry",
|
||||||
|
"bgp",
|
||||||
|
"dns",
|
||||||
|
"geo",
|
||||||
|
"providers",
|
||||||
|
"probes",
|
||||||
|
"raw",
|
||||||
|
"dht",
|
||||||
|
"banners",
|
||||||
|
])
|
||||||
|
: requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResolutionService {
|
||||||
|
constructor(
|
||||||
|
@Inject("REGISTRY_SERVICES") private readonly registries: RegistryService[],
|
||||||
|
@Inject(BgpService) private readonly bgp: BgpService,
|
||||||
|
@Inject(NccService) private readonly ncc: NccService,
|
||||||
|
@Inject(GeoProvidersService) private readonly providers:
|
||||||
|
GeoProvidersService,
|
||||||
|
@Inject(TriangulationService) private readonly triangulation:
|
||||||
|
TriangulationService,
|
||||||
|
@Inject(MlGeolocationService) private readonly ml: MlGeolocationService,
|
||||||
|
private readonly inventory: BackgroundInventoryService,
|
||||||
|
@InjectRepository(ResolutionSnapshot) private readonly snapshots:
|
||||||
|
Repository<ResolutionSnapshot>,
|
||||||
|
@InjectRepository(ScanObservation) private readonly observations:
|
||||||
|
Repository<ScanObservation>,
|
||||||
|
@InjectRepository(FaviconFingerprint) private readonly favicons: Repository<
|
||||||
|
FaviconFingerprint
|
||||||
|
>,
|
||||||
|
private readonly dht: DhtService,
|
||||||
|
private readonly gateway: ScanGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
address: string,
|
||||||
|
permitCredentialed = true,
|
||||||
|
expand = DEFAULT_EXPAND,
|
||||||
|
) {
|
||||||
|
if (!isIP(address)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Only IPv4 and IPv6 addresses can be resolved.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [registry, bgp, ncc, geolocation, triangulation, dht] = await Promise
|
||||||
|
.all([
|
||||||
|
expand.has("registry") ? this.firstRegistry(address) : undefined,
|
||||||
|
expand.has("bgp")
|
||||||
|
? this.safe(() => this.bgp.lookup(address))
|
||||||
|
: undefined,
|
||||||
|
expand.has("dns") ? this.ncc.lookup(address) : undefined,
|
||||||
|
expand.has("geo") || expand.has("providers")
|
||||||
|
? this.providers.resolve(address, permitCredentialed)
|
||||||
|
: undefined,
|
||||||
|
expand.has("probes")
|
||||||
|
? this.triangulation.locate(address, (probe) => {
|
||||||
|
this.gateway.publishLookup(address, "lookup.probe", probe);
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
expand.has("dht") ? this.dht.byAddress(address) : undefined,
|
||||||
|
]);
|
||||||
|
const probeList = triangulation?.probes || [];
|
||||||
|
const east = probeList
|
||||||
|
.filter((probe: any) => Number.isFinite(probe.milliseconds))
|
||||||
|
.sort(
|
||||||
|
(a: any, b: any) => (b.longitude || -180) - (a.longitude || -180),
|
||||||
|
)[0];
|
||||||
|
const west = probeList
|
||||||
|
.filter((probe: any) => Number.isFinite(probe.milliseconds))
|
||||||
|
.sort((a: any, b: any) => (a.longitude || 180) - (b.longitude || 180))[0];
|
||||||
|
const mlFeatures = geolocation?.result?.latitude !== undefined &&
|
||||||
|
geolocation.result.longitude !== undefined &&
|
||||||
|
east?.milliseconds &&
|
||||||
|
west?.milliseconds
|
||||||
|
? {
|
||||||
|
geoliteLatitude: geolocation.result.latitude,
|
||||||
|
geoliteLongitude: geolocation.result.longitude,
|
||||||
|
pennsylvaniaMs: east.milliseconds,
|
||||||
|
oregonMs: west.milliseconds,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const triangEstimate = triangulation?.available
|
||||||
|
? (triangulation as any).estimate
|
||||||
|
: undefined;
|
||||||
|
const collectedCalibration =
|
||||||
|
mlFeatures && triangEstimate?.latitude !== undefined
|
||||||
|
? await this.ml.collectProviderObservation({
|
||||||
|
target: address,
|
||||||
|
latitude: triangEstimate.latitude,
|
||||||
|
longitude: triangEstimate.longitude,
|
||||||
|
...mlFeatures,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const mlCorrection = mlFeatures
|
||||||
|
? await this.ml.predict(mlFeatures)
|
||||||
|
: undefined;
|
||||||
|
const registryValue = expand.has("raw")
|
||||||
|
? registry
|
||||||
|
: registry && { ...registry, raw: undefined };
|
||||||
|
const serviceBanners = expand.has("banners")
|
||||||
|
? dedupObservations(
|
||||||
|
await this.observations.find({
|
||||||
|
where: { address },
|
||||||
|
order: { observedAt: "DESC" },
|
||||||
|
take: 60,
|
||||||
|
}),
|
||||||
|
).slice(0, 30)
|
||||||
|
: [];
|
||||||
|
const faviconFingerprints = expand.has("banners")
|
||||||
|
? dedupFavicons(
|
||||||
|
await this.favicons.find({
|
||||||
|
where: { label: address },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
).slice(0, 10)
|
||||||
|
: [];
|
||||||
|
this.inventory.schedule(address, {
|
||||||
|
asn: bgp?.asn,
|
||||||
|
prefix: bgp?.prefix,
|
||||||
|
rir: registry?.registry,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
address,
|
||||||
|
resolvedAt: new Date().toISOString(),
|
||||||
|
registry: registryValue,
|
||||||
|
bgp,
|
||||||
|
ncc,
|
||||||
|
geolocation: geolocation?.result,
|
||||||
|
geolocationProviders: expand.has("providers")
|
||||||
|
? geolocation?.providers
|
||||||
|
: undefined,
|
||||||
|
geolocationConsensus: geolocation?.consensus,
|
||||||
|
probes: probeList,
|
||||||
|
triangulation,
|
||||||
|
mlCorrection,
|
||||||
|
collectedCalibration: collectedCalibration?.collected,
|
||||||
|
serviceBanners,
|
||||||
|
faviconFingerprints,
|
||||||
|
dht: dht ? dedupPeersByInfoHash(dht as Array<{ infoHash: string }>) : dht,
|
||||||
|
};
|
||||||
|
await this.snapshots.save(this.snapshots.create({ address, payload }));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async firstRegistry(address: string) {
|
||||||
|
for (const registry of this.registries) {
|
||||||
|
try {
|
||||||
|
return await registry.lookup(address);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
private async safe<T>(operation: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/services/routing/bgp.service.ts
Normal file
32
src/services/routing/bgp.service.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HttpService } from '../http.service';
|
||||||
|
|
||||||
|
export interface BgpRoute {
|
||||||
|
asn?: string;
|
||||||
|
prefix?: string;
|
||||||
|
holder?: string;
|
||||||
|
announced: boolean;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BgpService {
|
||||||
|
constructor(private readonly http: HttpService) {}
|
||||||
|
|
||||||
|
async lookup(ip: string): Promise<BgpRoute | undefined> {
|
||||||
|
const response = await this.http.json<{
|
||||||
|
data?: { asns?: string[]; prefix?: string; holder?: string };
|
||||||
|
}>(
|
||||||
|
`https://stat.ripe.net/data/network-info/data.json?resource=${encodeURIComponent(ip)}`,
|
||||||
|
);
|
||||||
|
const data = response.data;
|
||||||
|
if (!data?.prefix && !data?.asns?.length) return undefined;
|
||||||
|
return {
|
||||||
|
asn: data.asns?.[0],
|
||||||
|
prefix: data.prefix,
|
||||||
|
holder: data.holder,
|
||||||
|
announced: Boolean(data.asns?.length),
|
||||||
|
source: 'RIPEstat network-info',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/services/routing/ncc.service.ts
Normal file
18
src/services/routing/ncc.service.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { promises as dns } from 'node:dns';
|
||||||
|
|
||||||
|
export interface NccRecord {
|
||||||
|
reverseDns: string[];
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NccService {
|
||||||
|
async lookup(ip: string): Promise<NccRecord> {
|
||||||
|
try {
|
||||||
|
return { reverseDns: await dns.reverse(ip), source: 'DNS PTR' };
|
||||||
|
} catch {
|
||||||
|
return { reverseDns: [], source: 'DNS PTR' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/services/scan.gateway.ts
Normal file
33
src/services/scan.gateway.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
|
||||||
|
@WebSocketGateway({ namespace: '/scans', cors: { origin: true } })
|
||||||
|
export class ScanGateway {
|
||||||
|
@WebSocketServer() server!: Server;
|
||||||
|
publish(jobId: string, event: string, data: unknown) {
|
||||||
|
this.server.to(jobId).emit(event, data);
|
||||||
|
}
|
||||||
|
@SubscribeMessage('scan.join')
|
||||||
|
join(client: any, jobId: string) {
|
||||||
|
client.join(jobId);
|
||||||
|
}
|
||||||
|
@SubscribeMessage('lookup.join')
|
||||||
|
joinLookup(client: any, address: string) {
|
||||||
|
client.join(`lookup:${address}`);
|
||||||
|
}
|
||||||
|
publishLookup(address: string, event: string, data: unknown) {
|
||||||
|
this.server.to(`lookup:${address}`).emit(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('feed.join')
|
||||||
|
joinFeed(client: any) {
|
||||||
|
client.join('live-feed');
|
||||||
|
}
|
||||||
|
publishFeed(event: string, data: unknown) {
|
||||||
|
this.server.to('live-feed').emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/services/search-language.service.ts
Normal file
77
src/services/search-language.service.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
import { ScanObservation } from './entities';
|
||||||
|
|
||||||
|
const KEYMAP: Record<string, string> = {
|
||||||
|
ip: 'address',
|
||||||
|
host: 'address',
|
||||||
|
port: 'port',
|
||||||
|
asn: 'asn',
|
||||||
|
org: 'org',
|
||||||
|
rir: 'rir',
|
||||||
|
prefix: 'prefix',
|
||||||
|
product: 'product',
|
||||||
|
service: 'service',
|
||||||
|
version: 'version',
|
||||||
|
os: 'osGuess',
|
||||||
|
country: 'country',
|
||||||
|
title: 'httpTitle',
|
||||||
|
banner: 'banner',
|
||||||
|
hostname: 'hostname',
|
||||||
|
hash: 'faviconMmh3',
|
||||||
|
'favicon.hash': 'faviconMmh3',
|
||||||
|
'favicon.phash': 'faviconPhash',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SearchLanguageService {
|
||||||
|
apply(
|
||||||
|
qb: SelectQueryBuilder<ScanObservation>,
|
||||||
|
repo: Repository<ScanObservation>,
|
||||||
|
q: string,
|
||||||
|
): SelectQueryBuilder<ScanObservation> {
|
||||||
|
const ors = q.split(/\s*\|\s*/).filter(Boolean);
|
||||||
|
if (!ors.length) return qb;
|
||||||
|
ors.forEach((branch, bi) => {
|
||||||
|
const tokens = branch.match(/-?"[^"]+"|-?\S+:\s*"[^"]+"|-?\S+/g) ?? [];
|
||||||
|
tokens.forEach((tok, ti) => {
|
||||||
|
let neg = false;
|
||||||
|
if (tok.startsWith('-')) {
|
||||||
|
neg = true;
|
||||||
|
tok = tok.slice(1);
|
||||||
|
}
|
||||||
|
const m = tok.match(/^([\w.]+):(.*)$/);
|
||||||
|
const param = `q${bi}_${ti}`;
|
||||||
|
const where = (sql: string, p: unknown) => {
|
||||||
|
const fn =
|
||||||
|
bi === 0 && ti === 0
|
||||||
|
? 'where'
|
||||||
|
: bi > 0 && ti === 0
|
||||||
|
? 'orWhere'
|
||||||
|
: 'andWhere';
|
||||||
|
(qb as unknown as Record<string, (s: string, p?: unknown) => void>)[
|
||||||
|
fn
|
||||||
|
](neg ? `NOT (${sql})` : sql, { [param]: p });
|
||||||
|
};
|
||||||
|
if (!m) {
|
||||||
|
const v = tok.replace(/^"|"$/g, '').toLowerCase();
|
||||||
|
where(
|
||||||
|
`(LOWER(obs.address) LIKE :${param} OR LOWER(obs.banner) LIKE :${param} OR LOWER(obs.product) LIKE :${param} OR LOWER(obs.httpTitle) LIKE :${param} OR LOWER(obs.org) LIKE :${param})`,
|
||||||
|
`%${v}%`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = KEYMAP[m[1]!.toLowerCase()];
|
||||||
|
const val = m[2]!.replace(/^"|"$/g, '');
|
||||||
|
if (!key) return;
|
||||||
|
if (key === 'port') where(`obs.port = :${param}`, Number(val));
|
||||||
|
else if (key === 'open')
|
||||||
|
where(`obs.open = :${param}`, /^(true|1|yes)$/i.test(val));
|
||||||
|
else
|
||||||
|
where(`LOWER(obs.${key}) LIKE :${param}`, `%${val.toLowerCase()}%`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
void repo;
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
}
|
||||||
347
src/services/sweep.service.ts
Normal file
347
src/services/sweep.service.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BannerService } from "./banner.service";
|
||||||
|
import { BgpService } from "./routing/bgp.service";
|
||||||
|
import { HttpService } from "./http.service";
|
||||||
|
import { ScanGateway } from "./scan.gateway";
|
||||||
|
import { ScanObservation, SweepJob } from "./entities";
|
||||||
|
import { FaviconService } from "./favicon.service";
|
||||||
|
|
||||||
|
const CONCURRENCY = 32;
|
||||||
|
const BG_INDEX_CONCURRENCY = 8;
|
||||||
|
const MAX_ON_DEMAND = 65536;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SweepService {
|
||||||
|
private readonly logger = new Logger(SweepService.name);
|
||||||
|
private readonly indexedCidrs = new Map<string, Date>();
|
||||||
|
private bgRunning = false;
|
||||||
|
private bgAbort?: AbortController;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly banners: BannerService,
|
||||||
|
private readonly bgp: BgpService,
|
||||||
|
private readonly http: HttpService,
|
||||||
|
private readonly favicons: FaviconService,
|
||||||
|
private readonly gateway: ScanGateway,
|
||||||
|
@InjectRepository(ScanObservation) private readonly observations:
|
||||||
|
Repository<ScanObservation>,
|
||||||
|
@InjectRepository(SweepJob) private readonly jobs: Repository<SweepJob>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(
|
||||||
|
kind: "cidr" | "asn" | "prefix" | "registry",
|
||||||
|
target: string,
|
||||||
|
ports: number[],
|
||||||
|
) {
|
||||||
|
const cidrs = await this.expand(kind, target);
|
||||||
|
const total = cidrs.reduce((n, c) => n + this.size(c), 0);
|
||||||
|
if (total > MAX_ON_DEMAND) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Target expands to ${total} hosts. Maximum for on-demand is ${MAX_ON_DEMAND}. Use background indexing for larger ranges.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const job = await this.jobs.save(
|
||||||
|
this.jobs.create({ kind, target, status: "running", total, ports }),
|
||||||
|
);
|
||||||
|
setImmediate(() => void this.run(job.id, cidrs, ports));
|
||||||
|
return { id: job.id, kind, target, hosts: total, status: "queued" };
|
||||||
|
}
|
||||||
|
|
||||||
|
startLegacy(cidr: string, ports: number[]) {
|
||||||
|
return this.start("cidr", cidr, ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
async backgroundIndex(asn?: string) {
|
||||||
|
if (this.bgRunning) {
|
||||||
|
return { status: "already-running", queued: this.indexedCidrs.size };
|
||||||
|
}
|
||||||
|
this.bgAbort = new AbortController();
|
||||||
|
this.bgRunning = true;
|
||||||
|
const job = await this.jobs.save(
|
||||||
|
this.jobs.create({
|
||||||
|
kind: "registry",
|
||||||
|
target: asn || "all",
|
||||||
|
status: "running",
|
||||||
|
total: 0,
|
||||||
|
ports: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setImmediate(() =>
|
||||||
|
void this.runBackground(job.id, asn).finally(() => {
|
||||||
|
this.bgRunning = false;
|
||||||
|
this.jobs.update(job.id, { status: "complete" }).catch(() => {});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { status: "started", jobId: job.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBackgroundIndex() {
|
||||||
|
if (!this.bgRunning) return { status: "not-running" };
|
||||||
|
this.bgAbort?.abort();
|
||||||
|
this.bgRunning = false;
|
||||||
|
return { status: "stopping" };
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundIndexStatus() {
|
||||||
|
return {
|
||||||
|
running: this.bgRunning,
|
||||||
|
indexedCidrs: this.indexedCidrs.size,
|
||||||
|
recentCidrs: [...this.indexedCidrs.entries()]
|
||||||
|
.sort((a, b) => b[1].getTime() - a[1].getTime())
|
||||||
|
.slice(0, 50)
|
||||||
|
.map(([cidr, at]) => ({ cidr, indexedAt: at.toISOString() })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runBackground(jobId: string, asn?: string) {
|
||||||
|
const prefixes = await this.fetchAllPrefixes(asn);
|
||||||
|
this.logger.log(`Background indexing: ${prefixes.length} prefixes to scan`);
|
||||||
|
await this.jobs.update(jobId, { total: prefixes.length });
|
||||||
|
|
||||||
|
const allIps: string[] = [];
|
||||||
|
for (const cidr of prefixes) {
|
||||||
|
if (this.bgAbort?.signal.aborted) break;
|
||||||
|
if (this.indexedCidrs.has(cidr)) continue;
|
||||||
|
const r = this.parse(cidr);
|
||||||
|
for (let v = r.start; v <= r.end && allIps.length < 500000; v++) {
|
||||||
|
allIps.push(this.ip(v));
|
||||||
|
}
|
||||||
|
this.indexedCidrs.set(cidr, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gateway.publish(jobId, "bg.started", { jobId, total: allIps.length });
|
||||||
|
let completed = 0;
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(BG_INDEX_CONCURRENCY, allIps.length) },
|
||||||
|
async () => {
|
||||||
|
while (completed < allIps.length) {
|
||||||
|
if (this.bgAbort?.signal.aborted) break;
|
||||||
|
const idx = completed++;
|
||||||
|
if (idx >= allIps.length) break;
|
||||||
|
const address = allIps[idx];
|
||||||
|
try {
|
||||||
|
await this.indexAddress(address);
|
||||||
|
this.gateway.publish(jobId, "bg.node", { address });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
if (completed % 100 === 0) {
|
||||||
|
this.gateway.publish(jobId, "bg.progress", {
|
||||||
|
completed,
|
||||||
|
total: allIps.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
this.gateway.publish(jobId, "bg.complete", { jobId, completed });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAllPrefixes(asn?: string): Promise<string[]> {
|
||||||
|
if (asn) {
|
||||||
|
return this.fetchPrefixesForAsn(asn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rirs = ["ripe", "arin", "apnic", "lacnic", "afrinic"];
|
||||||
|
const allPrefixes: string[] = [];
|
||||||
|
for (const rir of rirs) {
|
||||||
|
try {
|
||||||
|
const res = await this.http.json<{
|
||||||
|
data?: { prefixes?: Array<{ prefix: string }> };
|
||||||
|
}>(
|
||||||
|
`https://stat.ripe.net/data/allocation-unique-id/data.json?resource=${rir}`,
|
||||||
|
);
|
||||||
|
const pfxs = (res.data?.prefixes || [])
|
||||||
|
.map((p) => p.prefix)
|
||||||
|
.filter((p) => p.includes(".") && !p.includes(":"));
|
||||||
|
allPrefixes.push(...pfxs);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const res = await this.http.json<
|
||||||
|
{ objects?: Array<{ attributes?: { inetnum?: string } }> }
|
||||||
|
>(
|
||||||
|
`https://stat.ripe.net/data/delegate-stats/data.json?resource=${rir}`,
|
||||||
|
);
|
||||||
|
const pfxs = (res.objects || [])
|
||||||
|
.map((o) => o.attributes?.inetnum)
|
||||||
|
.filter((p): p is string =>
|
||||||
|
Boolean(p && p.includes(".") && !p.includes(":"))
|
||||||
|
);
|
||||||
|
allPrefixes.push(...pfxs);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Failed to fetch prefixes from ${rir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deduplicatePrefixes(allPrefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPrefixesForAsn(asn: string): Promise<string[]> {
|
||||||
|
const clean = asn.replace(/^AS/i, "");
|
||||||
|
const res = await this.http.json<{
|
||||||
|
data?: { prefixes?: Array<{ prefix: string }> };
|
||||||
|
}>(
|
||||||
|
`https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${
|
||||||
|
encodeURIComponent(clean)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return (res.data?.prefixes || [])
|
||||||
|
.map((p) => p.prefix)
|
||||||
|
.filter((p) => p.includes(".") && !p.includes(":"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private deduplicatePrefixes(prefixes: string[]): string[] {
|
||||||
|
const parsed = prefixes.map((p) => ({ cidr: p, ...this.parse(p) }));
|
||||||
|
parsed.sort((a, b) => b.start - a.start || a.end - b.end);
|
||||||
|
const kept: typeof parsed = [];
|
||||||
|
for (const p of parsed) {
|
||||||
|
if (!kept.some((k) => k.start <= p.start && k.end >= p.end)) {
|
||||||
|
kept.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept.map((p) => p.cidr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async indexAddress(address: string) {
|
||||||
|
const banners = await this.banners.inspect(address, []);
|
||||||
|
const net = await this.safe(() => this.bgp.lookup(address));
|
||||||
|
const rows: ScanObservation[] = [];
|
||||||
|
for (
|
||||||
|
const b of banners as Array<{
|
||||||
|
port: number;
|
||||||
|
open: boolean;
|
||||||
|
banner?: string;
|
||||||
|
product?: string;
|
||||||
|
service?: string;
|
||||||
|
version?: string;
|
||||||
|
osGuess?: string;
|
||||||
|
httpTitle?: string;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
let fav: { mmh3: string; phash: string } | undefined;
|
||||||
|
if (
|
||||||
|
b.open &&
|
||||||
|
(b.port === 80 || b.port === 443 || b.port === 8080 || b.port === 8443)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
fav = await this.favicons.fetchAndIndex(address, b.port);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
rows.push(
|
||||||
|
this.observations.create({
|
||||||
|
address,
|
||||||
|
port: b.port,
|
||||||
|
open: b.open,
|
||||||
|
banner: b.banner?.slice(0, 4096),
|
||||||
|
product: b.product,
|
||||||
|
service: b.service,
|
||||||
|
version: b.version,
|
||||||
|
osGuess: b.osGuess,
|
||||||
|
httpTitle: b.httpTitle,
|
||||||
|
org: net?.holder,
|
||||||
|
asn: net?.asn,
|
||||||
|
prefix: net?.prefix,
|
||||||
|
faviconMmh3: fav?.mmh3,
|
||||||
|
faviconPhash: fav?.phash,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.observations.delete({ address });
|
||||||
|
await this.observations.save(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async expand(kind: string, target: string): Promise<string[]> {
|
||||||
|
if (kind === "cidr" || kind === "prefix") {
|
||||||
|
this.parse(target.includes("/") ? target : `${target}/32`);
|
||||||
|
return [target.includes("/") ? target : `${target}/32`];
|
||||||
|
}
|
||||||
|
if (kind === "asn") {
|
||||||
|
const prefixes = await this.fetchPrefixesForAsn(target);
|
||||||
|
if (!prefixes.length) {
|
||||||
|
throw new BadRequestException("No announced prefixes found for ASN.");
|
||||||
|
}
|
||||||
|
return prefixes;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Unsupported sweep kind. Use cidr, asn, or prefix.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(id: string, cidrs: string[], ports: number[]) {
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const cidr of cidrs) {
|
||||||
|
const r = this.parse(cidr);
|
||||||
|
for (let v = r.start; v <= r.end && ips.length < MAX_ON_DEMAND; v++) {
|
||||||
|
ips.push(this.ip(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.gateway.publish(id, "scan.started", { id, total: ips.length });
|
||||||
|
await this.jobs.update(id, { total: ips.length });
|
||||||
|
let completed = 0, done = 0;
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(CONCURRENCY, ips.length) },
|
||||||
|
async () => {
|
||||||
|
while (done < ips.length) {
|
||||||
|
const address = ips[done++]!;
|
||||||
|
try {
|
||||||
|
await this.indexAddress(address);
|
||||||
|
this.gateway.publish(id, "scan.node", { address });
|
||||||
|
} catch (e) {
|
||||||
|
this.gateway.publish(id, "scan.error", {
|
||||||
|
address,
|
||||||
|
error: e instanceof Error ? e.message : "scan failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
this.gateway.publish(id, "scan.progress", {
|
||||||
|
completed,
|
||||||
|
total: ips.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
await this.jobs.update(id, { status: "complete", completed });
|
||||||
|
this.gateway.publish(id, "scan.complete", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(cidr: string) {
|
||||||
|
const [ip, lengthText] = cidr.split("/");
|
||||||
|
const length = Number(lengthText);
|
||||||
|
const parts = ip?.split(".").map(Number);
|
||||||
|
if (
|
||||||
|
!parts || parts.length !== 4 ||
|
||||||
|
!Number.isInteger(length) || length < 0 || length > 32 ||
|
||||||
|
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("Use an IPv4 CIDR.");
|
||||||
|
}
|
||||||
|
const value = parts.reduce((sum, part) => sum * 256 + part, 0);
|
||||||
|
const mask = length === 0 ? 0 : (0xffffffff << (32 - length)) >>> 0;
|
||||||
|
const start = value & mask;
|
||||||
|
return { start, end: start + (2 ** (32 - length) - 1) };
|
||||||
|
}
|
||||||
|
contains(
|
||||||
|
outer: { start: number; end: number },
|
||||||
|
inner: { start: number; end: number },
|
||||||
|
) {
|
||||||
|
return outer.start <= inner.start && outer.end >= inner.end;
|
||||||
|
}
|
||||||
|
ip(value: number) {
|
||||||
|
return [24, 16, 8, 0].map((shift) => (value >>> shift) & 255).join(".");
|
||||||
|
}
|
||||||
|
size(cidr: string) {
|
||||||
|
const r = this.parse(cidr);
|
||||||
|
return r.end - r.start + 1;
|
||||||
|
}
|
||||||
|
private async safe<T>(op: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await op();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/services/triangulation.service.ts
Normal file
209
src/services/triangulation.service.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { PeerNode } from './entities';
|
||||||
|
import { PeerRelayService } from './peer-relay.service';
|
||||||
|
|
||||||
|
type Probe = { latitude: number; longitude: number; milliseconds: number };
|
||||||
|
export type NodeProbe = {
|
||||||
|
nodeId: string;
|
||||||
|
address?: string;
|
||||||
|
milliseconds?: number;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KM_PER_MS = 100;
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
function haversineKm(
|
||||||
|
aLat: number,
|
||||||
|
aLon: number,
|
||||||
|
bLat: number,
|
||||||
|
bLon: number,
|
||||||
|
): number {
|
||||||
|
const R = 6371,
|
||||||
|
dLat = toRad(bLat - aLat),
|
||||||
|
dLon = toRad(bLon - aLon);
|
||||||
|
const s =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TriangulationService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PeerNode) private readonly peers: Repository<PeerNode>,
|
||||||
|
private readonly relay: PeerRelayService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async locate(host: string, onProbe?: (probe: NodeProbe) => void) {
|
||||||
|
const peers = await this.peers.findBy({ kind: 'pathfinder' });
|
||||||
|
const nodeProbes = await this.probeAll(host, peers, onProbe);
|
||||||
|
const probes = nodeProbes.filter(
|
||||||
|
(
|
||||||
|
p,
|
||||||
|
): p is NodeProbe &
|
||||||
|
Required<Pick<NodeProbe, 'latitude' | 'longitude' | 'milliseconds'>> =>
|
||||||
|
Number.isFinite(p.latitude) &&
|
||||||
|
Number.isFinite(p.longitude) &&
|
||||||
|
Number.isFinite(p.milliseconds) &&
|
||||||
|
(p.milliseconds as number) > 0,
|
||||||
|
);
|
||||||
|
if (probes.length < 2) {
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
available: false,
|
||||||
|
reason: 'Two online pathfinders with coordinates are required.',
|
||||||
|
samples: probes.length,
|
||||||
|
probes: nodeProbes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const baseline = Math.min(...probes.map((p) => p.milliseconds));
|
||||||
|
const discs = probes.map((p) => ({
|
||||||
|
...p,
|
||||||
|
radiusKm: Math.max(
|
||||||
|
5,
|
||||||
|
((p.milliseconds - baseline * 0.5) / 2) * KM_PER_MS,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
let best = { lat: 0, lon: 0, score: Infinity };
|
||||||
|
const lats = discs.map((d) => d.latitude),
|
||||||
|
lons = discs.map((d) => d.longitude);
|
||||||
|
const minLat = Math.min(...lats) - 10,
|
||||||
|
maxLat = Math.max(...lats) + 10;
|
||||||
|
const minLon = Math.min(...lons) - 10,
|
||||||
|
maxLon = Math.max(...lons) + 10;
|
||||||
|
for (let step = 0; step < 3; step++) {
|
||||||
|
const n = step === 0 ? 25 : 15;
|
||||||
|
const dLa = (maxLat - minLat) / n,
|
||||||
|
dLo = (maxLon - minLon) / n;
|
||||||
|
const cLa = (minLat + maxLat) / 2,
|
||||||
|
cLo = (minLon + maxLon) / 2;
|
||||||
|
const spanLa =
|
||||||
|
step === 0 ? maxLat - minLat : (maxLat - minLat) / (step * 4);
|
||||||
|
const spanLo =
|
||||||
|
step === 0 ? maxLon - minLon : (maxLon - minLon) / (step * 4);
|
||||||
|
void dLa;
|
||||||
|
void dLo;
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
for (let j = 0; j <= n; j++) {
|
||||||
|
const lat = cLa - spanLa / 2 + (spanLa * i) / n,
|
||||||
|
lon = cLo - spanLo / 2 + (spanLo * j) / n;
|
||||||
|
let score = 0;
|
||||||
|
for (const d of discs) {
|
||||||
|
const dist = haversineKm(lat, lon, d.latitude, d.longitude);
|
||||||
|
const violation = Math.max(0, dist - d.radiusKm);
|
||||||
|
const w = 1 / Math.max(d.milliseconds, 1);
|
||||||
|
score += violation * violation * w * 10 + dist * w * 0.1;
|
||||||
|
}
|
||||||
|
if (score < best.score) best = { lat, lon, score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const residuals = discs.map((d) => ({
|
||||||
|
nodeId: d.nodeId,
|
||||||
|
residualKm: Math.abs(
|
||||||
|
haversineKm(best.lat, best.lon, d.latitude, d.longitude) -
|
||||||
|
d.radiusKm / 2,
|
||||||
|
),
|
||||||
|
rtt: d.milliseconds,
|
||||||
|
}));
|
||||||
|
const rmse = Math.sqrt(
|
||||||
|
residuals.reduce((s, r) => s + r.residualKm ** 2, 0) / residuals.length,
|
||||||
|
);
|
||||||
|
const spread =
|
||||||
|
Math.max(...probes.map((p) => p.milliseconds)) -
|
||||||
|
Math.min(...probes.map((p) => p.milliseconds));
|
||||||
|
const radiusKm = Math.min(2000, Math.max(25, rmse));
|
||||||
|
const confidence = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(0.95, 1 - rmse / 1500 - spread / 4000),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
available: true,
|
||||||
|
estimate: { latitude: best.lat, longitude: best.lon },
|
||||||
|
samples: probes.length,
|
||||||
|
confidence,
|
||||||
|
radiusKm,
|
||||||
|
rmseKm: rmse,
|
||||||
|
methodology:
|
||||||
|
'CBG: RTT discs at ~100km/ms over fiber, grid least-squares, outlier-aware. Coarse network estimate, not physical location.',
|
||||||
|
discs: discs.map((d) => ({
|
||||||
|
nodeId: d.nodeId,
|
||||||
|
latitude: d.latitude,
|
||||||
|
longitude: d.longitude,
|
||||||
|
rtt: d.milliseconds,
|
||||||
|
radiusKm: d.radiusKm,
|
||||||
|
})),
|
||||||
|
residuals,
|
||||||
|
probes: nodeProbes.map((p) => ({
|
||||||
|
nodeId: p.nodeId,
|
||||||
|
latitude: p.latitude,
|
||||||
|
longitude: p.longitude,
|
||||||
|
milliseconds: p.milliseconds,
|
||||||
|
error: p.error,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async probeAll(host: string, peers?: PeerNode[], onProbe?: (probe: NodeProbe) => void): Promise<NodeProbe[]> {
|
||||||
|
const targets = peers ?? (await this.peers.findBy({ kind: 'pathfinder' }));
|
||||||
|
const results = await Promise.all(targets.map((peer) => this.probe(peer, host)));
|
||||||
|
if (onProbe) results.forEach(onProbe);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probe(peer: PeerNode, host: string): Promise<NodeProbe> {
|
||||||
|
const metadata = peer.metadata as
|
||||||
|
| {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
transport?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const base = {
|
||||||
|
nodeId: peer.nodeId,
|
||||||
|
address: peer.address,
|
||||||
|
latitude: metadata?.latitude,
|
||||||
|
longitude: metadata?.longitude,
|
||||||
|
};
|
||||||
|
if (metadata?.transport === 'outbound-relay') {
|
||||||
|
const result = await this.relay.probe(peer.nodeId, host);
|
||||||
|
return { ...base, ...result };
|
||||||
|
}
|
||||||
|
if (!peer.address) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
error: 'Pathfinder has not reported a relay or public URL.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${peer.address.replace(/\/$/, '')}/probe?host=${encodeURIComponent(
|
||||||
|
host,
|
||||||
|
)}`,
|
||||||
|
{ signal: AbortSignal.timeout(5000) },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
error: `Pathfinder returned HTTP ${response.status}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { milliseconds?: number };
|
||||||
|
if (!Number.isFinite(body.milliseconds)) {
|
||||||
|
return { ...base, error: 'Pathfinder returned an invalid latency.' };
|
||||||
|
}
|
||||||
|
return { ...base, milliseconds: body.milliseconds };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Pathfinder probe failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
test/dedup.test.ts
Normal file
57
test/dedup.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
dedupFavicons,
|
||||||
|
dedupObservations,
|
||||||
|
dedupPeersByInfoHash,
|
||||||
|
} from '../src/services/dedup.util.js';
|
||||||
|
|
||||||
|
describe('dedupObservations', () => {
|
||||||
|
it('keeps newest row per port', () => {
|
||||||
|
const rows = [
|
||||||
|
{ port: 80, banner: 'old', observedAt: new Date('2024-01-01') },
|
||||||
|
{ port: 80, banner: 'new', observedAt: new Date('2024-06-01') },
|
||||||
|
{ port: 443, banner: 'tls', observedAt: new Date('2024-01-01') },
|
||||||
|
];
|
||||||
|
const out = dedupObservations(rows);
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
assert.equal(out.find((r) => r.port === 80)?.banner, 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is order-independent', () => {
|
||||||
|
const rows = [
|
||||||
|
{ port: 22, banner: 'new', observedAt: new Date('2024-06-01') },
|
||||||
|
{ port: 22, banner: 'old', observedAt: new Date('2024-01-01') },
|
||||||
|
];
|
||||||
|
assert.equal(dedupObservations(rows)[0]?.banner, 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
assert.deepEqual(dedupObservations([]), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dedupFavicons', () => {
|
||||||
|
it('collapses identical perceptual hashes', () => {
|
||||||
|
const rows = [
|
||||||
|
{ perceptualHash: 'ab', createdAt: new Date('2024-01-01') },
|
||||||
|
{ perceptualHash: 'ab', createdAt: new Date('2024-02-01') },
|
||||||
|
{ perceptualHash: 'cd', createdAt: new Date('2024-01-01') },
|
||||||
|
];
|
||||||
|
const out = dedupFavicons(rows);
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dedupPeersByInfoHash', () => {
|
||||||
|
it('dedups case-insensitively, keeps first (newest)', () => {
|
||||||
|
const rows = [
|
||||||
|
{ infoHash: 'AABB', address: '1.1.1.1' },
|
||||||
|
{ infoHash: 'aabb', address: '2.2.2.2' },
|
||||||
|
{ infoHash: 'ccdd', address: '3.3.3.3' },
|
||||||
|
];
|
||||||
|
const out = dedupPeersByInfoHash(rows);
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
assert.equal(out[0]?.address, '1.1.1.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
test/expand.test.ts
Normal file
25
test/expand.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseExpand } from '../src/services/resolution.service.js';
|
||||||
|
|
||||||
|
describe('parseExpand', () => {
|
||||||
|
it('returns defaults when empty', () => {
|
||||||
|
const s = parseExpand(undefined);
|
||||||
|
assert.ok(s.has('registry'));
|
||||||
|
assert.ok(s.has('dht'));
|
||||||
|
assert.ok(!s.has('probes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses comma list, lowercases, trims', () => {
|
||||||
|
const s = parseExpand(' Geo , PROBES ');
|
||||||
|
assert.ok(s.has('geo'));
|
||||||
|
assert.ok(s.has('probes'));
|
||||||
|
assert.equal(s.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands "all" to the full set', () => {
|
||||||
|
const s = parseExpand('all');
|
||||||
|
for (const k of ['registry', 'bgp', 'dns', 'geo', 'providers', 'probes', 'raw', 'dht', 'banners'])
|
||||||
|
assert.ok(s.has(k), `missing ${k}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
test/ml-gate.test.ts
Normal file
34
test/ml-gate.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MlGeolocationService } from '../src/services/ml-geolocation.service.js';
|
||||||
|
|
||||||
|
const emptyRepo = { find: async () => [], count: async () => 0 } as any;
|
||||||
|
const features = {
|
||||||
|
geoliteLatitude: 40.7,
|
||||||
|
geoliteLongitude: -74,
|
||||||
|
pennsylvaniaMs: 20,
|
||||||
|
oregonMs: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MlGeolocationService gate', () => {
|
||||||
|
it('predict is unavailable below 25 samples', async () => {
|
||||||
|
const ml = new MlGeolocationService(emptyRepo);
|
||||||
|
const out = (await ml.predict(features)) as any;
|
||||||
|
assert.equal(out.available, false);
|
||||||
|
assert.match(out.reason, /25/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status reports minimumSamples 25', async () => {
|
||||||
|
const ml = new MlGeolocationService(emptyRepo);
|
||||||
|
const status = await ml.status();
|
||||||
|
assert.equal(status.minimumSamples, 25);
|
||||||
|
assert.equal(status.ready, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid features', async () => {
|
||||||
|
const ml = new MlGeolocationService(emptyRepo);
|
||||||
|
await assert.rejects(() =>
|
||||||
|
ml.predict({ ...features, pennsylvaniaMs: -1 } as any),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
test/sweep.test.ts
Normal file
39
test/sweep.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { SweepService } from '../src/services/sweep.service.js';
|
||||||
|
|
||||||
|
const none = null as any;
|
||||||
|
const svc = new SweepService(none, none, none, none, none, none, none);
|
||||||
|
|
||||||
|
describe('SweepService.parse', () => {
|
||||||
|
it('parses /24 bounds', () => {
|
||||||
|
const r = svc.parse('10.0.0.0/24');
|
||||||
|
assert.equal(svc.ip(r.start), '10.0.0.0');
|
||||||
|
assert.equal(svc.ip(r.end), '10.0.0.255');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-IPv4 CIDRs', () => {
|
||||||
|
assert.throws(() => svc.parse('not-a-cidr'));
|
||||||
|
assert.throws(() => svc.parse('10.0.0.0/33'));
|
||||||
|
assert.throws(() => svc.parse('::1/128'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SweepService.size/ip', () => {
|
||||||
|
it('sizes /32 as 1 and /24 as 256', () => {
|
||||||
|
assert.equal(svc.size('1.2.3.4/32'), 1);
|
||||||
|
assert.equal(svc.size('1.2.3.0/24'), 256);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips ip encoding', () => {
|
||||||
|
assert.equal(svc.ip(svc.parse('192.168.1.0/24').start), '192.168.1.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SweepService.contains', () => {
|
||||||
|
it('detects covering ranges', () => {
|
||||||
|
const outer = svc.parse('10.0.0.0/8');
|
||||||
|
assert.ok(svc.contains(outer, svc.parse('10.1.2.0/24')));
|
||||||
|
assert.ok(!svc.contains(svc.parse('10.1.2.0/24'), outer));
|
||||||
|
});
|
||||||
|
});
|
||||||
8
tsconfig.build.json
Normal file
8
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.build.tsbuildinfo
Normal file
1
tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue