Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5331a3c2ce | |||
| a694b0c8ae | |||
| 7e973b842c | |||
| 14baec56f7 | |||
| 6eb5aca0df | |||
| b515d8c6a7 | |||
| 0fad90ffd6 | |||
| c3ac9b4f9e | |||
| e9dcd45acd | |||
| 0f79773985 | |||
| 6e59f0f5c0 | |||
| b219ec2208 | |||
| e70e5ac15c | |||
| 806606c9b9 | |||
| ac3b501adf | |||
| da02e04edf | |||
| 1a81adaabd | |||
| 5ae4187065 | |||
| b7d7e405eb | |||
| d1ab85cbb3 | |||
| 9cf4e433bf | |||
| 7c88ecd82a | |||
| 771bfe94e7 | |||
| def467a27b | |||
| 7d087e39ef | |||
| 26e1d5142a | |||
| d6be2e27b0 | |||
| d6c0af35fa | |||
| bc19c21949 | |||
| dba1855eb6 | |||
| db01586eb9 | |||
| 172fce2466 | |||
| f7f009e0d4 | |||
| e5111f0433 | |||
| 554a7d9647 | |||
| 76d30b7618 | |||
| eee90810da | |||
| 5bf7d19bf7 | |||
| 2fa6da38c6 | |||
| e11157fe44 | |||
| 28d99ecb77 | |||
| d8f409c191 | |||
| bcfa3be58b | |||
| 15744d3c4e | |||
| 8b2f541150 | |||
| b52bb4b474 | |||
| 42f5d66fc4 | |||
| 54bb9549a1 | |||
| 95c3314400 | |||
| 695d047200 | |||
| c308589d28 | |||
| 068177b09d | |||
| 4a299cf3cb | |||
| e5c37b1801 | |||
| 5be0586790 | |||
| f5e5297d47 | |||
| 718fada493 | |||
| a42b1b48b5 | |||
| 5ec50975f3 | |||
| ad222abb6a | |||
| b29e13b162 | |||
| 9544823401 | |||
| 260f000304 | |||
| d8044507ed | |||
| b9380be999 | |||
| 1b9c354d69 | |||
| a8f4ecf98f | |||
| 6350088d2a | |||
| 10ef1d0455 | |||
| f709238e50 | |||
| 49940635d5 | |||
| ec4121cbcf | |||
| ea9a2572f9 | |||
| cc0f1c40a6 | |||
| 9da04081e4 | |||
| 4ae0925043 | |||
| 4e862e784b | |||
| cf8abfd4f0 | |||
| 93c4488b9b |
@@ -0,0 +1,66 @@
|
||||
name: Default (not tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @ship.zone/npmci
|
||||
|
||||
- name: Run npm prepare
|
||||
run: npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm build
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Default (tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm build
|
||||
|
||||
release:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm publish
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Code quality
|
||||
run: |
|
||||
npmci command npm install -g typescript
|
||||
npmci npm install
|
||||
|
||||
- name: Trigger
|
||||
run: npmci trigger
|
||||
|
||||
- name: Build docs and upload artifacts
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
npmci command tsdoc
|
||||
continue-on-error: true
|
||||
+2
-2
@@ -3,7 +3,6 @@
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
@@ -16,5 +15,6 @@ node_modules/
|
||||
# builds
|
||||
dist/
|
||||
dist_*/
|
||||
rust/target/
|
||||
|
||||
# custom
|
||||
#------# custom
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
# gitzone ci_default
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .npmci_cache/
|
||||
key: '$CI_BUILD_STAGE'
|
||||
|
||||
stages:
|
||||
- security
|
||||
- test
|
||||
- release
|
||||
- metadata
|
||||
|
||||
# ====================
|
||||
# security stage
|
||||
# ====================
|
||||
mirror:
|
||||
stage: security
|
||||
script:
|
||||
- npmci git mirror
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
auditProductionDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci command npm install --production --ignore-scripts
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command npm audit --audit-level=high --only=prod
|
||||
tags:
|
||||
- docker
|
||||
|
||||
auditDevDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci command npm install --ignore-scripts
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command npm audit --audit-level=high --only=dev
|
||||
tags:
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
# ====================
|
||||
# test stage
|
||||
# ====================
|
||||
|
||||
testStable:
|
||||
stage: test
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci npm test
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
testBuild:
|
||||
stage: test
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci command npm run build
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
release:
|
||||
stage: release
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm publish
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
# ====================
|
||||
# metadata stage
|
||||
# ====================
|
||||
codequality:
|
||||
stage: metadata
|
||||
allow_failure: true
|
||||
only:
|
||||
- tags
|
||||
script:
|
||||
- npmci command npm install -g tslint typescript
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
- npmci command "tslint -c tslint.json ./ts/**/*.ts"
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- priv
|
||||
|
||||
trigger:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci trigger
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
pages:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci node install lts
|
||||
- npmci command npm install -g @gitzone/tsdoc
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
- npmci command tsdoc
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
only:
|
||||
- tags
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- public
|
||||
allow_failure: true
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"@git.zone/tsrust": {
|
||||
"targets": [
|
||||
"linux_amd64",
|
||||
"linux_arm64"
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartnetwork",
|
||||
"description": "A toolkit for network diagnostics including speed tests, port availability checks, and more.",
|
||||
"npmPackagename": "@push.rocks/smartnetwork",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"network diagnostics",
|
||||
"ping",
|
||||
"port check",
|
||||
"speed test",
|
||||
"network interface",
|
||||
"public IP retrieval",
|
||||
"Cloudflare speed test",
|
||||
"network performance",
|
||||
"network utility",
|
||||
"TypeScript"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
|
||||
version: v1.13.5
|
||||
ignore: {}
|
||||
patch: {}
|
||||
Vendored
+3
-21
@@ -2,28 +2,10 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "current file",
|
||||
"type": "node",
|
||||
"command": "npm test",
|
||||
"name": "Run npm test",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"${relativeFile}"
|
||||
],
|
||||
"runtimeArgs": ["-r", "@gitzone/tsrun"],
|
||||
"cwd": "${workspaceRoot}",
|
||||
"protocol": "inspector",
|
||||
"internalConsoleOptions": "openOnSessionStart"
|
||||
},
|
||||
{
|
||||
"name": "test.ts",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"test/test.ts"
|
||||
],
|
||||
"runtimeArgs": ["-r", "@gitzone/tsrun"],
|
||||
"cwd": "${workspaceRoot}",
|
||||
"protocol": "inspector",
|
||||
"internalConsoleOptions": "openOnSessionStart"
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-13 - 4.6.0 - feat(domain-intelligence)
|
||||
add domain intelligence lookups with RDAP and DNS enrichment
|
||||
|
||||
- introduces DomainIntelligence with normalized domain handling, RDAP bootstrap discovery, and merged DNS enrichment data
|
||||
- adds SmartNetwork.getDomainIntelligence() with cache support and public exports for domain intelligence types
|
||||
- reuses a shared smartdns client across DNS and intelligence features and tears it down cleanly in stop() to prevent hanging processes
|
||||
- adds integration tests for gTLDs, RDAP-less ccTLDs, IDN normalization, malformed input, caching, and shutdown behavior
|
||||
- updates the README to document domain intelligence, caching coverage, and shared smartdns lifecycle behavior
|
||||
|
||||
## 2026-03-26 - 4.5.2 - fix(docs)
|
||||
refresh README content and align license copyright holder
|
||||
|
||||
- Rewrite the README to better present network diagnostics, IP intelligence capabilities, usage examples, and full API reference
|
||||
- Add issue reporting guidance and clarify legal and trademark wording in project documentation
|
||||
- Update the license copyright holder to Task Venture Capital GmbH
|
||||
|
||||
## 2026-03-26 - 4.5.1 - fix(ipintelligence)
|
||||
handle flat geolocation MMDB schema and clean up DNS client lifecycle
|
||||
|
||||
- update IP intelligence lookups to read geolocation fields from the flat @ip-location-db schema instead of MaxMind nested city records
|
||||
- destroy the Team Cymru DNS client after queries to avoid leaking resources
|
||||
- adjust IP intelligence tests to validate countryCode-based geolocation and RDAP fields against current data sources
|
||||
|
||||
## 2026-03-26 - 4.5.0 - feat(smartnetwork)
|
||||
add Rust-powered network diagnostics bridge and IP intelligence lookups
|
||||
|
||||
- replace system-dependent ping, traceroute, port checks, and gateway detection with a Rust IPC binary integrated through smartrust
|
||||
- add IP intelligence support combining RDAP, ASN, and MaxMind geolocation data
|
||||
- update build configuration to compile and package Rust binaries for Linux amd64 and arm64
|
||||
- refresh tests and docs for explicit SmartNetwork start/stop lifecycle and new functionality
|
||||
|
||||
## 2025-09-12 - 4.4.0 - feat(smartnetwork)
|
||||
Add exclude option to findFreePort and skip excluded ports during search
|
||||
|
||||
- Add an 'exclude' array to IFindFreePortOptions so callers can specify ports to ignore when searching for a free port.
|
||||
- Respect excluded ports in findFreePort for both random (randomize=true) and sequential searches so excluded ports are never returned.
|
||||
- Add .claude/settings.local.json to include local permissions used for development/CI helpers.
|
||||
|
||||
## 2025-09-12 - 4.3.0 - feat(smartnetwork)
|
||||
Add randomizable port search and switch DNS resolution to @push.rocks/smartdns; export smartdns and update docs
|
||||
|
||||
- findFreePort: added options.randomize (IFindFreePortOptions) to return a random available port within a range; retains default sequential search behavior
|
||||
- resolveDns: switched to @push.rocks/smartdns client with 'prefer-system' strategy and DoH fallback; now returns parsed A, AAAA and MX records
|
||||
- Exported smartdns from the internal plugins module and added @push.rocks/smartdns dependency
|
||||
- Documentation updates: readme clarifies random port selection, DNS resolution strategy and adds examples for random findFreePort usage
|
||||
- Bumped @git.zone/tstest dev dependency to ^2.3.7
|
||||
|
||||
## 2025-09-12 - 4.2.0 - feat(PublicIp)
|
||||
Add PublicIp service and refactor SmartNetwork to use it; remove public-ip dependency; update exports, docs and dependencies
|
||||
|
||||
- Add PublicIp class (ts/smartnetwork.classes.publicip.ts) implementing public IPv4/IPv6 lookup with multiple fallback services, timeouts and validation
|
||||
- Refactor SmartNetwork.getPublicIps to use the new PublicIp class and preserve caching behavior
|
||||
- Export PublicIp from package entry (ts/index.ts)
|
||||
- Remove public-ip from plugins/exports and stop using the public-ip package
|
||||
- Bump devDependencies and runtime dependency versions in package.json (@git.zone/tsbuild, @git.zone/tstest, @push.rocks/smartenv, systeminformation)
|
||||
- Improve README: expanded usage, examples, formatting and added emojis for clarity
|
||||
- Add project local settings file (.claude/settings.local.json) for CI/permissions configuration
|
||||
|
||||
## 2025-07-31 - 4.1.0 - feat(port-management)
|
||||
Add findFreePort method for automatic port discovery within a range
|
||||
|
||||
- Added new `findFreePort` method to SmartNetwork class that finds the first available port in a specified range
|
||||
- Added comprehensive tests for the new port finding functionality
|
||||
- Updated README documentation with usage examples for the new feature
|
||||
- Improved port management capabilities for dynamic port allocation scenarios
|
||||
|
||||
## 2025-05-19 - 4.0.2 - fix(tests)
|
||||
Update dev dependencies and refactor test assertions for improved clarity
|
||||
|
||||
- Bumped @git.zone/tsbuild version to ^2.5.1
|
||||
- Bumped @git.zone/tstest version to ^1.9.0
|
||||
- Updated npm test script to include the verbose flag
|
||||
- Replaced expectAsync assertions with resolves based assertions in test files
|
||||
|
||||
## 2025-05-03 - 4.0.1 - fix(formatting)
|
||||
Fix minor formatting issues and newline consistency across project files
|
||||
|
||||
- Ensure newline at end of package.json, errors.ts, logging.ts, and test files
|
||||
- Refine code block formatting in readme.md
|
||||
- Adjust whitespace and code style in smartnetwork classes and cloudflarespeed module
|
||||
- Minor commitinfo data format update
|
||||
|
||||
## 2025-04-28 - 4.0.0 - BREAKING CHANGE(smartnetwork)
|
||||
Enhance documentation and add configurable speed test options with plugin architecture improvements
|
||||
|
||||
- Expanded README with detailed examples for traceroute, speed test, ping, and remote port checks
|
||||
- Added optional parameters for getSpeed (parallelStreams and duration) to allow configurable testing modes
|
||||
- Included plugin architecture usage examples to show runtime registration and unregistration of plugins
|
||||
- Updated test suite to cover DNS resolution, endpoint health-check, caching behavior, and various network diagnostics
|
||||
- Removed legacy planning documentation from readme.plan.md
|
||||
|
||||
## 2025-04-28 - 3.0.5 - fix(core)
|
||||
Improve logging and error handling by introducing custom error classes and a global logging interface while refactoring network diagnostics methods.
|
||||
|
||||
- Added custom error classes (NetworkError, TimeoutError) for network operations.
|
||||
- Introduced a global logging interface to replace direct console logging.
|
||||
- Updated CloudflareSpeed and SmartNetwork classes to use getLogger for improved error reporting.
|
||||
- Disabled connection pooling in HTTP requests to prevent listener accumulation.
|
||||
|
||||
## 2025-04-28 - 3.0.4 - fix(ci/config)
|
||||
Improve CI workflows, update project configuration, and clean up code formatting
|
||||
|
||||
- Added new Gitea workflow files (default_nottags.yaml and default_tags.yaml) to replace GitLab CI
|
||||
- Updated package.json with new buildDocs script, revised homepage URL, bug tracking info, and pnpm overrides
|
||||
- Refined code formatting in TypeScript files, including improved error handling in Cloudflare speed tests and consistent callback structure
|
||||
- Enhanced tsconfig.json by adding baseUrl and paths for better module resolution
|
||||
- Introduced readme.plan.md outlining future improvements and feature enhancements
|
||||
|
||||
## 2025-04-28 - 3.0.3 - fix(deps)
|
||||
Update dependency namespaces and bump package versions in CI configuration and source imports
|
||||
|
||||
- Renamed dependency imports from '@pushrocks/...' to '@push.rocks/...' in package.json, test files, and source files
|
||||
- Updated devDependencies versions and package references (e.g., tsbuild, tsrun, tstest, smartenv, tapbundle, smartpromise, smartstring, public-ip, and @types packages)
|
||||
- Fixed CI command to install '@git.zone/tsdoc' instead of the misspelled '@gitzone/tsdoc'
|
||||
- Updated commit info file to reflect the correct package namespace
|
||||
|
||||
## 2024-05-29 - 3.0.2 - misc
|
||||
Various documentation, configuration, and organizational updates.
|
||||
- Updated project description.
|
||||
- Updated tsconfig configuration.
|
||||
- Updated npmextra.json (githost configuration) across multiple commits.
|
||||
- Switched to a new organizational scheme.
|
||||
|
||||
## 2022-10-22 - 3.0.1 - core
|
||||
Core fixes and maintenance applied.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2022-10-21 - 3.0.0 - core
|
||||
Core updates and fixes.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2022-03-24 - 2.0.14 - core
|
||||
Breaking changes introduced.
|
||||
- BREAKING CHANGE: Switched core to an ECMAScript Modules (ESM) approach.
|
||||
|
||||
## 2022-02-16 - 2.0.13 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2022-02-16 - 2.0.12 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2022-02-16 - 2.0.11 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2022-02-16 - 2.0.10 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-29 - 2.0.9 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-28 - 2.0.8 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-28 - 2.0.7 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-28 - 2.0.6 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-28 - 2.0.5 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-27 - 2.0.4 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-13 - 2.0.3 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-13 - 2.0.2 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-13 - 2.0.1 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-13 - 2.0.0 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2021-04-13 - 1.1.22 - core
|
||||
Breaking update in core functionality.
|
||||
- BREAKING CHANGE: Updated core functionality.
|
||||
|
||||
## 2020-08-13 - 1.1.21 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2020-08-13 - 1.1.20 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2020-08-12 - 1.1.19 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2020-08-12 - 1.1.18 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-11-23 - 1.1.17 - minor
|
||||
Release with no notable changes.
|
||||
- No significant changes.
|
||||
|
||||
## 2019-11-23 - 1.1.16 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-11-19 - 1.1.15 - security
|
||||
Security enhancement.
|
||||
- Added Snyk configuration for improved security.
|
||||
|
||||
## 2019-11-19 - 1.1.14 - dependencies
|
||||
Dependency update.
|
||||
- Updated dependencies.
|
||||
|
||||
## 2019-09-08 - 1.1.13 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-08 - 1.1.12 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-08 - 1.1.11 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-08 - 1.1.10 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-08 - 1.1.9 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-06 - 1.1.8 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-06 - 1.1.7 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-09-06 - 1.1.6 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-04-17 - 1.1.5 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-04-17 - 1.1.4 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-04-17 - 1.1.3 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-04-17 - 1.1.2 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-04-16 - 1.1.1 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2019-04-16 - 1.1.0 - core
|
||||
Core maintenance update.
|
||||
- Fixed core issues.
|
||||
|
||||
## 2018-07-17 - 1.0.2 - feature
|
||||
New feature added.
|
||||
- Added port checking functionality.
|
||||
|
||||
## 2017-12-12 - 1.0.1 - initial
|
||||
Initial commit.
|
||||
- Initial project setup.
|
||||
@@ -0,0 +1,23 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Task Venture Capital GmbH
|
||||
Copyright (c) 2020 Tomás Arias
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "pushrocks",
|
||||
"gitrepo": "smartnetwork",
|
||||
"shortDescription": "network diagnostics",
|
||||
"npmPackagename": "@pushrocks/smartnetwork",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-11434
File diff suppressed because it is too large
Load Diff
+44
-22
@@ -1,32 +1,30 @@
|
||||
{
|
||||
"name": "@pushrocks/smartnetwork",
|
||||
"version": "1.1.20",
|
||||
"name": "@push.rocks/smartnetwork",
|
||||
"version": "4.6.0",
|
||||
"private": false,
|
||||
"description": "network diagnostics",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"description": "A toolkit for network diagnostics including speed tests, port availability checks, and more.",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/)",
|
||||
"build": "(tsbuild)"
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitzone/tsbuild": "^2.1.25",
|
||||
"@gitzone/tstest": "^1.0.43",
|
||||
"@pushrocks/tapbundle": "^3.2.9",
|
||||
"tslint": "^5.20.1",
|
||||
"tslint-config-prettier": "^1.18.0"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tsrust": "^1.3.2",
|
||||
"@git.zone/tstest": "^3.6.1",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pushrocks/smartpromise": "^3.0.6",
|
||||
"@pushrocks/smartstring": "^3.0.18",
|
||||
"@types/default-gateway": "^3.0.1",
|
||||
"default-gateway": "^5.0.5",
|
||||
"isopen": "^1.3.0",
|
||||
"public-ip": "^4.0.2",
|
||||
"speedtest-net": "^1.6.0",
|
||||
"systeminformation": "^4.26.10"
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"maxmind": "^5.0.5"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -37,10 +35,34 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
]
|
||||
],
|
||||
"keywords": [
|
||||
"network diagnostics",
|
||||
"ping",
|
||||
"port check",
|
||||
"speed test",
|
||||
"network interface",
|
||||
"public IP retrieval",
|
||||
"Cloudflare speed test",
|
||||
"network performance",
|
||||
"network utility",
|
||||
"TypeScript"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smartnetwork#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smartnetwork.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6",
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/push.rocks/smartnetwork/issues"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+9040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
# Project Analysis
|
||||
|
||||
## Architecture Overview
|
||||
This is a comprehensive network diagnostics toolkit. The main entry point is the `SmartNetwork` class which orchestrates all functionality. System-dependent operations are delegated to a Rust binary via JSON-over-stdin/stdout IPC using `@push.rocks/smartrust`.
|
||||
|
||||
Key features:
|
||||
- Speed testing via Cloudflare (parallelizable with duration support) — pure TS
|
||||
- ICMP ping with statistics — Rust binary (surge-ping)
|
||||
- Port availability checks (local and remote) — Rust binary (tokio TCP / socket2)
|
||||
- Network gateway discovery — Rust binary (parses /proc/net/route on Linux)
|
||||
- Public IP retrieval — pure TS
|
||||
- DNS resolution via smartdns — pure TS
|
||||
- HTTP endpoint health checks — pure TS
|
||||
- Traceroute — Rust binary (raw ICMP sockets via socket2)
|
||||
|
||||
## Rust Binary Architecture (v4.5.0+)
|
||||
|
||||
### Binary: `rustnetwork`
|
||||
- Located in `rust/crates/rustnetwork/`
|
||||
- Cross-compiled for linux_amd64 and linux_arm64 via `@git.zone/tsrust`
|
||||
- Output: `dist_rust/rustnetwork_linux_amd64`, `dist_rust/rustnetwork_linux_arm64`
|
||||
- IPC mode: `--management` flag for JSON-over-stdin/stdout
|
||||
|
||||
### IPC Commands
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `healthPing` | Liveness check (returns `{ pong: true }`) |
|
||||
| `ping` | ICMP ping with count/timeout, returns stats |
|
||||
| `traceroute` | Hop-by-hop latency via raw ICMP sockets |
|
||||
| `tcpPortCheck` | TCP connect probe to remote host:port |
|
||||
| `isLocalPortFree` | Bind test on IPv4 + IPv6 |
|
||||
| `defaultGateway` | Parse /proc/net/route for default interface |
|
||||
|
||||
### TypeScript Bridge
|
||||
- `ts/smartnetwork.classes.rustbridge.ts` — Singleton `RustNetworkBridge` wrapping `smartrust.RustBridge`
|
||||
- `SmartNetwork` class has `start()`/`stop()` lifecycle for the bridge
|
||||
- `ensureBridge()` auto-starts on first use
|
||||
|
||||
### Build Pipeline
|
||||
- `pnpm build` = `tsbuild tsfolders --allowimplicitany && tsrust`
|
||||
- Targets configured in `.smartconfig.json` under `@git.zone/tsrust`
|
||||
|
||||
## Key Components
|
||||
|
||||
### SmartNetwork Class
|
||||
- Central orchestrator for all network operations
|
||||
- Requires `start()` before using ping/traceroute/port/gateway operations
|
||||
- Supports caching via `cacheTtl` option for gateway and public IP lookups
|
||||
- Plugin architecture for extensibility
|
||||
|
||||
### RustNetworkBridge Class
|
||||
- Singleton pattern via `getInstance()`
|
||||
- Binary search: dist_rust/ (platform-suffixed) > rust/target/release/ > rust/target/debug/
|
||||
- `searchSystemPath: false` — only looks in local paths
|
||||
|
||||
### CloudflareSpeed Class
|
||||
- Handles internet speed testing using Cloudflare's infrastructure
|
||||
- Supports parallel streams and customizable test duration
|
||||
- Upload speed measurement uses server-timing header with client-side timing fallback
|
||||
|
||||
### Error Handling
|
||||
- Custom `NetworkError` and `TimeoutError` classes for better error context
|
||||
- Error codes follow Node.js conventions (ENOTSUP, EINVAL, ETIMEOUT)
|
||||
- Ping/port methods gracefully degrade on errors (return alive=false / false)
|
||||
|
||||
### Logging
|
||||
- Global logger interface for consistent logging across the codebase
|
||||
- Replaceable logger implementation (defaults to console)
|
||||
|
||||
### Statistics Helpers
|
||||
- Utility functions for statistical calculations (average, median, quartile, jitter)
|
||||
- Used by speed testing; ping statistics now computed server-side in Rust
|
||||
|
||||
## Dependencies
|
||||
- **Runtime**: `@push.rocks/smartrust` (IPC bridge), `@push.rocks/smartdns` (DNS resolution)
|
||||
- **Removed in v4.5.0**: `@push.rocks/smartping`, `@push.rocks/smartstring`, `isopen`, `systeminformation`
|
||||
- **Dev**: `@git.zone/tsrust` (Rust cross-compilation)
|
||||
|
||||
## Testing
|
||||
- `test/test.ts` — Core smoke tests (speed, ports, gateways, public IPs)
|
||||
- `test/test.ping.ts` — ICMP ping tests (requires CAP_NET_RAW for alive=true)
|
||||
- `test/test.ports.ts` — Comprehensive port testing (27 tests)
|
||||
- `test/test.features.ts` — DNS, HTTP health check, traceroute, multi-ping, plugins, caching
|
||||
- All tests require `start()`/`stop()` lifecycle for the Rust bridge
|
||||
|
||||
## Technical Details
|
||||
- ESM-only package (module type)
|
||||
- TypeScript with strict typing
|
||||
- Node built-in imports use `node:` prefix throughout
|
||||
- Uses native Node.js modules for HTTP/HTTPS and os.networkInterfaces()
|
||||
- Rust binary requires no elevated privileges for port checks; ICMP ping needs CAP_NET_RAW or appropriate ping_group_range
|
||||
|
||||
## Design Patterns
|
||||
- Singleton pattern for RustNetworkBridge
|
||||
- Factory pattern for plugin registration
|
||||
- Caching pattern with TTL for expensive operations
|
||||
- Promise-based async/await throughout
|
||||
- Error propagation with custom error types
|
||||
- Graceful degradation when ICMP permissions unavailable
|
||||
@@ -1,57 +1,509 @@
|
||||
# @pushrocks/smartnetwork
|
||||
network diagnostics
|
||||
# @push.rocks/smartnetwork 🌐
|
||||
|
||||
## Availabililty and Links
|
||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@pushrocks/smartnetwork)
|
||||
* [gitlab.com (source)](https://gitlab.com/pushrocks/smartnetwork)
|
||||
* [github.com (source mirror)](https://github.com/pushrocks/smartnetwork)
|
||||
* [docs (typedoc)](https://pushrocks.gitlab.io/smartnetwork/)
|
||||
Comprehensive network diagnostics and intelligence for Node.js — speed tests, port scanning, ICMP ping, traceroute, DNS, IP & domain RDAP, ASN lookups, geolocation, and DNS record enrichment in a single, promise-based toolkit.
|
||||
|
||||
## Status for master
|
||||
## Issue Reporting and Security
|
||||
|
||||
Status Category | Status Badge
|
||||
-- | --
|
||||
GitLab Pipelines | [](https://lossless.cloud)
|
||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
||||
npm | [](https://lossless.cloud)
|
||||
Snyk | [](https://lossless.cloud)
|
||||
TypeScript Support | [](https://lossless.cloud)
|
||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
||||
Code Style | [](https://lossless.cloud)
|
||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
||||
Platform support | [](https://lossless.cloud) [](https://lossless.cloud)
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Usage
|
||||
## 🚀 Install
|
||||
|
||||
```bash
|
||||
pnpm install @push.rocks/smartnetwork --save
|
||||
```
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
**@push.rocks/smartnetwork** is your Swiss Army knife for network diagnostics in Node.js. Whether you're building monitoring dashboards, investigating IP/domain ownership, or debugging connectivity — this library has you covered with a clean, async API and zero-config setup.
|
||||
|
||||
Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability. Everything else — speed tests, DNS, public IP discovery, IP & domain intelligence, HTTP health checks — runs in pure TypeScript.
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
| Category | Capabilities |
|
||||
|----------|-------------|
|
||||
| 🏎️ **Speed Testing** | Download/upload benchmarks via Cloudflare's global infrastructure |
|
||||
| 🔌 **Port Management** | Local/remote port checks, find free ports (sequential or random), exclusion lists |
|
||||
| 📡 **Connectivity** | ICMP ping with stats, hop-by-hop traceroute, HTTP/HTTPS health checks |
|
||||
| 🌍 **DNS** | A, AAAA, MX resolution with system-first + DoH fallback strategy |
|
||||
| 🔍 **IP Intelligence** | ASN, organization, geolocation, RDAP registration — all from free public sources |
|
||||
| 🏢 **Domain Intelligence** | RDAP registrar/registrant, nameservers, events, DNSSEC, plus DNS enrichment (A/AAAA/MX/TXT/SOA) |
|
||||
| 🖥️ **Network Discovery** | Interfaces, default gateway, public IPv4/IPv6 |
|
||||
| ⚡ **Caching** | Built-in TTL cache for expensive lookups |
|
||||
| 🔧 **Extensible** | Plugin architecture for custom functionality |
|
||||
| 📝 **TypeScript** | Full type definitions, ESM-native |
|
||||
|
||||
## 💻 Usage
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import * as smartnetwork from 'smartnetwork';
|
||||
const testSmartNetwork = new smartnetwork.SmartNetwork();
|
||||
const run = async () => {
|
||||
// measure average speed over a period of 5 seconds
|
||||
// the structure of speedResult is self explanatory using TypeScript (or the linked TypeDoc above)
|
||||
const speedResult = testSmartNetwork.getSpeed(5000);
|
||||
import { SmartNetwork } from '@push.rocks/smartnetwork';
|
||||
|
||||
//
|
||||
const isLocalPortAvailable: boolean = await testSmartNetwork.isLocalPortAvailable(1234);
|
||||
const isRemotePortAvailable: boolean = await testSmartNetwork.isRemotePortAvailable(
|
||||
'google.com:80'
|
||||
);
|
||||
const isRemotePortAvailable: boolean = await testSmartNetwork.isRemotePortAvailable(
|
||||
'google.com',
|
||||
80
|
||||
);
|
||||
const network = new SmartNetwork();
|
||||
|
||||
// Start the Rust bridge (auto-started on first use, but explicit start is recommended)
|
||||
await network.start();
|
||||
|
||||
// ... use the network instance ...
|
||||
|
||||
// Clean up when done — tears down both the Rust bridge and the smartdns backend
|
||||
await network.stop();
|
||||
```
|
||||
|
||||
Enable caching for repeated lookups:
|
||||
|
||||
```typescript
|
||||
const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s TTL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🕵️ IP Intelligence
|
||||
|
||||
Get ASN, organization, geolocation, and RDAP registration data for any IPv4 address. Combines three free public data sources in parallel:
|
||||
|
||||
- **RDAP** — direct queries to RIRs (RIPE, ARIN, APNIC, LACNIC, AFRINIC) for authoritative registration data
|
||||
- **Team Cymru DNS** — fast ASN resolution via DNS TXT records
|
||||
- **MaxMind GeoLite2** — in-memory MMDB databases (auto-downloaded from CDN, periodically refreshed)
|
||||
|
||||
```typescript
|
||||
const intel = await network.getIpIntelligence('8.8.8.8');
|
||||
|
||||
console.log(intel);
|
||||
// {
|
||||
// asn: 15169,
|
||||
// asnOrg: 'Google LLC',
|
||||
// registrantOrg: 'Google LLC',
|
||||
// registrantCountry: 'United States',
|
||||
// networkRange: '8.8.8.0/24',
|
||||
// abuseContact: null,
|
||||
// country: null,
|
||||
// countryCode: 'US',
|
||||
// city: null,
|
||||
// latitude: 37.751,
|
||||
// longitude: -97.822,
|
||||
// accuracyRadius: null,
|
||||
// timezone: 'America/Chicago'
|
||||
// }
|
||||
```
|
||||
|
||||
Works great for your own IP too:
|
||||
|
||||
```typescript
|
||||
const publicIps = await network.getPublicIps();
|
||||
if (publicIps.v4) {
|
||||
const myIntel = await network.getIpIntelligence(publicIps.v4);
|
||||
console.log(`You're on AS${myIntel.asn} (${myIntel.asnOrg}) in ${myIntel.city}, ${myIntel.countryCode}`);
|
||||
}
|
||||
```
|
||||
|
||||
The `IIpIntelligenceResult` interface:
|
||||
|
||||
```typescript
|
||||
interface IIpIntelligenceResult {
|
||||
// ASN (Team Cymru primary, MaxMind fallback)
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
|
||||
// Registration (RDAP)
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
networkRange: string | null; // CIDR or range
|
||||
abuseContact: string | null; // abuse email from RDAP
|
||||
|
||||
// Geolocation (MaxMind GeoLite2)
|
||||
country: string | null;
|
||||
countryCode: string | null; // ISO 3166-1 alpha-2
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadius: number | null; // km
|
||||
timezone: string | null; // IANA timezone
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 The GeoLite2 databases are fetched into memory from jsDelivr CDN on first use (~32 MB total). They auto-refresh in the background every 7 days (configurable via `IpIntelligence` options). No disk I/O, no API keys, no rate limits.
|
||||
|
||||
---
|
||||
|
||||
### 🏢 Domain Intelligence
|
||||
|
||||
Get comprehensive domain registration and DNS data. Runs an **RDAP layer** and a **DNS layer** in parallel, then merges results:
|
||||
|
||||
- **RDAP** — queries the correct registry RDAP server (discovered via the [IANA DNS bootstrap](https://data.iana.org/rdap/dns.json)) for registrar, registrant, events, status flags, DNSSEC, and abuse contacts
|
||||
- **DNS** — queries A, AAAA, NS, MX, TXT, and SOA records via `@push.rocks/smartdns` (system resolver first, DoH fallback)
|
||||
|
||||
For gTLDs (`.com`, `.org`, `.net`, etc.) you get the full picture from both layers. For ccTLDs without RDAP support (`.de`, `.fr`, `.nl`, etc.), the DNS layer fills in nameservers, resolved IPs, MX, TXT, and SOA — so you still get useful intelligence even when RDAP isn't available.
|
||||
|
||||
```typescript
|
||||
const info = await network.getDomainIntelligence('google.com');
|
||||
|
||||
console.log(info);
|
||||
// {
|
||||
// domain: 'google.com',
|
||||
// handle: '2138514_DOMAIN_COM-VRSN',
|
||||
// status: ['client delete prohibited', 'server transfer prohibited', ...],
|
||||
// registrationDate: '1997-09-15T04:00:00Z',
|
||||
// expirationDate: '2028-09-14T04:00:00Z',
|
||||
// lastChangedDate: '2019-09-09T15:39:04Z',
|
||||
// registrarName: 'MarkMonitor Inc.',
|
||||
// registrarIanaId: 292,
|
||||
// registrantOrg: null, // often redacted under GDPR
|
||||
// registrantCountry: null,
|
||||
// abuseEmail: 'abusecomplaints@markmonitor.com',
|
||||
// abusePhone: '+1.2086851750',
|
||||
// nameservers: ['ns1.google.com', 'ns2.google.com', 'ns3.google.com', 'ns4.google.com'],
|
||||
// dnssec: false,
|
||||
// nameserversSource: 'rdap',
|
||||
// resolvedIpv4: ['142.251.20.102', '142.251.20.113', ...],
|
||||
// resolvedIpv6: ['2a00:1450:4001:c15::8b', ...],
|
||||
// mxRecords: [{ priority: 10, exchange: 'smtp.google.com' }],
|
||||
// txtRecords: ['v=spf1 include:_spf.google.com ~all', ...],
|
||||
// soaRecord: 'ns1.google.com dns-admin.google.com 895796075 900 900 1800 60'
|
||||
// }
|
||||
```
|
||||
|
||||
Works with ccTLDs that don't have RDAP (like `.de`):
|
||||
|
||||
```typescript
|
||||
const deInfo = await network.getDomainIntelligence('bund.de');
|
||||
console.log(deInfo.registrarName); // null (no .de RDAP)
|
||||
console.log(deInfo.nameservers); // ['bamberg.bund.de', 'argon.bund.de', ...]
|
||||
console.log(deInfo.nameserversSource); // 'dns' — nameservers came from DNS, not RDAP
|
||||
console.log(deInfo.resolvedIpv4); // ['80.245.156.34']
|
||||
console.log(deInfo.mxRecords); // [{priority: 10, exchange: 'mx1.bund.de'}, ...]
|
||||
```
|
||||
|
||||
Handles IDN (internationalized domain names) automatically:
|
||||
|
||||
```typescript
|
||||
const idn = await network.getDomainIntelligence('münchen.de');
|
||||
console.log(idn.domain); // 'xn--mnchen-3ya.de' — normalized to punycode
|
||||
```
|
||||
|
||||
The `IDomainIntelligenceResult` interface:
|
||||
|
||||
```typescript
|
||||
interface IDomainIntelligenceResult {
|
||||
domain: string | null; // normalized ASCII form
|
||||
handle: string | null; // registry handle
|
||||
status: string[] | null; // EPP status codes
|
||||
|
||||
// Registration lifecycle (ISO 8601)
|
||||
registrationDate: string | null;
|
||||
expirationDate: string | null;
|
||||
lastChangedDate: string | null;
|
||||
|
||||
// Registrar
|
||||
registrarName: string | null;
|
||||
registrarIanaId: number | null;
|
||||
|
||||
// Registrant (often redacted under GDPR)
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
|
||||
// Abuse contact
|
||||
abuseEmail: string | null;
|
||||
abusePhone: string | null;
|
||||
|
||||
// Technical
|
||||
nameservers: string[] | null;
|
||||
dnssec: boolean | null; // secureDNS.delegationSigned from RDAP
|
||||
nameserversSource: 'rdap' | 'dns' | null;
|
||||
|
||||
// DNS enrichment
|
||||
resolvedIpv4: string[] | null; // A records
|
||||
resolvedIpv6: string[] | null; // AAAA records
|
||||
mxRecords: { priority: number | null; exchange: string }[] | null;
|
||||
txtRecords: string[] | null; // SPF, DKIM, site verification, etc.
|
||||
soaRecord: string | null; // raw SOA value
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 **RDAP vs DNS nameservers**: When RDAP is available (most gTLDs), `nameservers` comes from the registry's authoritative parent-zone delegation (`nameserversSource: 'rdap'`). When RDAP is unavailable (many ccTLDs), the DNS layer fills in nameservers via NS record resolution (`nameserversSource: 'dns'`). The `nameserversSource` field tells you which source won.
|
||||
|
||||
---
|
||||
|
||||
### 🏎️ Speed Testing
|
||||
|
||||
Measure network performance via Cloudflare's global infrastructure:
|
||||
|
||||
```typescript
|
||||
const result = await network.getSpeed();
|
||||
console.log(`⬇️ Download: ${result.downloadSpeed} Mbps`);
|
||||
console.log(`⬆️ Upload: ${result.uploadSpeed} Mbps`);
|
||||
|
||||
// Advanced: parallel streams + fixed duration
|
||||
const advanced = await network.getSpeed({
|
||||
parallelStreams: 3,
|
||||
duration: 5,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔌 Port Management
|
||||
|
||||
#### Check Local Port Availability
|
||||
|
||||
Checks both IPv4 and IPv6:
|
||||
|
||||
```typescript
|
||||
const isUnused = await network.isLocalPortUnused(8080);
|
||||
console.log(isUnused ? '✅ Port 8080 is free' : '❌ Port 8080 is in use');
|
||||
```
|
||||
|
||||
#### Find Free Port in Range
|
||||
|
||||
```typescript
|
||||
// First available
|
||||
const port = await network.findFreePort(3000, 3100);
|
||||
|
||||
// Random pick (avoids clustering)
|
||||
const randomPort = await network.findFreePort(3000, 3100, { randomize: true });
|
||||
|
||||
// With exclusions
|
||||
const port2 = await network.findFreePort(3000, 3100, {
|
||||
randomize: true,
|
||||
exclude: [3000, 3001, 3005],
|
||||
});
|
||||
```
|
||||
|
||||
#### Check Remote Port
|
||||
|
||||
```typescript
|
||||
// Simple
|
||||
const isOpen = await network.isRemotePortAvailable('example.com', 443);
|
||||
|
||||
// With retries and timeout
|
||||
const isOpen2 = await network.isRemotePortAvailable('example.com', {
|
||||
port: 443,
|
||||
retries: 3,
|
||||
timeout: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📡 Ping & Traceroute
|
||||
|
||||
```typescript
|
||||
// Simple ping
|
||||
const ping = await network.ping('google.com');
|
||||
console.log(`Alive: ${ping.alive}, RTT: ${ping.time} ms`);
|
||||
|
||||
// Multi-ping with statistics
|
||||
const stats = await network.ping('google.com', { count: 10, timeout: 2000 });
|
||||
console.log(`📊 min=${stats.min} avg=${stats.avg.toFixed(1)} max=${stats.max} loss=${stats.packetLoss}%`);
|
||||
|
||||
// Traceroute
|
||||
const hops = await network.traceroute('google.com', { maxHops: 20 });
|
||||
hops.forEach(hop => {
|
||||
const rtt = hop.rtt === null ? '*' : `${hop.rtt} ms`;
|
||||
console.log(` ${hop.ttl}\t${hop.ip}\t${rtt}`);
|
||||
});
|
||||
```
|
||||
|
||||
> ⚠️ ICMP ping requires `CAP_NET_RAW` or appropriate `ping_group_range` sysctl on Linux.
|
||||
|
||||
---
|
||||
|
||||
### 🌍 DNS Resolution
|
||||
|
||||
Uses `@push.rocks/smartdns` with a system-first strategy and automatic DoH (DNS-over-HTTPS) fallback:
|
||||
|
||||
```typescript
|
||||
const dns = await network.resolveDns('example.com');
|
||||
console.log('A records:', dns.A); // ['93.184.216.34']
|
||||
console.log('AAAA records:', dns.AAAA); // ['2606:2800:220:1:248:1893:25c8:1946']
|
||||
dns.MX.forEach(mx => {
|
||||
console.log(`📧 ${mx.exchange} (priority ${mx.priority})`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🏥 HTTP/HTTPS Health Checks
|
||||
|
||||
```typescript
|
||||
const health = await network.checkEndpoint('https://api.example.com/health', {
|
||||
timeout: 5000,
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
console.log(`Status: ${health.status}, RTT: ${health.rtt.toFixed(0)} ms`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🖥️ Network Interfaces & Public IPs
|
||||
|
||||
```typescript
|
||||
// All interfaces
|
||||
const gateways = await network.getGateways();
|
||||
Object.entries(gateways).forEach(([name, ifaces]) => {
|
||||
console.log(`🔌 ${name}:`);
|
||||
ifaces.forEach(i => console.log(` ${i.family}: ${i.address}`));
|
||||
});
|
||||
|
||||
// Default gateway
|
||||
const gw = await network.getDefaultGateway();
|
||||
if (gw) {
|
||||
console.log(`🌐 Gateway IPv4: ${gw.ipv4.address}`);
|
||||
}
|
||||
|
||||
// Public IPs (multiple fallback services: ipify, ident.me, seeip, icanhazip)
|
||||
const publicIps = await network.getPublicIps();
|
||||
console.log(`🌍 IPv4: ${publicIps.v4 || 'N/A'}`);
|
||||
console.log(`🌍 IPv6: ${publicIps.v6 || 'N/A'}`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Caching
|
||||
|
||||
Caching applies to `getGateways()`, `getPublicIps()`, `getIpIntelligence()`, and `getDomainIntelligence()`:
|
||||
|
||||
```typescript
|
||||
const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s
|
||||
|
||||
const ips1 = await network.getPublicIps(); // fetches
|
||||
const ips2 = await network.getPublicIps(); // cache hit ⚡
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🚨 Error Handling
|
||||
|
||||
```typescript
|
||||
import { SmartNetwork, NetworkError, TimeoutError } from '@push.rocks/smartnetwork';
|
||||
|
||||
try {
|
||||
await network.isRemotePortAvailable('example.com', { protocol: 'udp' });
|
||||
} catch (error) {
|
||||
if (error instanceof NetworkError) {
|
||||
console.error(`${error.message} (code: ${error.code})`); // ENOTSUP
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error codes: `EINVAL` (invalid argument), `ENOTSUP` (not supported), `ETIMEOUT` (timeout).
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Plugin Architecture
|
||||
|
||||
```typescript
|
||||
class MyPlugin {
|
||||
constructor(private network: SmartNetwork) {}
|
||||
async doStuff() { /* ... */ }
|
||||
}
|
||||
|
||||
SmartNetwork.registerPlugin('myPlugin', MyPlugin);
|
||||
|
||||
const PluginClass = SmartNetwork.pluginsRegistry.get('myPlugin');
|
||||
const plugin = new PluginClass(network);
|
||||
await plugin.doStuff();
|
||||
|
||||
SmartNetwork.unregisterPlugin('myPlugin');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📝 Custom Logging
|
||||
|
||||
Replace the default `console` logger:
|
||||
|
||||
```typescript
|
||||
import { setLogger } from '@push.rocks/smartnetwork';
|
||||
|
||||
setLogger({
|
||||
debug: (msg) => myLogger.debug(msg),
|
||||
info: (msg) => myLogger.info(msg),
|
||||
warn: (msg) => myLogger.warn(msg),
|
||||
error: (msg) => myLogger.error(msg),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Advanced Example: Network Monitor
|
||||
|
||||
```typescript
|
||||
const monitor = async () => {
|
||||
const network = new SmartNetwork({ cacheTtl: 30000 });
|
||||
|
||||
// Check critical services
|
||||
const services = [
|
||||
{ name: 'Web', host: 'example.com', port: 443 },
|
||||
{ name: 'DB', host: 'db.internal', port: 5432 },
|
||||
];
|
||||
|
||||
for (const svc of services) {
|
||||
const up = await network.isRemotePortAvailable(svc.host, svc.port);
|
||||
console.log(`${svc.name}: ${up ? '✅ UP' : '❌ DOWN'}`);
|
||||
}
|
||||
|
||||
// Internet connectivity + latency
|
||||
const ping = await network.ping('8.8.8.8');
|
||||
console.log(`Internet: ${ping.alive ? '✅' : '❌'} (${ping.time} ms)`);
|
||||
|
||||
// Speed
|
||||
const speed = await network.getSpeed();
|
||||
console.log(`Speed: ⬇️ ${speed.downloadSpeed} / ⬆️ ${speed.uploadSpeed} Mbps`);
|
||||
|
||||
// Who am I?
|
||||
const ips = await network.getPublicIps();
|
||||
if (ips.v4) {
|
||||
const intel = await network.getIpIntelligence(ips.v4);
|
||||
console.log(`AS${intel.asn} (${intel.asnOrg}) — ${intel.city || intel.countryCode}`);
|
||||
}
|
||||
|
||||
// Domain recon
|
||||
const domain = await network.getDomainIntelligence('example.com');
|
||||
console.log(`Registrar: ${domain.registrarName}, expires: ${domain.expirationDate}`);
|
||||
console.log(`Nameservers (${domain.nameserversSource}):`, domain.nameservers);
|
||||
|
||||
await network.stop();
|
||||
};
|
||||
```
|
||||
|
||||
## Contribution
|
||||
## 📋 Full API Reference
|
||||
|
||||
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
|
||||
| Method | Description | Requires Rust |
|
||||
|--------|-------------|:---:|
|
||||
| `start()` / `stop()` | Start/stop the Rust bridge and smartdns backend | — |
|
||||
| `getSpeed(opts?)` | Cloudflare speed test (download + upload) | No |
|
||||
| `ping(host, opts?)` | ICMP ping with optional multi-ping stats | Yes |
|
||||
| `traceroute(host, opts?)` | Hop-by-hop network path analysis | Yes |
|
||||
| `isLocalPortUnused(port)` | Check if local port is free (IPv4+IPv6) | Yes |
|
||||
| `findFreePort(start, end, opts?)` | Find available port in range | Yes |
|
||||
| `isRemotePortAvailable(target, opts?)` | TCP port check on remote host | Yes |
|
||||
| `getGateways()` | List all network interfaces | No |
|
||||
| `getDefaultGateway()` | Get default gateway info | Yes |
|
||||
| `getPublicIps()` | Discover public IPv4/IPv6 (4 fallback services) | No |
|
||||
| `resolveDns(host)` | Resolve A, AAAA, MX records | No |
|
||||
| `checkEndpoint(url, opts?)` | HTTP/HTTPS health check with RTT | No |
|
||||
| `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | No |
|
||||
| `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC | No |
|
||||
|
||||
For further information read the linked docs at the top of this readme.
|
||||
## License and Legal Information
|
||||
|
||||
> MIT licensed | **©** [Lossless GmbH](https://lossless.gmbh)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
[](https://maintainedby.lossless.com)
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
Generated
+915
@@ -0,0 +1,915 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.181"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-net"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pnet_base"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe4cf6fb3ab38b68d01ab2aea03ed3d1132b4868fa4e06285f29f16da01c5f4c"
|
||||
dependencies = [
|
||||
"no-std-net",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pnet_macros"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pnet_macros_support"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eea925b72f4bd37f8eab0f221bbe4c78b63498350c983ffa9dd4bcde7e030f56"
|
||||
dependencies = [
|
||||
"pnet_base",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pnet_packet"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9a005825396b7fe7a38a8e288dbc342d5034dac80c15212436424fef8ea90ba"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"pnet_base",
|
||||
"pnet_macros",
|
||||
"pnet_macros_support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
|
||||
[[package]]
|
||||
name = "rustnetwork"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2 0.5.10",
|
||||
"surge-ping",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "surge-ping"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30498e9c9feba213c3df6ed675bdf75519ccbee493517e7225305898c86cac05"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"parking_lot",
|
||||
"pnet_packet",
|
||||
"rand",
|
||||
"socket2 0.6.2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
|
||||
@@ -0,0 +1,18 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/rustnetwork"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
surge-ping = "0.8"
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "rustnetwork"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "rustnetwork"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
surge-ping.workspace = true
|
||||
socket2.workspace = true
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GatewayAddress {
|
||||
pub family: String,
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GatewayInfo {
|
||||
pub interface_name: String,
|
||||
pub addresses: Vec<GatewayAddress>,
|
||||
}
|
||||
|
||||
/// Get the default gateway interface and its addresses.
|
||||
/// Linux-only: parses /proc/net/route to find the default route,
|
||||
/// then reads interface addresses from /proc/net/if_inet6 and /proc/net/fib_trie.
|
||||
pub fn get_default_gateway() -> Result<GatewayInfo, String> {
|
||||
let iface = get_default_interface()?;
|
||||
let addresses = get_interface_addresses(&iface)?;
|
||||
Ok(GatewayInfo {
|
||||
interface_name: iface,
|
||||
addresses,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse /proc/net/route to find the default route interface
|
||||
fn get_default_interface() -> Result<String, String> {
|
||||
let content =
|
||||
std::fs::read_to_string("/proc/net/route").map_err(|e| format!("Cannot read /proc/net/route: {e}"))?;
|
||||
|
||||
for line in content.lines().skip(1) {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() < 8 {
|
||||
continue;
|
||||
}
|
||||
let destination = fields[1];
|
||||
let flags = u32::from_str_radix(fields[3], 16).unwrap_or(0);
|
||||
// Destination 00000000 = default route, flags & 0x2 = RTF_GATEWAY
|
||||
if destination == "00000000" && (flags & 0x2) != 0 {
|
||||
return Ok(fields[0].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err("No default gateway found in /proc/net/route".to_string())
|
||||
}
|
||||
|
||||
/// Get IPv4 and IPv6 addresses for a given interface
|
||||
fn get_interface_addresses(iface: &str) -> Result<Vec<GatewayAddress>, String> {
|
||||
let mut addresses = Vec::new();
|
||||
|
||||
// IPv4: parse /proc/net/fib_trie or fallback to reading /sys/class/net/<iface>/...
|
||||
if let Ok(ipv4_addrs) = get_ipv4_addresses(iface) {
|
||||
for addr in ipv4_addrs {
|
||||
addresses.push(GatewayAddress {
|
||||
family: "IPv4".to_string(),
|
||||
address: addr.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6: parse /proc/net/if_inet6
|
||||
if let Ok(ipv6_addrs) = get_ipv6_addresses(iface) {
|
||||
for addr in ipv6_addrs {
|
||||
addresses.push(GatewayAddress {
|
||||
family: "IPv6".to_string(),
|
||||
address: addr.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
/// Get IPv4 addresses for an interface by reading /proc/net/fib_trie
|
||||
fn get_ipv4_addresses(iface: &str) -> Result<Vec<Ipv4Addr>, String> {
|
||||
// Simpler approach: use the ip command output or parse /sys/class/net
|
||||
// Let's read from /sys/class/net/<iface>/... via getifaddrs equivalent
|
||||
// Actually, let's parse /proc/net/fib_trie
|
||||
let content = std::fs::read_to_string("/proc/net/fib_trie")
|
||||
.map_err(|e| format!("Cannot read /proc/net/fib_trie: {e}"))?;
|
||||
|
||||
// Also need to correlate with interface. Simpler: read RTNETLINK via a different approach.
|
||||
// Fallback to a cleaner approach: parse `ip -4 addr show <iface>` equivalent via /proc
|
||||
|
||||
// Use /proc/net/if_inet6 for v6 and a different approach for v4:
|
||||
// Read all interface addresses by parsing the route table and ARP cache
|
||||
// Actually, the simplest reliable approach on Linux: use nix/libc getifaddrs
|
||||
// But to avoid extra deps, let's parse /proc/net/fib_trie looking for LOCAL entries
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
let mut in_local_table = false;
|
||||
let mut current_prefix: Option<String> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("Local:") {
|
||||
in_local_table = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("Main:") {
|
||||
in_local_table = false;
|
||||
continue;
|
||||
}
|
||||
if !in_local_table {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for lines like " |-- 192.168.1.0" (prefix) or "/32 host LOCAL" (entry)
|
||||
if trimmed.contains("|--") {
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
current_prefix = Some(parts.last().unwrap().to_string());
|
||||
}
|
||||
}
|
||||
if trimmed.contains("/32 host LOCAL") {
|
||||
if let Some(ref prefix) = current_prefix {
|
||||
if let Ok(addr) = prefix.parse::<Ipv4Addr>() {
|
||||
// Now verify this belongs to our interface
|
||||
// We need interface correlation — check via /sys
|
||||
addresses.push(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If fib_trie parsing yielded results, filter by interface
|
||||
// Read interface index mapping
|
||||
if !addresses.is_empty() {
|
||||
let filtered = filter_addresses_by_interface(iface, &addresses);
|
||||
if !filtered.is_empty() {
|
||||
return Ok(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try reading from /sys/class/net/<iface>/
|
||||
// Parse the operstate and try to extract from ARP
|
||||
get_ipv4_from_sys(iface)
|
||||
}
|
||||
|
||||
/// Filter addresses to those belonging to a specific interface
|
||||
fn filter_addresses_by_interface(iface: &str, candidates: &[Ipv4Addr]) -> Vec<Ipv4Addr> {
|
||||
let route_content = match std::fs::read_to_string("/proc/net/route") {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut iface_networks: Vec<(u32, u32)> = Vec::new(); // (network, mask)
|
||||
for line in route_content.lines().skip(1) {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() < 8 || fields[0] != iface {
|
||||
continue;
|
||||
}
|
||||
let dest = u32::from_str_radix(fields[1], 16).unwrap_or(0);
|
||||
let mask = u32::from_str_radix(fields[7], 16).unwrap_or(0);
|
||||
if dest != 0 && mask != 0 {
|
||||
iface_networks.push((dest, mask));
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|addr| {
|
||||
let octets = addr.octets();
|
||||
let addr_u32 = u32::from_le_bytes(octets); // /proc/net/route uses little-endian
|
||||
iface_networks
|
||||
.iter()
|
||||
.any(|(net, mask)| (addr_u32 & mask) == (net & mask))
|
||||
})
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fallback: get IPv4 address from /sys filesystem
|
||||
fn get_ipv4_from_sys(_iface: &str) -> Result<Vec<Ipv4Addr>, String> {
|
||||
// Fallback: return empty — TS side uses os.networkInterfaces() to enrich
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Get IPv6 addresses for an interface from /proc/net/if_inet6
|
||||
fn get_ipv6_addresses(iface: &str) -> Result<Vec<Ipv6Addr>, String> {
|
||||
let content = std::fs::read_to_string("/proc/net/if_inet6")
|
||||
.map_err(|e| format!("Cannot read /proc/net/if_inet6: {e}"))?;
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let dev_name = fields[5];
|
||||
if dev_name != iface {
|
||||
continue;
|
||||
}
|
||||
let hex_addr = fields[0];
|
||||
if hex_addr.len() != 32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse 32-char hex into IPv6 address
|
||||
if let Ok(addr) = parse_ipv6_hex(hex_addr) {
|
||||
addresses.push(addr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
/// Parse a 32-character hex string into an Ipv6Addr
|
||||
fn parse_ipv6_hex(hex: &str) -> Result<Ipv6Addr, String> {
|
||||
if hex.len() != 32 {
|
||||
return Err("Invalid hex length".to_string());
|
||||
}
|
||||
let mut segments = [0u16; 8];
|
||||
for (i, segment) in segments.iter_mut().enumerate() {
|
||||
let start = i * 4;
|
||||
let end = start + 4;
|
||||
*segment =
|
||||
u16::from_str_radix(&hex[start..end], 16).map_err(|e| format!("Invalid hex: {e}"))?;
|
||||
}
|
||||
Ok(Ipv6Addr::new(
|
||||
segments[0],
|
||||
segments[1],
|
||||
segments[2],
|
||||
segments[3],
|
||||
segments[4],
|
||||
segments[5],
|
||||
segments[6],
|
||||
segments[7],
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request received from TypeScript via stdin
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IpcRequest {
|
||||
pub id: String,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Response sent to TypeScript via stdout
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IpcResponse {
|
||||
pub id: String,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Unsolicited event sent to TypeScript via stdout (no id field)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IpcEvent {
|
||||
pub event: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
impl IpcResponse {
|
||||
pub fn success(id: String, result: serde_json::Value) -> Self {
|
||||
Self {
|
||||
id,
|
||||
success: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: String, message: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
mod gateway;
|
||||
mod ipc_types;
|
||||
mod management;
|
||||
mod ping;
|
||||
mod port_scan;
|
||||
mod traceroute;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "rustnetwork", about = "Network diagnostics binary")]
|
||||
struct Cli {
|
||||
/// Run in IPC management mode (JSON-over-stdin/stdout)
|
||||
#[arg(long)]
|
||||
management: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.management {
|
||||
// Set up tracing to stderr (stdout is reserved for IPC)
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
// Run the tokio runtime for the management loop
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to create tokio runtime");
|
||||
|
||||
runtime.block_on(management::management_loop());
|
||||
} else {
|
||||
eprintln!("Use --management for IPC mode");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use crate::ipc_types::{IpcEvent, IpcRequest, IpcResponse};
|
||||
use crate::{gateway, ping, port_scan, traceroute};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Write a JSON line to stdout (IPC channel to TypeScript)
|
||||
fn send_line(value: &impl serde::Serialize) {
|
||||
if let Ok(json) = serde_json::to_string(value) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Main management loop: reads JSON commands from stdin, dispatches to handlers
|
||||
pub async fn management_loop() {
|
||||
// Emit ready event
|
||||
let ready_event = IpcEvent {
|
||||
event: "ready".to_string(),
|
||||
data: json!({ "version": env!("CARGO_PKG_VERSION") }),
|
||||
};
|
||||
send_line(&ready_event);
|
||||
info!("Management mode ready");
|
||||
|
||||
// Set up stdin reader
|
||||
let stdin = tokio::io::stdin();
|
||||
let reader = BufReader::new(stdin);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
// Process lines
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
debug!("Received request: {}", &line);
|
||||
|
||||
// Parse the request
|
||||
let request: IpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
warn!("Invalid JSON request: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn handler task
|
||||
tokio::spawn(async move {
|
||||
let response = dispatch_command(&request).await;
|
||||
send_line(&response);
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
// stdin closed — parent process is gone
|
||||
info!("Stdin closed, shutting down");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reading stdin: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a command to the appropriate handler
|
||||
async fn dispatch_command(req: &IpcRequest) -> IpcResponse {
|
||||
match req.method.as_str() {
|
||||
"healthPing" => IpcResponse::success(req.id.clone(), json!({ "pong": true })),
|
||||
|
||||
"ping" => handle_ping(req).await,
|
||||
"traceroute" => handle_traceroute(req).await,
|
||||
"tcpPortCheck" => handle_tcp_port_check(req).await,
|
||||
"isLocalPortFree" => handle_is_local_port_free(req).await,
|
||||
"defaultGateway" => handle_default_gateway(req).await,
|
||||
|
||||
_ => IpcResponse::error(req.id.clone(), format!("Unknown method: {}", req.method)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ping(req: &IpcRequest) -> IpcResponse {
|
||||
let host = req.params.get("host").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let count = req.params.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
|
||||
let timeout_ms = req
|
||||
.params
|
||||
.get("timeoutMs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(5000);
|
||||
|
||||
if host.is_empty() {
|
||||
return IpcResponse::error(req.id.clone(), "Missing 'host' parameter".to_string());
|
||||
}
|
||||
|
||||
match ping::ping(host, count, timeout_ms).await {
|
||||
Ok(result) => {
|
||||
let times: Vec<serde_json::Value> = result
|
||||
.times
|
||||
.iter()
|
||||
.map(|t| {
|
||||
if t.is_nan() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!(t)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
IpcResponse::success(
|
||||
req.id.clone(),
|
||||
json!({
|
||||
"alive": result.alive,
|
||||
"times": times,
|
||||
"min": if result.min.is_nan() { serde_json::Value::Null } else { json!(result.min) },
|
||||
"max": if result.max.is_nan() { serde_json::Value::Null } else { json!(result.max) },
|
||||
"avg": if result.avg.is_nan() { serde_json::Value::Null } else { json!(result.avg) },
|
||||
"stddev": if result.stddev.is_nan() { serde_json::Value::Null } else { json!(result.stddev) },
|
||||
"packetLoss": result.packet_loss,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(e) => IpcResponse::error(req.id.clone(), e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_traceroute(req: &IpcRequest) -> IpcResponse {
|
||||
let host = req.params.get("host").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let max_hops = req
|
||||
.params
|
||||
.get("maxHops")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(30) as u8;
|
||||
let timeout_ms = req
|
||||
.params
|
||||
.get("timeoutMs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(5000);
|
||||
|
||||
if host.is_empty() {
|
||||
return IpcResponse::error(req.id.clone(), "Missing 'host' parameter".to_string());
|
||||
}
|
||||
|
||||
match traceroute::traceroute(host, max_hops, timeout_ms).await {
|
||||
Ok(hops) => {
|
||||
let hop_values: Vec<serde_json::Value> = hops
|
||||
.iter()
|
||||
.map(|h| {
|
||||
json!({
|
||||
"ttl": h.ttl,
|
||||
"ip": h.ip.as_deref().unwrap_or("*"),
|
||||
"rtt": h.rtt,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
IpcResponse::success(req.id.clone(), json!({ "hops": hop_values }))
|
||||
}
|
||||
Err(e) => IpcResponse::error(req.id.clone(), e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tcp_port_check(req: &IpcRequest) -> IpcResponse {
|
||||
let host = req.params.get("host").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let port = req.params.get("port").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
|
||||
let timeout_ms = req
|
||||
.params
|
||||
.get("timeoutMs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(5000);
|
||||
|
||||
if host.is_empty() {
|
||||
return IpcResponse::error(req.id.clone(), "Missing 'host' parameter".to_string());
|
||||
}
|
||||
if port == 0 {
|
||||
return IpcResponse::error(req.id.clone(), "Missing or invalid 'port' parameter".to_string());
|
||||
}
|
||||
|
||||
match port_scan::tcp_port_check(host, port, timeout_ms).await {
|
||||
Ok((is_open, latency_ms)) => IpcResponse::success(
|
||||
req.id.clone(),
|
||||
json!({
|
||||
"isOpen": is_open,
|
||||
"latencyMs": latency_ms,
|
||||
}),
|
||||
),
|
||||
Err(e) => IpcResponse::error(req.id.clone(), e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_is_local_port_free(req: &IpcRequest) -> IpcResponse {
|
||||
let port = req.params.get("port").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
|
||||
|
||||
if port == 0 {
|
||||
return IpcResponse::error(
|
||||
req.id.clone(),
|
||||
"Missing or invalid 'port' parameter".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Run the blocking port check on the blocking thread pool
|
||||
match tokio::task::spawn_blocking(move || port_scan::is_local_port_free(port)).await {
|
||||
Ok(Ok(free)) => IpcResponse::success(req.id.clone(), json!({ "free": free })),
|
||||
Ok(Err(e)) => IpcResponse::error(req.id.clone(), e),
|
||||
Err(e) => IpcResponse::error(req.id.clone(), format!("Task join error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_default_gateway(req: &IpcRequest) -> IpcResponse {
|
||||
match tokio::task::spawn_blocking(gateway::get_default_gateway).await {
|
||||
Ok(Ok(info)) => {
|
||||
let addresses: Vec<serde_json::Value> = info
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"family": a.family,
|
||||
"address": a.address,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
IpcResponse::success(
|
||||
req.id.clone(),
|
||||
json!({
|
||||
"interfaceName": info.interface_name,
|
||||
"addresses": addresses,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => IpcResponse::error(req.id.clone(), e),
|
||||
Err(e) => IpcResponse::error(req.id.clone(), format!("Task join error: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use surge_ping::{Client, Config, PingIdentifier, PingSequence, ICMP};
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PingResult {
|
||||
pub alive: bool,
|
||||
pub times: Vec<f64>,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub avg: f64,
|
||||
pub stddev: f64,
|
||||
pub packet_loss: f64,
|
||||
}
|
||||
|
||||
pub async fn ping(host: &str, count: u32, timeout_ms: u64) -> Result<PingResult, String> {
|
||||
let addr: IpAddr = resolve_host(host).await?;
|
||||
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let config = match addr {
|
||||
IpAddr::V4(_) => Config::default(),
|
||||
IpAddr::V6(_) => Config::builder().kind(ICMP::V6).build(),
|
||||
};
|
||||
let client = Client::new(&config).map_err(|e| format!("Failed to create ping client: {e}"))?;
|
||||
let mut pinger = client.pinger(addr, PingIdentifier(rand_u16())).await;
|
||||
|
||||
let mut times: Vec<f64> = Vec::with_capacity(count as usize);
|
||||
let mut alive_count: u32 = 0;
|
||||
|
||||
for seq in 0..count {
|
||||
let payload = vec![0u8; 56];
|
||||
let start = Instant::now();
|
||||
|
||||
match timeout(timeout_dur, pinger.ping(PingSequence(seq as u16), &payload)).await {
|
||||
Ok(Ok((_packet, rtt))) => {
|
||||
let ms = rtt.as_secs_f64() * 1000.0;
|
||||
times.push(ms);
|
||||
alive_count += 1;
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
times.push(f64::NAN);
|
||||
}
|
||||
Err(_) => {
|
||||
// timeout
|
||||
let _ = start; // suppress unused warning
|
||||
times.push(f64::NAN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let valid: Vec<f64> = times.iter().copied().filter(|t| !t.is_nan()).collect();
|
||||
let min = valid.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let max = valid.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let avg = if valid.is_empty() {
|
||||
f64::NAN
|
||||
} else {
|
||||
valid.iter().sum::<f64>() / valid.len() as f64
|
||||
};
|
||||
let stddev = if valid.is_empty() {
|
||||
f64::NAN
|
||||
} else {
|
||||
let variance = valid.iter().map(|v| (v - avg).powi(2)).sum::<f64>() / valid.len() as f64;
|
||||
variance.sqrt()
|
||||
};
|
||||
let packet_loss = ((count - alive_count) as f64 / count as f64) * 100.0;
|
||||
|
||||
Ok(PingResult {
|
||||
alive: alive_count > 0,
|
||||
times,
|
||||
min: if min.is_infinite() { f64::NAN } else { min },
|
||||
max: if max.is_infinite() { f64::NAN } else { max },
|
||||
avg,
|
||||
stddev,
|
||||
packet_loss,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_host(host: &str) -> Result<IpAddr, String> {
|
||||
// Try parsing as IP first
|
||||
if let Ok(addr) = host.parse::<IpAddr>() {
|
||||
return Ok(addr);
|
||||
}
|
||||
// DNS resolution
|
||||
let addrs = tokio::net::lookup_host(format!("{host}:0"))
|
||||
.await
|
||||
.map_err(|e| format!("DNS resolution failed for {host}: {e}"))?;
|
||||
|
||||
for addr in addrs {
|
||||
return Ok(addr.ip());
|
||||
}
|
||||
Err(format!("No addresses found for {host}"))
|
||||
}
|
||||
|
||||
fn rand_u16() -> u16 {
|
||||
// Simple random using current time
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
(now.subsec_nanos() % 65536) as u16
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
|
||||
/// Check if a remote TCP port is open
|
||||
pub async fn tcp_port_check(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_ms: u64,
|
||||
) -> Result<(bool, Option<f64>), String> {
|
||||
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
// Resolve host — treat DNS failure as "not open"
|
||||
let addr_str = format!("{host}:{port}");
|
||||
let addrs: Vec<SocketAddr> = match tokio::net::lookup_host(&addr_str).await {
|
||||
Ok(iter) => iter.collect(),
|
||||
Err(_) => return Ok((false, None)),
|
||||
};
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Ok((false, None));
|
||||
}
|
||||
|
||||
// Try each resolved address
|
||||
for addr in &addrs {
|
||||
let start = Instant::now();
|
||||
match timeout(timeout_dur, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(_stream)) => {
|
||||
let latency = start.elapsed().as_secs_f64() * 1000.0;
|
||||
return Ok((true, Some(latency)));
|
||||
}
|
||||
Ok(Err(_)) => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok((false, None))
|
||||
}
|
||||
|
||||
/// Check if a local port is free (both IPv4 and IPv6)
|
||||
pub fn is_local_port_free(port: u16) -> Result<bool, String> {
|
||||
// Check IPv4
|
||||
let ipv4_free = check_bind_ipv4(port)?;
|
||||
if !ipv4_free {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check IPv6
|
||||
let ipv6_free = check_bind_ipv6(port)?;
|
||||
Ok(ipv6_free)
|
||||
}
|
||||
|
||||
fn check_bind_ipv4(port: u16) -> Result<bool, String> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))
|
||||
.map_err(|e| format!("Failed to create IPv4 socket: {e}"))?;
|
||||
socket
|
||||
.set_reuse_address(true)
|
||||
.map_err(|e| format!("Failed to set SO_REUSEADDR: {e}"))?;
|
||||
|
||||
let addr: SocketAddr = format!("0.0.0.0:{port}")
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid address: {e}"))?;
|
||||
|
||||
match socket.bind(&addr.into()) {
|
||||
Ok(()) => {
|
||||
// Try to listen to fully test availability
|
||||
match socket.listen(1) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_bind_ipv6(port: u16) -> Result<bool, String> {
|
||||
let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))
|
||||
.map_err(|e| format!("Failed to create IPv6 socket: {e}"))?;
|
||||
socket
|
||||
.set_reuse_address(true)
|
||||
.map_err(|e| format!("Failed to set SO_REUSEADDR: {e}"))?;
|
||||
// Set IPV6_ONLY to avoid dual-stack interference
|
||||
socket
|
||||
.set_only_v6(true)
|
||||
.map_err(|e| format!("Failed to set IPV6_V6ONLY: {e}"))?;
|
||||
|
||||
let addr: SocketAddr = format!("[::]:{port}")
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid address: {e}"))?;
|
||||
|
||||
match socket.bind(&addr.into()) {
|
||||
Ok(()) => match socket.listen(1) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
},
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::io;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TracerouteHop {
|
||||
pub ttl: u8,
|
||||
pub ip: Option<String>,
|
||||
pub rtt: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn traceroute(
|
||||
host: &str,
|
||||
max_hops: u8,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Vec<TracerouteHop>, String> {
|
||||
let dest: IpAddr = resolve_host(host).await?;
|
||||
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
// Run blocking raw-socket traceroute on the blocking thread pool
|
||||
tokio::task::spawn_blocking(move || traceroute_blocking(dest, max_hops, timeout_dur))
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {e}"))?
|
||||
}
|
||||
|
||||
fn traceroute_blocking(
|
||||
dest: IpAddr,
|
||||
max_hops: u8,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<TracerouteHop>, String> {
|
||||
let mut hops = Vec::new();
|
||||
|
||||
for ttl in 1..=max_hops {
|
||||
match send_probe(dest, ttl, timeout) {
|
||||
Ok((ip, rtt)) => {
|
||||
let reached = ip.as_ref().map(|a| a == &dest.to_string()).unwrap_or(false);
|
||||
hops.push(TracerouteHop {
|
||||
ttl,
|
||||
ip,
|
||||
rtt: Some(rtt),
|
||||
});
|
||||
if reached {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ProbeError::Timeout) => {
|
||||
hops.push(TracerouteHop {
|
||||
ttl,
|
||||
ip: None,
|
||||
rtt: None,
|
||||
});
|
||||
}
|
||||
Err(ProbeError::Other(e)) => {
|
||||
hops.push(TracerouteHop {
|
||||
ttl,
|
||||
ip: None,
|
||||
rtt: None,
|
||||
});
|
||||
// Log but continue
|
||||
eprintln!("Probe error at TTL {ttl}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hops)
|
||||
}
|
||||
|
||||
enum ProbeError {
|
||||
Timeout,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
fn send_probe(dest: IpAddr, ttl: u8, timeout: Duration) -> Result<(Option<String>, f64), ProbeError> {
|
||||
let (domain, proto) = match dest {
|
||||
IpAddr::V4(_) => (Domain::IPV4, Protocol::ICMPV4),
|
||||
IpAddr::V6(_) => (Domain::IPV6, Protocol::ICMPV6),
|
||||
};
|
||||
|
||||
let sock = Socket::new(domain, Type::RAW, Some(proto))
|
||||
.map_err(|e| ProbeError::Other(format!("Socket creation failed: {e}")))?;
|
||||
|
||||
sock.set_ttl(ttl as u32)
|
||||
.map_err(|e| ProbeError::Other(format!("Failed to set TTL: {e}")))?;
|
||||
sock.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| ProbeError::Other(format!("Failed to set timeout: {e}")))?;
|
||||
|
||||
let dest_addr = match dest {
|
||||
IpAddr::V4(v4) => SocketAddr::new(IpAddr::V4(v4), 0),
|
||||
IpAddr::V6(v6) => SocketAddr::new(IpAddr::V6(v6), 0),
|
||||
};
|
||||
|
||||
// Build ICMP Echo Request packet
|
||||
let ident = (std::process::id() as u16) ^ (ttl as u16);
|
||||
let seq = ttl as u16;
|
||||
let packet = match dest {
|
||||
IpAddr::V4(_) => build_icmpv4_echo_request(ident, seq),
|
||||
IpAddr::V6(_) => build_icmpv6_echo_request(ident, seq),
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
sock.send_to(&packet, &dest_addr.into())
|
||||
.map_err(|e| ProbeError::Other(format!("Send failed: {e}")))?;
|
||||
|
||||
// Wait for response using MaybeUninit buffer as required by socket2
|
||||
let mut buf_uninit = [MaybeUninit::<u8>::uninit(); 512];
|
||||
loop {
|
||||
match sock.recv_from(&mut buf_uninit) {
|
||||
Ok((n, from_addr)) => {
|
||||
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
||||
// Safety: recv_from initialized the first n bytes
|
||||
let buf: &[u8] = unsafe {
|
||||
std::slice::from_raw_parts(buf_uninit.as_ptr() as *const u8, n)
|
||||
};
|
||||
let from_ip = match from_addr.as_socket() {
|
||||
Some(sa) => sa.ip().to_string(),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
|
||||
// Check if this response is for our probe
|
||||
match dest {
|
||||
IpAddr::V4(_) => {
|
||||
if is_relevant_icmpv4_response(buf, ident, seq) {
|
||||
return Ok((Some(from_ip), elapsed));
|
||||
}
|
||||
}
|
||||
IpAddr::V6(_) => {
|
||||
if is_relevant_icmpv6_response(buf, ident, seq) {
|
||||
return Ok((Some(from_ip), elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've exceeded timeout
|
||||
if start.elapsed() >= timeout {
|
||||
return Err(ProbeError::Timeout);
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut => {
|
||||
return Err(ProbeError::Timeout);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ProbeError::Other(format!("Recv error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an ICMPv4 response is relevant to our probe.
|
||||
/// It could be Echo Reply (type 0) or Time Exceeded (type 11).
|
||||
fn is_relevant_icmpv4_response(buf: &[u8], ident: u16, seq: u16) -> bool {
|
||||
// IPv4 header is at least 20 bytes, then ICMP follows
|
||||
if buf.len() < 20 {
|
||||
return false;
|
||||
}
|
||||
let ip_header_len = ((buf[0] & 0x0f) as usize) * 4;
|
||||
if buf.len() < ip_header_len + 8 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let icmp = &buf[ip_header_len..];
|
||||
let icmp_type = icmp[0];
|
||||
|
||||
match icmp_type {
|
||||
0 => {
|
||||
// Echo Reply: check ident and seq
|
||||
if icmp.len() < 8 {
|
||||
return false;
|
||||
}
|
||||
let reply_ident = u16::from_be_bytes([icmp[4], icmp[5]]);
|
||||
let reply_seq = u16::from_be_bytes([icmp[6], icmp[7]]);
|
||||
reply_ident == ident && reply_seq == seq
|
||||
}
|
||||
11 => {
|
||||
// Time Exceeded: the original IP packet + first 8 bytes of original ICMP are in payload
|
||||
// icmp[0]=type, [1]=code, [2-3]=checksum, [4-7]=unused, [8+]=original IP header+8 bytes
|
||||
if icmp.len() < 36 {
|
||||
// 8 (outer ICMP header) + 20 (inner IP header) + 8 (inner ICMP header)
|
||||
return false;
|
||||
}
|
||||
let inner_ip = &icmp[8..];
|
||||
let inner_ip_header_len = ((inner_ip[0] & 0x0f) as usize) * 4;
|
||||
if icmp.len() < 8 + inner_ip_header_len + 8 {
|
||||
return false;
|
||||
}
|
||||
let inner_icmp = &inner_ip[inner_ip_header_len..];
|
||||
// Check inner ICMP echo request ident and seq
|
||||
if inner_icmp[0] != 8 {
|
||||
// Not echo request
|
||||
return false;
|
||||
}
|
||||
let inner_ident = u16::from_be_bytes([inner_icmp[4], inner_icmp[5]]);
|
||||
let inner_seq = u16::from_be_bytes([inner_icmp[6], inner_icmp[7]]);
|
||||
inner_ident == ident && inner_seq == seq
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an ICMPv6 response is relevant to our probe
|
||||
fn is_relevant_icmpv6_response(buf: &[u8], ident: u16, seq: u16) -> bool {
|
||||
// ICMPv6: no IP header in raw socket recv (kernel strips it)
|
||||
if buf.len() < 8 {
|
||||
return false;
|
||||
}
|
||||
let icmp_type = buf[0];
|
||||
|
||||
match icmp_type {
|
||||
129 => {
|
||||
// Echo Reply
|
||||
let reply_ident = u16::from_be_bytes([buf[4], buf[5]]);
|
||||
let reply_seq = u16::from_be_bytes([buf[6], buf[7]]);
|
||||
reply_ident == ident && reply_seq == seq
|
||||
}
|
||||
3 => {
|
||||
// Time Exceeded: payload contains original IPv6 header + first bytes of original ICMPv6
|
||||
if buf.len() < 56 {
|
||||
// 8 (outer ICMPv6) + 40 (inner IPv6 header) + 8 (inner ICMPv6)
|
||||
return false;
|
||||
}
|
||||
let inner_icmp = &buf[48..]; // 8 + 40
|
||||
if inner_icmp[0] != 128 {
|
||||
// Not echo request
|
||||
return false;
|
||||
}
|
||||
let inner_ident = u16::from_be_bytes([inner_icmp[4], inner_icmp[5]]);
|
||||
let inner_seq = u16::from_be_bytes([inner_icmp[6], inner_icmp[7]]);
|
||||
inner_ident == ident && inner_seq == seq
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an ICMPv4 Echo Request packet
|
||||
fn build_icmpv4_echo_request(ident: u16, seq: u16) -> Vec<u8> {
|
||||
let mut pkt = vec![0u8; 64]; // 8 header + 56 payload
|
||||
pkt[0] = 8; // Type: Echo Request
|
||||
pkt[1] = 0; // Code
|
||||
// Checksum placeholder [2,3]
|
||||
pkt[4] = (ident >> 8) as u8;
|
||||
pkt[5] = (ident & 0xff) as u8;
|
||||
pkt[6] = (seq >> 8) as u8;
|
||||
pkt[7] = (seq & 0xff) as u8;
|
||||
|
||||
// Fill payload with pattern
|
||||
for i in 8..64 {
|
||||
pkt[i] = (i as u8) & 0xff;
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
let cksum = icmp_checksum(&pkt);
|
||||
pkt[2] = (cksum >> 8) as u8;
|
||||
pkt[3] = (cksum & 0xff) as u8;
|
||||
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Build an ICMPv6 Echo Request packet
|
||||
fn build_icmpv6_echo_request(ident: u16, seq: u16) -> Vec<u8> {
|
||||
let mut pkt = vec![0u8; 64];
|
||||
pkt[0] = 128; // Type: Echo Request
|
||||
pkt[1] = 0; // Code
|
||||
// Checksum [2,3] - kernel calculates for ICMPv6
|
||||
pkt[4] = (ident >> 8) as u8;
|
||||
pkt[5] = (ident & 0xff) as u8;
|
||||
pkt[6] = (seq >> 8) as u8;
|
||||
pkt[7] = (seq & 0xff) as u8;
|
||||
|
||||
for i in 8..64 {
|
||||
pkt[i] = (i as u8) & 0xff;
|
||||
}
|
||||
|
||||
// Note: ICMPv6 checksum is computed by the kernel when using raw sockets on Linux
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Calculate ICMP checksum
|
||||
fn icmp_checksum(data: &[u8]) -> u16 {
|
||||
let mut sum: u32 = 0;
|
||||
let mut i = 0;
|
||||
while i + 1 < data.len() {
|
||||
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
|
||||
i += 2;
|
||||
}
|
||||
if i < data.len() {
|
||||
sum += (data[i] as u32) << 8;
|
||||
}
|
||||
while sum >> 16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16);
|
||||
}
|
||||
!sum as u16
|
||||
}
|
||||
|
||||
async fn resolve_host(host: &str) -> Result<IpAddr, String> {
|
||||
if let Ok(addr) = host.parse::<IpAddr>() {
|
||||
return Ok(addr);
|
||||
}
|
||||
let addrs = tokio::net::lookup_host(format!("{host}:0"))
|
||||
.await
|
||||
.map_err(|e| format!("DNS resolution failed for {host}: {e}"))?;
|
||||
|
||||
for addr in addrs {
|
||||
return Ok(addr.ip());
|
||||
}
|
||||
Err(format!("No addresses found for {host}"))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartnetwork from '../ts/index.js';
|
||||
|
||||
let testSmartNetwork: smartnetwork.SmartNetwork;
|
||||
|
||||
tap.test('should create a SmartNetwork instance', async () => {
|
||||
testSmartNetwork = new smartnetwork.SmartNetwork();
|
||||
expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
|
||||
});
|
||||
|
||||
tap.test('should get domain intelligence for google.com', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('google.com');
|
||||
console.log('Domain Intelligence for google.com:', JSON.stringify(result, null, 2));
|
||||
|
||||
// RDAP fields
|
||||
expect(result.domain).toEqual('google.com');
|
||||
expect(result.registrarName).toBeTruthy();
|
||||
expect(result.nameservers).toBeTruthy();
|
||||
expect(Array.isArray(result.nameservers)).toBeTrue();
|
||||
expect(result.nameservers!.length).toBeGreaterThan(0);
|
||||
expect(result.registrationDate).toBeTruthy();
|
||||
expect(result.expirationDate).toBeTruthy();
|
||||
expect(Array.isArray(result.status)).toBeTrue();
|
||||
expect(result.nameserversSource).toEqual('rdap');
|
||||
|
||||
// DNS enrichment fields
|
||||
expect(result.resolvedIpv4).toBeTruthy();
|
||||
expect(result.resolvedIpv4!.length).toBeGreaterThan(0);
|
||||
expect(result.mxRecords).toBeTruthy();
|
||||
expect(result.mxRecords!.length).toBeGreaterThan(0);
|
||||
expect(result.txtRecords).toBeTruthy();
|
||||
expect(result.txtRecords!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should get domain intelligence for cloudflare.com', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('cloudflare.com');
|
||||
console.log('Domain Intelligence for cloudflare.com:', JSON.stringify(result, null, 2));
|
||||
|
||||
expect(result.domain).toEqual('cloudflare.com');
|
||||
expect(result.registrarName).toBeTruthy();
|
||||
expect(result.nameservers).toBeTruthy();
|
||||
expect(result.nameservers!.length).toBeGreaterThan(0);
|
||||
expect(result.registrationDate).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should get domain intelligence for wikipedia.org (.org TLD)', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('wikipedia.org');
|
||||
console.log('Domain Intelligence for wikipedia.org:', JSON.stringify(result, null, 2));
|
||||
|
||||
expect(result.domain).toEqual('wikipedia.org');
|
||||
expect(result.registrarName).toBeTruthy();
|
||||
expect(result.nameservers).toBeTruthy();
|
||||
expect(result.nameservers!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should normalize an FQDN with trailing dot', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('Google.Com.');
|
||||
expect(result.domain).toEqual('google.com');
|
||||
});
|
||||
|
||||
tap.test('should normalize an IDN to ASCII (punycode)', async () => {
|
||||
// The IDN "münchen.de" → "xn--mnchen-3ya.de"
|
||||
const result = await testSmartNetwork.getDomainIntelligence('münchen.de');
|
||||
console.log('IDN normalized to:', result.domain);
|
||||
// Even if the lookup fails (e.g. .de RDAP behavior), the normalization should still produce the ASCII form
|
||||
expect(result.domain).toEqual('xn--mnchen-3ya.de');
|
||||
});
|
||||
|
||||
tap.test('should handle unknown TLD gracefully', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('foo.invalidtld12345');
|
||||
console.log('Unknown TLD result:', JSON.stringify(result, null, 2));
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.registrarName).toBeNull();
|
||||
expect(result.nameservers).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('should handle malformed input gracefully', async () => {
|
||||
const r1 = await testSmartNetwork.getDomainIntelligence('');
|
||||
expect(r1).toBeTruthy();
|
||||
expect(r1.domain).toBeNull();
|
||||
|
||||
const r2 = await testSmartNetwork.getDomainIntelligence('not a domain at all');
|
||||
expect(r2).toBeTruthy();
|
||||
expect(r2.domain).toBeNull();
|
||||
|
||||
const r3 = await testSmartNetwork.getDomainIntelligence('nodot');
|
||||
expect(r3).toBeTruthy();
|
||||
expect(r3.domain).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('should parse MX records with priority and exchange', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('google.com');
|
||||
expect(result.mxRecords).toBeTruthy();
|
||||
for (const mx of result.mxRecords!) {
|
||||
expect(typeof mx.exchange).toEqual('string');
|
||||
expect(mx.exchange.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should populate nameservers via DNS for RDAP-less .de TLD', async () => {
|
||||
const result = await testSmartNetwork.getDomainIntelligence('bund.de');
|
||||
console.log('Domain Intelligence for bund.de:', JSON.stringify(result, null, 2));
|
||||
// .de has no RDAP in the IANA bootstrap, so RDAP fields are null
|
||||
expect(result.registrarName).toBeNull();
|
||||
// DNS layer should fill in nameservers + resolved IPs
|
||||
expect(result.nameservers).toBeTruthy();
|
||||
expect(result.nameservers!.length).toBeGreaterThan(0);
|
||||
expect(result.nameserversSource).toEqual('dns');
|
||||
expect(result.resolvedIpv4).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should use cache when cacheTtl is set', async () => {
|
||||
const cached = new smartnetwork.SmartNetwork({ cacheTtl: 60000 });
|
||||
const r1 = await cached.getDomainIntelligence('google.com');
|
||||
const r2 = await cached.getDomainIntelligence('google.com');
|
||||
expect(r1.registrarName).toEqual(r2.registrarName);
|
||||
expect(r1.registrationDate).toEqual(r2.registrationDate);
|
||||
await cached.stop();
|
||||
});
|
||||
|
||||
tap.test('should stop cleanly (tears down shared smartdns client)', async () => {
|
||||
// If the Rust-backed smartdns bridge wasn't destroyed, this test process
|
||||
// would hang at exit instead of completing.
|
||||
await testSmartNetwork.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,222 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartNetwork, NetworkError } from '../ts/index.js';
|
||||
import * as net from 'node:net';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
let sharedSn: SmartNetwork;
|
||||
|
||||
tap.test('setup: create and start SmartNetwork', async () => {
|
||||
sharedSn = new SmartNetwork();
|
||||
await sharedSn.start();
|
||||
});
|
||||
|
||||
// DNS resolution
|
||||
tap.test('resolveDns should return A records for localhost', async () => {
|
||||
const res = await sharedSn.resolveDns('localhost');
|
||||
expect(res.A.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(res.A)).toBeTrue();
|
||||
expect(Array.isArray(res.AAAA)).toBeTrue();
|
||||
expect(Array.isArray(res.MX)).toBeTrue();
|
||||
});
|
||||
|
||||
// DNS resolution edge cases and MX records
|
||||
tap.test('resolveDns should handle non-existent domains', async () => {
|
||||
const res = await sharedSn.resolveDns('no.such.domain.invalid');
|
||||
expect(Array.isArray(res.A)).toBeTrue();
|
||||
expect(Array.isArray(res.AAAA)).toBeTrue();
|
||||
expect(Array.isArray(res.MX)).toBeTrue();
|
||||
expect(res.A.length).toEqual(0);
|
||||
expect(res.AAAA.length).toEqual(0);
|
||||
expect(res.MX.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('resolveDns MX records for google.com', async () => {
|
||||
const res = await sharedSn.resolveDns('google.com');
|
||||
expect(Array.isArray(res.MX)).toBeTrue();
|
||||
if (res.MX.length > 0) {
|
||||
expect(typeof res.MX[0].exchange).toEqual('string');
|
||||
expect(typeof res.MX[0].priority).toEqual('number');
|
||||
}
|
||||
});
|
||||
|
||||
// HTTP endpoint health-check
|
||||
tap.test('checkEndpoint should return status and headers', async () => {
|
||||
const result = await sharedSn.checkEndpoint('https://example.com', { rejectUnauthorized: false });
|
||||
expect(result.status).toEqual(200);
|
||||
expect(typeof result.rtt).toEqual('number');
|
||||
expect(typeof result.headers).toEqual('object');
|
||||
expect(result.headers).toHaveProperty('content-type');
|
||||
});
|
||||
|
||||
// Traceroute
|
||||
tap.test('traceroute should return at least one hop', async () => {
|
||||
const hops = await sharedSn.traceroute('127.0.0.1');
|
||||
expect(Array.isArray(hops)).toBeTrue();
|
||||
expect(hops.length).toBeGreaterThanOrEqual(1);
|
||||
const hop = hops[0];
|
||||
expect(typeof hop.ttl).toEqual('number');
|
||||
expect(typeof hop.ip).toEqual('string');
|
||||
expect(hop.rtt === null || typeof hop.rtt === 'number').toBeTrue();
|
||||
});
|
||||
|
||||
// getSpeed options
|
||||
tap.test('getSpeed should accept options and return speeds', async () => {
|
||||
const opts = { parallelStreams: 2, duration: 1 };
|
||||
const result = await sharedSn.getSpeed(opts);
|
||||
expect(typeof result.downloadSpeed).toEqual('string');
|
||||
expect(typeof result.uploadSpeed).toEqual('string');
|
||||
expect(parseFloat(result.downloadSpeed)).toBeGreaterThan(0);
|
||||
expect(parseFloat(result.uploadSpeed)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Ping multiple count
|
||||
tap.test('ping with count > 1 should return stats', async () => {
|
||||
const stats = await sharedSn.ping('127.0.0.1', { count: 3 });
|
||||
expect(stats.count).toEqual(3);
|
||||
expect(Array.isArray(stats.times)).toBeTrue();
|
||||
expect(stats.times.length).toEqual(3);
|
||||
expect(typeof stats.min).toEqual('number');
|
||||
expect(typeof stats.max).toEqual('number');
|
||||
expect(typeof stats.avg).toEqual('number');
|
||||
expect(typeof stats.stddev).toEqual('number');
|
||||
expect(typeof stats.packetLoss).toEqual('number');
|
||||
expect(typeof stats.alive).toEqual('boolean');
|
||||
});
|
||||
|
||||
// Remote port UDP not supported
|
||||
tap.test('isRemotePortAvailable should throw on UDP', async () => {
|
||||
try {
|
||||
await sharedSn.isRemotePortAvailable('example.com', { protocol: 'udp' });
|
||||
throw new Error('Expected isRemotePortAvailable to throw for UDP');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('ENOTSUP');
|
||||
}
|
||||
});
|
||||
|
||||
// Plugin registry
|
||||
tap.test('should register and unregister plugin', async () => {
|
||||
class Dummy {}
|
||||
SmartNetwork.registerPlugin('dummy', Dummy);
|
||||
expect(SmartNetwork.pluginsRegistry.has('dummy')).toBeTrue();
|
||||
SmartNetwork.unregisterPlugin('dummy');
|
||||
expect(SmartNetwork.pluginsRegistry.has('dummy')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('getGateways should respect cacheTtl', async () => {
|
||||
const sn = new SmartNetwork({ cacheTtl: 1000 });
|
||||
const first = await sn.getGateways();
|
||||
const second = await sn.getGateways();
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
// Remote port checks: missing port should error
|
||||
tap.test('isRemotePortAvailable should require a port', async () => {
|
||||
try {
|
||||
await sharedSn.isRemotePortAvailable('example.com');
|
||||
throw new Error('Expected error when port is not specified');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
}
|
||||
});
|
||||
|
||||
// Remote port checks: detect open TCP port
|
||||
tap.test('isRemotePortAvailable should detect open TCP port via string target', async () => {
|
||||
const open = await sharedSn.isRemotePortAvailable('example.com:80');
|
||||
expect(open).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable should detect open TCP port via numeric arg', async () => {
|
||||
const open = await sharedSn.isRemotePortAvailable('example.com', 80);
|
||||
expect(open).toBeTrue();
|
||||
});
|
||||
|
||||
// Caching public IPs
|
||||
tap.test('getPublicIps should respect cacheTtl', async () => {
|
||||
const sn = new SmartNetwork({ cacheTtl: 1000 });
|
||||
const first = await sn.getPublicIps();
|
||||
const second = await sn.getPublicIps();
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
// Local port usage detection
|
||||
tap.test('isLocalPortUnused should detect used local port', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((res) => server.listen(0, res));
|
||||
const addr = server.address() as AddressInfo;
|
||||
const inUse = await sharedSn.isLocalPortUnused(addr.port);
|
||||
expect(inUse).toBeFalse();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
// findFreePort tests
|
||||
tap.test('findFreePort should find an available port in range', async () => {
|
||||
const freePort = await sharedSn.findFreePort(49152, 49200);
|
||||
expect(freePort).toBeGreaterThanOrEqual(49152);
|
||||
expect(freePort).toBeLessThanOrEqual(49200);
|
||||
|
||||
const isUnused = await sharedSn.isLocalPortUnused(freePort);
|
||||
expect(isUnused).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('findFreePort should return null when all ports are occupied', async () => {
|
||||
const servers = [];
|
||||
const startPort = 49300;
|
||||
const endPort = 49302;
|
||||
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((res) => server.listen(port, res));
|
||||
servers.push(server);
|
||||
}
|
||||
|
||||
const freePort = await sharedSn.findFreePort(startPort, endPort);
|
||||
expect(freePort).toBeNull();
|
||||
|
||||
await Promise.all(servers.map(s => new Promise<void>((res) => s.close(() => res()))));
|
||||
});
|
||||
|
||||
tap.test('findFreePort should validate port range', async () => {
|
||||
try {
|
||||
await sharedSn.findFreePort(0, 100);
|
||||
throw new Error('Expected error for port < 1');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
}
|
||||
|
||||
try {
|
||||
await sharedSn.findFreePort(100, 70000);
|
||||
throw new Error('Expected error for port > 65535');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
}
|
||||
|
||||
try {
|
||||
await sharedSn.findFreePort(200, 100);
|
||||
throw new Error('Expected error for startPort > endPort');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
}
|
||||
});
|
||||
|
||||
// Real traceroute integration test
|
||||
tap.test('traceroute real integration against google.com', async () => {
|
||||
const hops = await sharedSn.traceroute('google.com', { maxHops: 5, timeout: 5000 });
|
||||
expect(Array.isArray(hops)).toBeTrue();
|
||||
expect(hops.length).toBeGreaterThanOrEqual(1);
|
||||
for (const hop of hops) {
|
||||
expect(typeof hop.ttl).toEqual('number');
|
||||
expect(typeof hop.ip).toEqual('string');
|
||||
expect(hop.rtt === null || typeof hop.rtt === 'number').toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('teardown: stop SmartNetwork', async () => {
|
||||
await sharedSn.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartnetwork from '../ts/index.js';
|
||||
|
||||
let testSmartNetwork: smartnetwork.SmartNetwork;
|
||||
|
||||
tap.test('should create a SmartNetwork instance', async () => {
|
||||
testSmartNetwork = new smartnetwork.SmartNetwork();
|
||||
expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
|
||||
});
|
||||
|
||||
tap.test('should get IP intelligence for 8.8.8.8 (Google)', async () => {
|
||||
const result = await testSmartNetwork.getIpIntelligence('8.8.8.8');
|
||||
console.log('IP Intelligence for 8.8.8.8:', JSON.stringify(result, null, 2));
|
||||
|
||||
// Google's ASN is 15169
|
||||
expect(result.asn).toEqual(15169);
|
||||
expect(result.asnOrg).toBeTruthy();
|
||||
|
||||
// Geolocation
|
||||
expect(result.countryCode).toEqual('US');
|
||||
expect(result.latitude).not.toBeNull();
|
||||
expect(result.longitude).not.toBeNull();
|
||||
|
||||
// RDAP registration
|
||||
expect(result.registrantOrg).toBeTruthy();
|
||||
expect(result.networkRange).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => {
|
||||
const result = await testSmartNetwork.getIpIntelligence('1.1.1.1');
|
||||
console.log('IP Intelligence for 1.1.1.1:', JSON.stringify(result, null, 2));
|
||||
|
||||
// ASN should be Cloudflare's 13335
|
||||
expect(result.asn).toEqual(13335);
|
||||
expect(result.asnOrg).toBeTruthy();
|
||||
|
||||
// RDAP registration data should be present
|
||||
expect(result.networkRange).toBeTruthy();
|
||||
expect(result.registrantCountry).toBeTruthy();
|
||||
|
||||
// Note: 1.1.1.1 is anycast — city-level geo may be null in GeoLite2
|
||||
});
|
||||
|
||||
tap.test('should get IP intelligence for own public IP', async () => {
|
||||
const ips = await testSmartNetwork.getPublicIps();
|
||||
if (ips.v4) {
|
||||
const result = await testSmartNetwork.getIpIntelligence(ips.v4);
|
||||
console.log(`IP Intelligence for own IP (${ips.v4}):`, JSON.stringify(result, null, 2));
|
||||
expect(result.asn).toBeTypeofNumber();
|
||||
expect(result.countryCode).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle invalid IP gracefully', async () => {
|
||||
const result = await testSmartNetwork.getIpIntelligence('999.999.999.999');
|
||||
console.log('IP Intelligence for invalid IP:', JSON.stringify(result, null, 2));
|
||||
// Should return nulls without throwing
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should use cache when cacheTtl is set', async () => {
|
||||
const cached = new smartnetwork.SmartNetwork({ cacheTtl: 60000 });
|
||||
const r1 = await cached.getIpIntelligence('8.8.8.8');
|
||||
const r2 = await cached.getIpIntelligence('8.8.8.8');
|
||||
// Second call should return the same cached result
|
||||
expect(r1.asn).toEqual(r2.asn);
|
||||
expect(r1.countryCode).toEqual(r2.countryCode);
|
||||
await cached.stop();
|
||||
});
|
||||
|
||||
tap.test('should stop cleanly (tears down shared smartdns client)', async () => {
|
||||
// If the Rust-backed smartdns bridge wasn't destroyed, this test process
|
||||
// would hang at exit instead of completing.
|
||||
await testSmartNetwork.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as smartnetwork from '../ts/index.js';
|
||||
|
||||
let testSmartnetwork: smartnetwork.SmartNetwork;
|
||||
|
||||
tap.test('should create a valid instance of SmartNetwork', async () => {
|
||||
testSmartnetwork = new smartnetwork.SmartNetwork();
|
||||
expect(testSmartnetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
|
||||
});
|
||||
|
||||
tap.test('should start the Rust bridge', async () => {
|
||||
await testSmartnetwork.start();
|
||||
});
|
||||
|
||||
tap.test('should send a ping to Google', async () => {
|
||||
const res = await testSmartnetwork.ping('google.com');
|
||||
console.log(res);
|
||||
// Ping requires CAP_NET_RAW or appropriate ping_group_range.
|
||||
// When permissions are available, alive should be true.
|
||||
// When not, we gracefully get alive=false.
|
||||
expect(typeof res.alive).toEqual('boolean');
|
||||
expect(typeof res.time).toEqual('number');
|
||||
});
|
||||
|
||||
tap.test('should state when a ping is not alive', async () => {
|
||||
const res = await testSmartnetwork.ping('notthere.lossless.com');
|
||||
expect(res.alive).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should send a ping to an invalid IP', async () => {
|
||||
const res = await testSmartnetwork.ping('192.168.186.999');
|
||||
expect(res.alive).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should stop the Rust bridge', async () => {
|
||||
await testSmartnetwork.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,319 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartNetwork, NetworkError } from '../ts/index.js';
|
||||
import * as net from 'node:net';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
// Helper to create a server on a specific port
|
||||
const createServerOnPort = async (port: number): Promise<net.Server> => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
// Helper to clean up servers
|
||||
const cleanupServers = async (servers: net.Server[]): Promise<void> => {
|
||||
await Promise.all(servers.map(s => new Promise<void>((res) => s.close(() => res()))));
|
||||
};
|
||||
|
||||
let sharedSn: SmartNetwork;
|
||||
|
||||
tap.test('setup: create and start SmartNetwork', async () => {
|
||||
sharedSn = new SmartNetwork();
|
||||
await sharedSn.start();
|
||||
});
|
||||
|
||||
// ========= isLocalPortUnused Tests =========
|
||||
|
||||
tap.test('isLocalPortUnused - should detect free port correctly', async () => {
|
||||
const result = await sharedSn.isLocalPortUnused(54321);
|
||||
expect(typeof result).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('isLocalPortUnused - should detect occupied port', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((res) => server.listen(0, res));
|
||||
const addr = server.address() as AddressInfo;
|
||||
|
||||
const isUnused = await sharedSn.isLocalPortUnused(addr.port);
|
||||
expect(isUnused).toBeFalse();
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
tap.test('isLocalPortUnused - should handle multiple simultaneous checks', async () => {
|
||||
const ports = [55001, 55002, 55003, 55004, 55005];
|
||||
|
||||
const results = await Promise.all(
|
||||
ports.map(port => sharedSn.isLocalPortUnused(port))
|
||||
);
|
||||
|
||||
results.forEach(result => {
|
||||
expect(typeof result).toEqual('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('isLocalPortUnused - should work with IPv6 loopback', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((res) => server.listen(55100, '::', res));
|
||||
const addr = server.address() as AddressInfo;
|
||||
|
||||
const isUnused = await sharedSn.isLocalPortUnused(addr.port);
|
||||
expect(isUnused).toBeFalse();
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
tap.test('isLocalPortUnused - boundary port numbers', async () => {
|
||||
const port1Result = await sharedSn.isLocalPortUnused(1);
|
||||
expect(typeof port1Result).toEqual('boolean');
|
||||
|
||||
const port65535Result = await sharedSn.isLocalPortUnused(65535);
|
||||
expect(typeof port65535Result).toEqual('boolean');
|
||||
});
|
||||
|
||||
// ========= findFreePort Tests =========
|
||||
|
||||
tap.test('findFreePort - should find free port in small range', async () => {
|
||||
const freePort = await sharedSn.findFreePort(50000, 50010);
|
||||
|
||||
expect(freePort).not.toBeNull();
|
||||
expect(freePort).toBeGreaterThanOrEqual(50000);
|
||||
expect(freePort).toBeLessThanOrEqual(50010);
|
||||
|
||||
if (freePort !== null) {
|
||||
const isUnused = await sharedSn.isLocalPortUnused(freePort);
|
||||
expect(isUnused).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should find first available port', async () => {
|
||||
const servers = [];
|
||||
|
||||
servers.push(await createServerOnPort(50100));
|
||||
servers.push(await createServerOnPort(50101));
|
||||
|
||||
const freePort = await sharedSn.findFreePort(50100, 50105);
|
||||
expect(freePort).toEqual(50102);
|
||||
|
||||
await cleanupServers(servers);
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should handle fully occupied range', async () => {
|
||||
const servers = [];
|
||||
const startPort = 50200;
|
||||
const endPort = 50202;
|
||||
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
servers.push(await createServerOnPort(port));
|
||||
}
|
||||
|
||||
const freePort = await sharedSn.findFreePort(startPort, endPort);
|
||||
expect(freePort).toBeNull();
|
||||
|
||||
await cleanupServers(servers);
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should validate port boundaries', async () => {
|
||||
try {
|
||||
await sharedSn.findFreePort(0, 100);
|
||||
throw new Error('Should have thrown for port < 1');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
expect(err.message).toContain('between 1 and 65535');
|
||||
}
|
||||
|
||||
try {
|
||||
await sharedSn.findFreePort(100, 70000);
|
||||
throw new Error('Should have thrown for port > 65535');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
}
|
||||
|
||||
try {
|
||||
await sharedSn.findFreePort(-100, 100);
|
||||
throw new Error('Should have thrown for negative port');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should validate range order', async () => {
|
||||
try {
|
||||
await sharedSn.findFreePort(200, 100);
|
||||
throw new Error('Should have thrown for startPort > endPort');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
expect(err.message).toContain('less than or equal to end port');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should handle single port range', async () => {
|
||||
const freePort = await sharedSn.findFreePort(50300, 50300);
|
||||
expect(freePort === 50300 || freePort === null).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should work with large ranges', async () => {
|
||||
const freePort = await sharedSn.findFreePort(40000, 50000);
|
||||
expect(freePort).not.toBeNull();
|
||||
expect(freePort).toBeGreaterThanOrEqual(40000);
|
||||
expect(freePort).toBeLessThanOrEqual(50000);
|
||||
});
|
||||
|
||||
tap.test('findFreePort - should handle intermittent occupied ports', async () => {
|
||||
const servers = [];
|
||||
|
||||
servers.push(await createServerOnPort(50400));
|
||||
servers.push(await createServerOnPort(50402));
|
||||
servers.push(await createServerOnPort(50404));
|
||||
|
||||
const freePort = await sharedSn.findFreePort(50400, 50405);
|
||||
expect([50401, 50403, 50405]).toContain(freePort);
|
||||
|
||||
await cleanupServers(servers);
|
||||
});
|
||||
|
||||
// ========= isRemotePortAvailable Tests =========
|
||||
|
||||
tap.test('isRemotePortAvailable - should detect open HTTP port', async () => {
|
||||
const open1 = await sharedSn.isRemotePortAvailable('example.com:80');
|
||||
expect(open1).toBeTrue();
|
||||
|
||||
const open2 = await sharedSn.isRemotePortAvailable('example.com', 80);
|
||||
expect(open2).toBeTrue();
|
||||
|
||||
const open3 = await sharedSn.isRemotePortAvailable('example.com', { port: 80 });
|
||||
expect(open3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should detect closed port', async () => {
|
||||
const closed = await sharedSn.isRemotePortAvailable('example.com', 12345);
|
||||
expect(closed).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should handle retries', async () => {
|
||||
const result = await sharedSn.isRemotePortAvailable('example.com', {
|
||||
port: 80,
|
||||
retries: 3,
|
||||
timeout: 1000
|
||||
});
|
||||
expect(result).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should reject UDP protocol', async () => {
|
||||
try {
|
||||
await sharedSn.isRemotePortAvailable('example.com', {
|
||||
port: 53,
|
||||
protocol: 'udp'
|
||||
});
|
||||
throw new Error('Should have thrown for UDP protocol');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('ENOTSUP');
|
||||
expect(err.message).toContain('UDP port check not supported');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should require port specification', async () => {
|
||||
try {
|
||||
await sharedSn.isRemotePortAvailable('example.com');
|
||||
throw new Error('Should have thrown for missing port');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(err.code).toEqual('EINVAL');
|
||||
expect(err.message).toContain('Port not specified');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should parse port from host:port string', async () => {
|
||||
const result1 = await sharedSn.isRemotePortAvailable('example.com:443');
|
||||
expect(result1).toBeTrue();
|
||||
|
||||
const result2 = await sharedSn.isRemotePortAvailable('example.com:8080', { port: 80 });
|
||||
expect(result2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should handle localhost', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((res) => server.listen(51000, 'localhost', res));
|
||||
|
||||
const isOpen = await sharedSn.isRemotePortAvailable('localhost', 51000);
|
||||
expect(isOpen).toBeTrue();
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - should handle invalid hosts gracefully', async () => {
|
||||
const result = await sharedSn.isRemotePortAvailable('this-domain-definitely-does-not-exist-12345.com', 80);
|
||||
expect(result).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('isRemotePortAvailable - edge case ports', async () => {
|
||||
const https = await sharedSn.isRemotePortAvailable('example.com', 443);
|
||||
expect(https).toBeTrue();
|
||||
|
||||
const ssh = await sharedSn.isRemotePortAvailable('example.com', 22);
|
||||
expect(ssh).toBeFalse();
|
||||
});
|
||||
|
||||
// ========= Integration Tests =========
|
||||
|
||||
tap.test('Integration - findFreePort and isLocalPortUnused consistency', async () => {
|
||||
const freePort = await sharedSn.findFreePort(52000, 52100);
|
||||
expect(freePort).not.toBeNull();
|
||||
|
||||
if (freePort !== null) {
|
||||
const isUnused1 = await sharedSn.isLocalPortUnused(freePort);
|
||||
expect(isUnused1).toBeTrue();
|
||||
|
||||
const server = await createServerOnPort(freePort);
|
||||
|
||||
const isUnused2 = await sharedSn.isLocalPortUnused(freePort);
|
||||
expect(isUnused2).toBeFalse();
|
||||
|
||||
const nextFreePort = await sharedSn.findFreePort(freePort, freePort + 10);
|
||||
expect(nextFreePort).not.toEqual(freePort);
|
||||
|
||||
await cleanupServers([server]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Integration - stress test with many concurrent port checks', async () => {
|
||||
const portRange = Array.from({ length: 20 }, (_, i) => 53000 + i);
|
||||
|
||||
const results = await Promise.all(
|
||||
portRange.map(async port => ({
|
||||
port,
|
||||
isUnused: await sharedSn.isLocalPortUnused(port)
|
||||
}))
|
||||
);
|
||||
|
||||
results.forEach(result => {
|
||||
expect(typeof result.isUnused).toEqual('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('Performance - findFreePort with large range', async () => {
|
||||
const startTime = Date.now();
|
||||
const freePort = await sharedSn.findFreePort(30000, 60000);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(freePort).not.toBeNull();
|
||||
// Should complete quickly since it finds a port early
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
tap.test('teardown: stop SmartNetwork', async () => {
|
||||
await sharedSn.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
+40
-16
@@ -1,42 +1,66 @@
|
||||
import { expect, tap } from '@pushrocks/tapbundle';
|
||||
import * as smartnetwork from '../ts/index';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartnetwork from '../ts/index.js';
|
||||
|
||||
let testSmartNetwork: smartnetwork.SmartNetwork;
|
||||
|
||||
tap.test('should create a valid instance of SmartNetwork', async () => {
|
||||
testSmartNetwork = new smartnetwork.SmartNetwork();
|
||||
expect(testSmartNetwork).to.be.instanceOf(smartnetwork.SmartNetwork);
|
||||
expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
|
||||
});
|
||||
|
||||
tap.test('should start the Rust bridge', async () => {
|
||||
await testSmartNetwork.start();
|
||||
});
|
||||
|
||||
tap.test('should perform a speedtest', async () => {
|
||||
let result = await testSmartNetwork.getSpeed();
|
||||
console.log(`Download speed for this instance is ${result.speeds.download}`);
|
||||
console.log(`Upload speed for this instance is ${result.speeds.upload}`);
|
||||
const result = await testSmartNetwork.getSpeed();
|
||||
console.log(`Download speed for this instance is ${result.downloadSpeed}`);
|
||||
console.log(`Upload speed for this instance is ${result.uploadSpeed}`);
|
||||
// verify speeds are returned as strings and parse to positive numbers
|
||||
expect(typeof result.downloadSpeed).toEqual('string');
|
||||
expect(typeof result.uploadSpeed).toEqual('string');
|
||||
expect(parseFloat(result.downloadSpeed)).toBeGreaterThan(0);
|
||||
expect(parseFloat(result.uploadSpeed)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should determine wether a port is free', async () => {
|
||||
await expect(testSmartNetwork.isLocalPortAvailable(8080)).to.eventually.be.true;
|
||||
tap.test('should determine whether a port is free', async () => {
|
||||
// Use a high-numbered port that's unlikely to be in use
|
||||
await expect(testSmartNetwork.isLocalPortUnused(59123)).resolves.toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should scan a port', async () => {
|
||||
await expect(testSmartNetwork.isRemotePortAvailable('lossless.com:443')).to.eventually.be.true;
|
||||
await expect(testSmartNetwork.isRemotePortAvailable('lossless.com', 443)).to.be.eventually.true;
|
||||
// await expect(testSmartNetwork.isRemotePortAvailable('lossless.com:444')).to.eventually.be.false;
|
||||
await expect(testSmartNetwork.isRemotePortAvailable('lossless.com:443')).resolves.toBeTrue();
|
||||
await expect(testSmartNetwork.isRemotePortAvailable('lossless.com', 443)).resolves.toBeTrue();
|
||||
await expect(testSmartNetwork.isRemotePortAvailable('lossless.com:444')).resolves.toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should get gateways', async () => {
|
||||
const gatewayResult = await testSmartNetwork.getGateways();
|
||||
console.log(gatewayResult);
|
||||
const gateways = await testSmartNetwork.getGateways();
|
||||
console.log(gateways);
|
||||
// verify gateways object has at least one interface
|
||||
expect(typeof gateways).toEqual('object');
|
||||
expect(Object.keys(gateways).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should get the default gateway', async () => {
|
||||
const gatewayResult = await testSmartNetwork.getDefaultGateway();
|
||||
console.log(gatewayResult);
|
||||
const defaultGw = await testSmartNetwork.getDefaultGateway();
|
||||
console.log(defaultGw);
|
||||
// verify default gateway contains ipv4 and ipv6 info
|
||||
expect(defaultGw).toBeDefined();
|
||||
expect(defaultGw.ipv4).toBeDefined();
|
||||
expect(defaultGw.ipv6).toBeDefined();
|
||||
});
|
||||
|
||||
tap.test('should get public ips', async () => {
|
||||
const ips = await testSmartNetwork.getPublicIps();
|
||||
console.log(ips);
|
||||
// verify public IPs object contains v4 and v6 properties
|
||||
expect(ips).toHaveProperty('v4');
|
||||
expect(ips).toHaveProperty('v6');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
tap.test('should stop the Rust bridge', async () => {
|
||||
await testSmartNetwork.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartnetwork',
|
||||
version: '4.6.0',
|
||||
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Custom error classes for network operations
|
||||
*/
|
||||
export class NetworkError extends Error {
|
||||
public code?: string;
|
||||
constructor(message?: string, code?: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
this.code = code;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends NetworkError {
|
||||
constructor(message?: string) {
|
||||
super(message, 'ETIMEOUT');
|
||||
this.name = 'TimeoutError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export function average(values: number[]) {
|
||||
let total = 0;
|
||||
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
total += values[i];
|
||||
}
|
||||
|
||||
return total / values.length;
|
||||
}
|
||||
|
||||
export function median(values: number[]) {
|
||||
const half = Math.floor(values.length / 2);
|
||||
|
||||
values.sort((a, b) => a - b);
|
||||
|
||||
if (values.length % 2) return values[half];
|
||||
|
||||
return (values[half - 1] + values[half]) / 2;
|
||||
}
|
||||
|
||||
export function quartile(values: number[], percentile: number) {
|
||||
values.sort((a, b) => a - b);
|
||||
const pos = (values.length - 1) * percentile;
|
||||
const base = Math.floor(pos);
|
||||
const rest = pos - base;
|
||||
|
||||
if (values[base + 1] !== undefined) {
|
||||
return values[base] + rest * (values[base + 1] - values[base]);
|
||||
}
|
||||
|
||||
return values[base];
|
||||
}
|
||||
|
||||
export function jitter(values: number[]) {
|
||||
// Average distance between consecutive latency measurements...
|
||||
let jitters: number[] = [];
|
||||
|
||||
for (let i = 0; i < values.length - 1; i += 1) {
|
||||
jitters.push(Math.abs(values[i] - values[i + 1]));
|
||||
}
|
||||
|
||||
return average(jitters);
|
||||
}
|
||||
+10
-1
@@ -1 +1,10 @@
|
||||
export * from './smartnetwork.classes.smartnetwork';
|
||||
export * from './smartnetwork.classes.smartnetwork.js';
|
||||
export type { SmartNetworkOptions, Hop } from './smartnetwork.classes.smartnetwork.js';
|
||||
export { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
|
||||
export { PublicIp } from './smartnetwork.classes.publicip.js';
|
||||
export { IpIntelligence } from './smartnetwork.classes.ipintelligence.js';
|
||||
export type { IIpIntelligenceResult, IIpIntelligenceOptions } from './smartnetwork.classes.ipintelligence.js';
|
||||
export { DomainIntelligence } from './smartnetwork.classes.domainintelligence.js';
|
||||
export type { IDomainIntelligenceResult, IDomainIntelligenceOptions } from './smartnetwork.classes.domainintelligence.js';
|
||||
export { setLogger, getLogger } from './logging.js';
|
||||
export { NetworkError, TimeoutError } from './errors.js';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Injectable logging interface and global logger
|
||||
*/
|
||||
export interface Logger {
|
||||
/** Debug-level messages */
|
||||
debug?(...args: unknown[]): void;
|
||||
/** Informational messages */
|
||||
info(...args: unknown[]): void;
|
||||
/** Warning messages */
|
||||
warn?(...args: unknown[]): void;
|
||||
/** Error messages */
|
||||
error(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
let globalLogger: Logger = console;
|
||||
|
||||
/**
|
||||
* Replace the global logger implementation
|
||||
* @param logger Custom logger adhering to Logger interface
|
||||
*/
|
||||
export function setLogger(logger: Logger): void {
|
||||
globalLogger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the current global logger
|
||||
*/
|
||||
export function getLogger(): Logger {
|
||||
return globalLogger;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import { getLogger } from './logging.js';
|
||||
import { NetworkError, TimeoutError } from './errors.js';
|
||||
import * as stats from './helpers/stats.js';
|
||||
|
||||
export interface SpeedOptions {
|
||||
parallelStreams?: number;
|
||||
duration?: number;
|
||||
}
|
||||
export class CloudflareSpeed {
|
||||
private opts: SpeedOptions;
|
||||
constructor(opts?: SpeedOptions) {
|
||||
this.opts = opts || {};
|
||||
}
|
||||
|
||||
public async speedTest() {
|
||||
const latency = await this.measureLatency();
|
||||
|
||||
const serverLocations = await this.fetchServerLocations();
|
||||
const cgiData = await this.fetchCfCdnCgiTrace();
|
||||
|
||||
// speed tests: either fixed segments or duration-based mode
|
||||
const parallel = this.opts.parallelStreams ?? 1;
|
||||
const measureDownloadParallel = (bytes: number, iterations: number) => {
|
||||
if (parallel <= 1) {
|
||||
return this.measureDownload(bytes, iterations);
|
||||
}
|
||||
return Promise.all(
|
||||
Array(parallel)
|
||||
.fill(null)
|
||||
.map(() => this.measureDownload(bytes, iterations)),
|
||||
).then((arrays) => arrays.flat());
|
||||
};
|
||||
let downloadTests: number[];
|
||||
if (this.opts.duration && this.opts.duration > 0) {
|
||||
// duration-based download: run for specified seconds
|
||||
downloadTests = [];
|
||||
const durMs = this.opts.duration * 1000;
|
||||
const startMs = Date.now();
|
||||
// use medium chunk size for download
|
||||
const chunkBytes = 25001000;
|
||||
while (Date.now() - startMs < durMs) {
|
||||
const speeds = await measureDownloadParallel(chunkBytes, 1);
|
||||
downloadTests.push(...speeds);
|
||||
}
|
||||
if (downloadTests.length === 0) downloadTests = [0];
|
||||
} else {
|
||||
// fixed download segments
|
||||
const t1 = await measureDownloadParallel(101000, 10);
|
||||
const t2 = await measureDownloadParallel(1001000, 8);
|
||||
const t3 = await measureDownloadParallel(10001000, 6);
|
||||
const t4 = await measureDownloadParallel(25001000, 4);
|
||||
const t5 = await measureDownloadParallel(100001000, 1);
|
||||
downloadTests = [...t1, ...t2, ...t3, ...t4, ...t5];
|
||||
}
|
||||
const speedDownload = downloadTests.length > 0 ? stats.quartile(downloadTests, 0.9).toFixed(2) : '0.00';
|
||||
|
||||
// lets test the upload speed with configurable parallel streams
|
||||
const measureUploadParallel = (bytes: number, iterations: number) => {
|
||||
if (parallel <= 1) {
|
||||
return this.measureUpload(bytes, iterations);
|
||||
}
|
||||
return Promise.all(
|
||||
Array(parallel)
|
||||
.fill(null)
|
||||
.map(() => this.measureUpload(bytes, iterations)),
|
||||
).then((arrays) => arrays.flat());
|
||||
};
|
||||
let uploadTests: number[];
|
||||
if (this.opts.duration && this.opts.duration > 0) {
|
||||
// duration-based upload: run for specified seconds
|
||||
uploadTests = [];
|
||||
const durMsUp = this.opts.duration * 1000;
|
||||
const startMsUp = Date.now();
|
||||
const chunkBytesUp = 1001000;
|
||||
while (Date.now() - startMsUp < durMsUp) {
|
||||
const speeds = await measureUploadParallel(chunkBytesUp, 1);
|
||||
uploadTests.push(...speeds);
|
||||
}
|
||||
if (uploadTests.length === 0) uploadTests = [0];
|
||||
} else {
|
||||
const u1 = await measureUploadParallel(11000, 10);
|
||||
const u2 = await measureUploadParallel(101000, 10);
|
||||
const u3 = await measureUploadParallel(1001000, 8);
|
||||
uploadTests = [...u1, ...u2, ...u3];
|
||||
}
|
||||
const speedUpload = uploadTests.length > 0 ? stats.quartile(uploadTests, 0.9).toFixed(2) : '0.00';
|
||||
|
||||
return {
|
||||
...latency,
|
||||
ip: cgiData.ip,
|
||||
serverLocation: {
|
||||
shortId: cgiData.colo,
|
||||
name: serverLocations[cgiData.colo],
|
||||
availableLocations: serverLocations,
|
||||
},
|
||||
downloadSpeed: speedDownload,
|
||||
uploadSpeed: speedUpload,
|
||||
};
|
||||
}
|
||||
|
||||
public async measureLatency() {
|
||||
const measurements: number[] = [];
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await this.download(1000).then(
|
||||
(response) => {
|
||||
// TTFB - Server processing time
|
||||
measurements.push(response[4] - response[0] - response[6]);
|
||||
},
|
||||
(error) => {
|
||||
getLogger().error('Error measuring latency:', error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
maxTime: Math.max(...measurements),
|
||||
minTime: Math.min(...measurements),
|
||||
averageTime: stats.average(measurements),
|
||||
medianTime: stats.median(measurements),
|
||||
jitter: stats.jitter(measurements),
|
||||
};
|
||||
}
|
||||
|
||||
public async measureDownload(bytes: number, iterations: number) {
|
||||
const measurements: number[] = [];
|
||||
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
await this.download(bytes).then(
|
||||
async (response) => {
|
||||
const transferTime = response[5] - response[4];
|
||||
measurements.push(await this.measureSpeed(bytes, transferTime));
|
||||
},
|
||||
(error) => {
|
||||
getLogger().error('Error measuring download chunk:', error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return measurements;
|
||||
}
|
||||
|
||||
public async measureUpload(bytes: number, iterations: number) {
|
||||
const measurements: number[] = [];
|
||||
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
await this.upload(bytes).then(
|
||||
async (response) => {
|
||||
// Prefer server-timing duration; fall back to client-side transfer time
|
||||
let transferTime = response[6];
|
||||
if (!transferTime || !isFinite(transferTime)) {
|
||||
transferTime = response[5] - response[4]; // ended - ttfb
|
||||
}
|
||||
if (transferTime > 0) {
|
||||
measurements.push(await this.measureSpeed(bytes, transferTime));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
getLogger().error('Error measuring upload chunk:', error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return measurements;
|
||||
}
|
||||
|
||||
public async measureSpeed(bytes: number, duration: number) {
|
||||
return (bytes * 8) / (duration / 1000) / 1e6;
|
||||
}
|
||||
|
||||
public async fetchServerLocations(): Promise<{ [key: string]: string }> {
|
||||
try {
|
||||
const raw = await this.get('speed.cloudflare.com', '/locations');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return (parsed as Array<{ iata: string; city: string }>).reduce(
|
||||
(data: Record<string, string>, entry) => {
|
||||
data[entry.iata] = entry.city;
|
||||
return data;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
public async get(hostname: string, path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = plugins.https.request(
|
||||
{
|
||||
hostname,
|
||||
path,
|
||||
method: 'GET',
|
||||
// disable connection pooling to avoid listener accumulation
|
||||
agent: false,
|
||||
},
|
||||
(res) => {
|
||||
const body: Array<Buffer> = [];
|
||||
res.on('data', (chunk) => {
|
||||
body.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(Buffer.concat(body).toString());
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
req.on('error', (err: Error & { code?: string }) => {
|
||||
reject(new NetworkError(err.message, err.code));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
public async download(bytes: number) {
|
||||
const options = {
|
||||
hostname: 'speed.cloudflare.com',
|
||||
path: `/__down?bytes=${bytes}`,
|
||||
method: 'GET',
|
||||
};
|
||||
|
||||
return this.request(options);
|
||||
}
|
||||
|
||||
public async upload(bytes: number) {
|
||||
const data = '0'.repeat(bytes);
|
||||
const options = {
|
||||
hostname: 'speed.cloudflare.com',
|
||||
path: '/__up',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
},
|
||||
};
|
||||
|
||||
return this.request(options, data);
|
||||
}
|
||||
|
||||
public async request(options: plugins.https.RequestOptions, data = ''): Promise<number[]> {
|
||||
let started: number;
|
||||
let dnsLookup: number;
|
||||
let tcpHandshake: number;
|
||||
let sslHandshake: number;
|
||||
let ttfb: number;
|
||||
let ended: number;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
started = plugins.perfHooks.performance.now();
|
||||
// disable connection pooling to avoid listener accumulation across requests
|
||||
const reqOptions = { ...options, agent: false };
|
||||
const req = plugins.https.request(reqOptions, (res) => {
|
||||
res.once('readable', () => {
|
||||
ttfb = plugins.perfHooks.performance.now();
|
||||
});
|
||||
res.on('data', () => {});
|
||||
res.on('end', () => {
|
||||
ended = plugins.perfHooks.performance.now();
|
||||
resolve([
|
||||
started,
|
||||
dnsLookup,
|
||||
tcpHandshake,
|
||||
sslHandshake,
|
||||
ttfb,
|
||||
ended,
|
||||
(() => {
|
||||
const serverTiming = res.headers['server-timing'] as string | undefined;
|
||||
if (!serverTiming) return 0;
|
||||
const match = serverTiming.match(/dur=([\d.]+)/);
|
||||
return match ? parseFloat(match[1]) : parseFloat(serverTiming.slice(22)) || 0;
|
||||
})(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for timing events once per new socket
|
||||
req.once('socket', (socket) => {
|
||||
socket.once('lookup', () => {
|
||||
dnsLookup = plugins.perfHooks.performance.now();
|
||||
});
|
||||
socket.once('connect', () => {
|
||||
tcpHandshake = plugins.perfHooks.performance.now();
|
||||
});
|
||||
socket.once('secureConnect', () => {
|
||||
sslHandshake = plugins.perfHooks.performance.now();
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error: Error & { code?: string }) => {
|
||||
reject(new NetworkError(error.message, error.code));
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Cloudflare's trace endpoint and parse key=value lines to a record.
|
||||
*/
|
||||
public async fetchCfCdnCgiTrace(): Promise<Record<string, string>> {
|
||||
const parseCfCdnCgiTrace = (text: string) =>
|
||||
text
|
||||
.split('\n')
|
||||
.map((i) => {
|
||||
const parts = i.split('=');
|
||||
return [parts[0], parts[1]];
|
||||
})
|
||||
.reduce(
|
||||
(data: Record<string, string>, [k, v]) => {
|
||||
if (v === undefined) return data;
|
||||
data[k] = v;
|
||||
return data;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
return this.get('speed.cloudflare.com', '/cdn-cgi/trace').then(parseCfCdnCgiTrace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import { domainToASCII } from 'node:url';
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import { getLogger } from './logging.js';
|
||||
|
||||
/** Type alias for a Smartdns client instance */
|
||||
type TSmartdnsClient = InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
|
||||
/** Type alias for a single DNS record returned by Smartdns */
|
||||
type TDnsRecord = Awaited<ReturnType<TSmartdnsClient['getRecordsA']>>[number];
|
||||
|
||||
/**
|
||||
* Unified result from a domain RDAP intelligence lookup.
|
||||
*/
|
||||
export interface IDomainIntelligenceResult {
|
||||
/** Normalized ASCII (punycode) form of the queried domain */
|
||||
domain: string | null;
|
||||
/** Registry handle / identifier */
|
||||
handle: string | null;
|
||||
/** EPP status values, e.g. ["active", "client transfer prohibited"] */
|
||||
status: string[] | null;
|
||||
|
||||
// Registration lifecycle (ISO 8601 timestamps from RDAP events)
|
||||
registrationDate: string | null;
|
||||
expirationDate: string | null;
|
||||
lastChangedDate: string | null;
|
||||
|
||||
// Sponsoring registrar
|
||||
registrarName: string | null;
|
||||
registrarIanaId: number | null;
|
||||
|
||||
// Registrant (often redacted under GDPR)
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
|
||||
// Abuse contact (commonly nested under the registrar entity)
|
||||
abuseEmail: string | null;
|
||||
abusePhone: string | null;
|
||||
|
||||
// Technical
|
||||
nameservers: string[] | null;
|
||||
dnssec: boolean | null;
|
||||
|
||||
/** Which layer populated the nameservers field */
|
||||
nameserversSource: 'rdap' | 'dns' | null;
|
||||
|
||||
// DNS enrichment (from smartdns)
|
||||
/** IPv4 A records */
|
||||
resolvedIpv4: string[] | null;
|
||||
/** IPv6 AAAA records */
|
||||
resolvedIpv6: string[] | null;
|
||||
/** Parsed MX records with priority and exchange */
|
||||
mxRecords: { priority: number | null; exchange: string }[] | null;
|
||||
/** TXT records (SPF, DKIM, site verification, etc.) */
|
||||
txtRecords: string[] | null;
|
||||
/** Raw serialized SOA record value */
|
||||
soaRecord: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for DomainIntelligence
|
||||
*/
|
||||
export interface IDomainIntelligenceOptions {
|
||||
/** Timeout (ms) for RDAP/bootstrap/DNS requests. Default: 5000 */
|
||||
timeout?: number;
|
||||
/**
|
||||
* Optional injected smartdns client. When provided, DomainIntelligence
|
||||
* will not create or destroy its own client (the owner — typically
|
||||
* SmartNetwork — manages lifecycle). When omitted, a short-lived client
|
||||
* is created per DNS-layer query and destroyed in finally.
|
||||
*/
|
||||
dnsClient?: TSmartdnsClient;
|
||||
}
|
||||
|
||||
// IANA bootstrap for domain RDAP
|
||||
const IANA_BOOTSTRAP_DNS_URL = 'https://data.iana.org/rdap/dns.json';
|
||||
const DEFAULT_TIMEOUT = 5000;
|
||||
|
||||
/**
|
||||
* Build an empty result object with all fields nulled.
|
||||
*/
|
||||
function emptyResult(domain: string | null = null): IDomainIntelligenceResult {
|
||||
return {
|
||||
domain,
|
||||
handle: null,
|
||||
status: null,
|
||||
registrationDate: null,
|
||||
expirationDate: null,
|
||||
lastChangedDate: null,
|
||||
registrarName: null,
|
||||
registrarIanaId: null,
|
||||
registrantOrg: null,
|
||||
registrantCountry: null,
|
||||
abuseEmail: null,
|
||||
abusePhone: null,
|
||||
nameservers: null,
|
||||
dnssec: null,
|
||||
nameserversSource: null,
|
||||
resolvedIpv4: null,
|
||||
resolvedIpv6: null,
|
||||
mxRecords: null,
|
||||
txtRecords: null,
|
||||
soaRecord: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DomainIntelligence performs RDAP lookups for domain names using the
|
||||
* IANA DNS bootstrap to discover the correct registry RDAP endpoint per TLD.
|
||||
*/
|
||||
export class DomainIntelligence {
|
||||
private readonly logger = getLogger();
|
||||
private readonly timeout: number;
|
||||
|
||||
// Bootstrap cache: tld (lowercased) -> RDAP base URL (without trailing slash)
|
||||
private bootstrapEntries: Map<string, string> | null = null;
|
||||
private bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
// Optional injected smartdns client (shared by SmartNetwork)
|
||||
private readonly sharedDnsClient: TSmartdnsClient | null;
|
||||
|
||||
constructor(options?: IDomainIntelligenceOptions) {
|
||||
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
this.sharedDnsClient = options?.dnsClient ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive domain intelligence. Runs RDAP and DNS lookups in
|
||||
* parallel, then merges the results. Returns an all-null result (rather
|
||||
* than throwing) for malformed input, unknown TLDs, or total failure.
|
||||
*
|
||||
* - RDAP provides: registrar, registrant, events (registration/expiration),
|
||||
* nameservers (registry/parent), status, DNSSEC, abuse contact
|
||||
* - DNS provides: A/AAAA records, MX, TXT, SOA, and a nameservers fallback
|
||||
* when RDAP is unavailable (closes the ccTLD gap)
|
||||
*/
|
||||
public async getIntelligence(domain: string): Promise<IDomainIntelligenceResult> {
|
||||
const normalized = this.normalizeDomain(domain);
|
||||
if (!normalized) return emptyResult(null);
|
||||
|
||||
const [rdapSettled, dnsSettled] = await Promise.allSettled([
|
||||
this.queryRdapLayer(normalized),
|
||||
this.queryDnsLayer(normalized),
|
||||
]);
|
||||
|
||||
const result = emptyResult(normalized);
|
||||
|
||||
// Merge RDAP fields (if any) — start with the parsed RDAP result as the base
|
||||
if (rdapSettled.status === 'fulfilled' && rdapSettled.value) {
|
||||
Object.assign(result, rdapSettled.value);
|
||||
if (result.nameservers && result.nameservers.length > 0) {
|
||||
result.nameserversSource = 'rdap';
|
||||
}
|
||||
}
|
||||
|
||||
// Merge DNS fields (if any)
|
||||
if (dnsSettled.status === 'fulfilled' && dnsSettled.value) {
|
||||
const dns = dnsSettled.value;
|
||||
result.resolvedIpv4 = dns.resolvedIpv4;
|
||||
result.resolvedIpv6 = dns.resolvedIpv6;
|
||||
result.mxRecords = dns.mxRecords;
|
||||
result.txtRecords = dns.txtRecords;
|
||||
result.soaRecord = dns.soaRecord;
|
||||
// Nameserver fallback: only from DNS when RDAP didn't populate it
|
||||
if ((!result.nameservers || result.nameservers.length === 0) && dns.nameservers) {
|
||||
result.nameservers = dns.nameservers;
|
||||
result.nameserversSource = 'dns';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── RDAP Layer (existing logic, wrapped) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Run the full RDAP lookup flow for a pre-normalized domain: extract TLD,
|
||||
* load bootstrap, match registry, query, and parse. Returns the parsed
|
||||
* RDAP fields (as a full IDomainIntelligenceResult) or null if any step
|
||||
* fails or the TLD has no RDAP support.
|
||||
*/
|
||||
private async queryRdapLayer(domain: string): Promise<IDomainIntelligenceResult | null> {
|
||||
const tld = this.extractTld(domain);
|
||||
if (!tld) return null;
|
||||
|
||||
await this.ensureBootstrap();
|
||||
const baseUrl = this.matchTld(tld);
|
||||
if (!baseUrl) return null;
|
||||
|
||||
const rdapData = await this.queryRdap(domain, baseUrl);
|
||||
if (!rdapData) return null;
|
||||
|
||||
return this.parseRdapResponse(domain, rdapData);
|
||||
}
|
||||
|
||||
// ─── Normalization & TLD extraction ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a domain to lowercased ASCII punycode form. Returns null for
|
||||
* obviously invalid input.
|
||||
*/
|
||||
private normalizeDomain(input: string): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
let trimmed = input.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
// Strip a single trailing dot (FQDN form)
|
||||
if (trimmed.endsWith('.')) trimmed = trimmed.slice(0, -1);
|
||||
if (!trimmed) return null;
|
||||
// Reject inputs that contain whitespace, slashes, or other URL noise
|
||||
if (/[\s/\\?#]/.test(trimmed)) return null;
|
||||
|
||||
// Convert IDN to ASCII (punycode). Returns '' for invalid input.
|
||||
const ascii = domainToASCII(trimmed);
|
||||
if (!ascii) return null;
|
||||
|
||||
// Must contain at least one dot to have a TLD
|
||||
if (!ascii.includes('.')) return null;
|
||||
|
||||
return ascii;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the TLD as the last dot-separated label.
|
||||
*/
|
||||
private extractTld(domain: string): string | null {
|
||||
const idx = domain.lastIndexOf('.');
|
||||
if (idx < 0 || idx === domain.length - 1) return null;
|
||||
return domain.slice(idx + 1);
|
||||
}
|
||||
|
||||
// ─── Bootstrap Subsystem ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and cache the IANA DNS bootstrap file.
|
||||
*/
|
||||
private async ensureBootstrap(): Promise<void> {
|
||||
if (this.bootstrapEntries) return;
|
||||
if (this.bootstrapPromise) {
|
||||
await this.bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bootstrapPromise = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const response = await fetch(IANA_BOOTSTRAP_DNS_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = (await response.json()) as { services: [string[], string[]][] };
|
||||
|
||||
const entries = new Map<string, string>();
|
||||
for (const [tlds, urls] of data.services) {
|
||||
const baseUrl = urls[0]; // first URL is preferred
|
||||
if (!baseUrl) continue;
|
||||
const cleanBase = baseUrl.replace(/\/$/, ''); // strip trailing slash
|
||||
for (const tld of tlds) {
|
||||
entries.set(tld.toLowerCase(), cleanBase);
|
||||
}
|
||||
}
|
||||
this.bootstrapEntries = entries;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Failed to load DNS RDAP bootstrap: ${err.message}`);
|
||||
this.bootstrapEntries = new Map(); // empty = all RDAP lookups will skip
|
||||
}
|
||||
})();
|
||||
|
||||
await this.bootstrapPromise;
|
||||
this.bootstrapPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the RDAP base URL for a given TLD via direct lookup.
|
||||
*/
|
||||
private matchTld(tld: string): string | null {
|
||||
if (!this.bootstrapEntries || this.bootstrapEntries.size === 0) return null;
|
||||
return this.bootstrapEntries.get(tld.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
// ─── RDAP Query ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform the RDAP HTTP query for a domain.
|
||||
*/
|
||||
private async queryRdap(domain: string, baseUrl: string): Promise<any | null> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/domain/${encodeURIComponent(domain)}`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/rdap+json',
|
||||
'User-Agent': '@push.rocks/smartnetwork',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RDAP Response Parsing ──────────────────────────────────────────
|
||||
|
||||
private parseRdapResponse(domain: string, data: any): IDomainIntelligenceResult {
|
||||
const result = emptyResult(domain);
|
||||
|
||||
if (typeof data.handle === 'string') result.handle = data.handle;
|
||||
|
||||
if (Array.isArray(data.status) && data.status.length > 0) {
|
||||
result.status = data.status.filter((s: any): s is string => typeof s === 'string');
|
||||
}
|
||||
|
||||
// Events: registration / expiration / last changed
|
||||
if (Array.isArray(data.events)) {
|
||||
const events = this.extractEvents(data.events);
|
||||
result.registrationDate = events.registration;
|
||||
result.expirationDate = events.expiration;
|
||||
result.lastChangedDate = events.lastChanged;
|
||||
}
|
||||
|
||||
// Registrar (sponsor) and registrant from entities
|
||||
if (Array.isArray(data.entities)) {
|
||||
const registrar = this.extractRegistrar(data.entities);
|
||||
result.registrarName = registrar.name;
|
||||
result.registrarIanaId = registrar.ianaId;
|
||||
result.abuseEmail = registrar.abuseEmail;
|
||||
result.abusePhone = registrar.abusePhone;
|
||||
|
||||
const registrant = this.extractRegistrant(data.entities);
|
||||
result.registrantOrg = registrant.org;
|
||||
result.registrantCountry = registrant.country;
|
||||
}
|
||||
|
||||
// Nameservers
|
||||
if (Array.isArray(data.nameservers)) {
|
||||
result.nameservers = this.extractNameservers(data.nameservers);
|
||||
}
|
||||
|
||||
// DNSSEC: secureDNS.delegationSigned
|
||||
if (data.secureDNS && typeof data.secureDNS.delegationSigned === 'boolean') {
|
||||
result.dnssec = data.secureDNS.delegationSigned;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull registration / expiration / last changed timestamps from an
|
||||
* RDAP `events` array.
|
||||
*/
|
||||
private extractEvents(events: any[]): {
|
||||
registration: string | null;
|
||||
expiration: string | null;
|
||||
lastChanged: string | null;
|
||||
} {
|
||||
let registration: string | null = null;
|
||||
let expiration: string | null = null;
|
||||
let lastChanged: string | null = null;
|
||||
|
||||
for (const ev of events) {
|
||||
const action = typeof ev?.eventAction === 'string' ? ev.eventAction.toLowerCase() : '';
|
||||
const date = typeof ev?.eventDate === 'string' ? ev.eventDate : null;
|
||||
if (!date) continue;
|
||||
if (action === 'registration') registration = date;
|
||||
else if (action === 'expiration') expiration = date;
|
||||
else if (action === 'last changed') lastChanged = date;
|
||||
}
|
||||
|
||||
return { registration, expiration, lastChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract registrar identity (name, IANA ID) and a nested abuse contact.
|
||||
*/
|
||||
private extractRegistrar(entities: any[]): {
|
||||
name: string | null;
|
||||
ianaId: number | null;
|
||||
abuseEmail: string | null;
|
||||
abusePhone: string | null;
|
||||
} {
|
||||
let name: string | null = null;
|
||||
let ianaId: number | null = null;
|
||||
let abuseEmail: string | null = null;
|
||||
let abusePhone: string | null = null;
|
||||
|
||||
for (const entity of entities) {
|
||||
const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
|
||||
if (!roles.includes('registrar')) continue;
|
||||
|
||||
name = this.extractVcardFn(entity);
|
||||
|
||||
// IANA Registrar ID lives in publicIds[]
|
||||
if (Array.isArray(entity.publicIds)) {
|
||||
for (const pid of entity.publicIds) {
|
||||
if (pid && typeof pid === 'object' && pid.type === 'IANA Registrar ID') {
|
||||
const parsed = parseInt(String(pid.identifier), 10);
|
||||
if (!isNaN(parsed)) ianaId = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Abuse contact: nested entity with role "abuse"
|
||||
if (Array.isArray(entity.entities)) {
|
||||
for (const sub of entity.entities) {
|
||||
const subRoles: string[] = Array.isArray(sub?.roles) ? sub.roles : [];
|
||||
if (subRoles.includes('abuse')) {
|
||||
if (!abuseEmail) abuseEmail = this.extractVcardEmail(sub);
|
||||
if (!abusePhone) abusePhone = this.extractVcardTel(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break; // first registrar wins
|
||||
}
|
||||
|
||||
return { name, ianaId, abuseEmail, abusePhone };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract registrant org/country from entities array.
|
||||
*/
|
||||
private extractRegistrant(entities: any[]): {
|
||||
org: string | null;
|
||||
country: string | null;
|
||||
} {
|
||||
for (const entity of entities) {
|
||||
const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
|
||||
if (!roles.includes('registrant')) continue;
|
||||
const org = this.extractVcardFn(entity);
|
||||
const country = this.extractVcardCountry(entity);
|
||||
return { org, country };
|
||||
}
|
||||
return { org: null, country: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an RDAP nameservers[] array to a list of lowercased ldhName strings.
|
||||
*/
|
||||
private extractNameservers(nameservers: any[]): string[] | null {
|
||||
const out: string[] = [];
|
||||
for (const ns of nameservers) {
|
||||
const ldh = ns?.ldhName;
|
||||
if (typeof ldh === 'string' && ldh.length > 0) {
|
||||
out.push(ldh.toLowerCase());
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
// ─── DNS Layer ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run DNS record lookups for the given domain using smartdns. Queries
|
||||
* NS/A/AAAA/MX/TXT/SOA in parallel via Promise.allSettled — failures in
|
||||
* one record type do not affect the others. Returns an object with each
|
||||
* field either populated or null.
|
||||
*
|
||||
* If a shared dnsClient was injected via constructor options, it is
|
||||
* reused and NOT destroyed (ownership stays with the injector). Otherwise
|
||||
* a short-lived client is created and destroyed in finally.
|
||||
*/
|
||||
private async queryDnsLayer(domain: string): Promise<{
|
||||
nameservers: string[] | null;
|
||||
resolvedIpv4: string[] | null;
|
||||
resolvedIpv6: string[] | null;
|
||||
mxRecords: { priority: number | null; exchange: string }[] | null;
|
||||
txtRecords: string[] | null;
|
||||
soaRecord: string | null;
|
||||
} | null> {
|
||||
const external = this.sharedDnsClient !== null;
|
||||
let dnsClient: TSmartdnsClient | null = this.sharedDnsClient;
|
||||
try {
|
||||
if (!dnsClient) {
|
||||
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||
strategy: 'prefer-system',
|
||||
allowDohFallback: true,
|
||||
timeoutMs: this.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
const [nsRes, aRes, aaaaRes, mxRes, txtRes, soaRes] = await Promise.allSettled([
|
||||
dnsClient.getNameServers(domain),
|
||||
dnsClient.getRecordsA(domain),
|
||||
dnsClient.getRecordsAAAA(domain),
|
||||
dnsClient.getRecords(domain, 'MX'),
|
||||
dnsClient.getRecordsTxt(domain),
|
||||
// 'SOA' is in smartdns's runtime dnsTypeMap but missing from the
|
||||
// TDnsRecordType union in @tsclass/tsclass — cast to bypass the
|
||||
// stale type definition.
|
||||
dnsClient.getRecords(domain, 'SOA' as any),
|
||||
]);
|
||||
|
||||
return {
|
||||
nameservers: this.parseNsResult(nsRes),
|
||||
resolvedIpv4: this.dnsValuesOrNull(aRes),
|
||||
resolvedIpv6: this.dnsValuesOrNull(aaaaRes),
|
||||
mxRecords: this.parseMxRecords(mxRes),
|
||||
txtRecords: this.dnsValuesOrNull(txtRes),
|
||||
soaRecord: this.dnsFirstValueOrNull(soaRes),
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
// Only destroy clients we created ourselves; leave injected ones alone.
|
||||
if (!external && dnsClient) {
|
||||
dnsClient.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract normalized nameserver hostnames from a getNameServers() result.
|
||||
*/
|
||||
private parseNsResult(res: PromiseSettledResult<string[]>): string[] | null {
|
||||
if (res.status !== 'fulfilled' || !Array.isArray(res.value) || res.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const out = res.value
|
||||
.map((ns) => (typeof ns === 'string' ? ns.toLowerCase().replace(/\.$/, '') : ''))
|
||||
.filter((ns) => ns.length > 0);
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `value` strings from a settled DNS lookup result.
|
||||
*/
|
||||
private dnsValuesOrNull(res: PromiseSettledResult<TDnsRecord[]>): string[] | null {
|
||||
if (res.status !== 'fulfilled' || !Array.isArray(res.value) || res.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const values = res.value
|
||||
.map((r) => r.value)
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return values.length > 0 ? values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* First non-empty value from a settled DNS lookup result.
|
||||
*/
|
||||
private dnsFirstValueOrNull(res: PromiseSettledResult<TDnsRecord[]>): string | null {
|
||||
const values = this.dnsValuesOrNull(res);
|
||||
return values?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MX records into {priority, exchange} pairs. smartdns returns MX
|
||||
* values as serialized strings (typically "10 mail.example.com"). We
|
||||
* best-effort parse the priority; if parsing fails we store the whole
|
||||
* value as the exchange with priority=null so the result is still useful.
|
||||
*/
|
||||
private parseMxRecords(
|
||||
res: PromiseSettledResult<TDnsRecord[]>,
|
||||
): { priority: number | null; exchange: string }[] | null {
|
||||
if (res.status !== 'fulfilled' || !Array.isArray(res.value) || res.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const out: { priority: number | null; exchange: string }[] = [];
|
||||
for (const r of res.value) {
|
||||
if (typeof r.value !== 'string' || !r.value.trim()) continue;
|
||||
const match = r.value.trim().match(/^(\d+)\s+(.+?)\.?$/);
|
||||
if (match) {
|
||||
out.push({ priority: parseInt(match[1], 10), exchange: match[2].toLowerCase() });
|
||||
} else {
|
||||
out.push({ priority: null, exchange: r.value.toLowerCase().replace(/\.$/, '') });
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
// ─── vCard helpers (duplicated from IpIntelligence) ─────────────────
|
||||
|
||||
/**
|
||||
* Extract the 'fn' (formatted name) from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardFn(entity: any): string | null {
|
||||
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'fn') {
|
||||
return prop[3] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardEmail(entity: any): string | null {
|
||||
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'email') {
|
||||
return prop[3] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract telephone number from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardTel(entity: any): string | null {
|
||||
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'tel') {
|
||||
// tel value can be a string or a uri like "tel:+1.5555555555"
|
||||
const value = prop[3];
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('tel:') ? value.slice(4) : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract country from an entity's vcardArray address field
|
||||
*/
|
||||
private extractVcardCountry(entity: any): string | null {
|
||||
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'adr') {
|
||||
// The label parameter often contains the full address with country at the end
|
||||
const label = prop[1]?.label;
|
||||
if (typeof label === 'string') {
|
||||
const lines = label.split('\n');
|
||||
const lastLine = lines[lines.length - 1]?.trim();
|
||||
if (lastLine && lastLine.length > 1) return lastLine;
|
||||
}
|
||||
// Also check the structured value (7-element array, last element is country)
|
||||
const value = prop[3];
|
||||
if (Array.isArray(value) && value.length >= 7 && value[6]) {
|
||||
return value[6];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import { getLogger } from './logging.js';
|
||||
|
||||
// MaxMind types re-exported from mmdb-lib via maxmind
|
||||
import type { AsnResponse, Reader, Response } from 'maxmind';
|
||||
|
||||
/**
|
||||
* The @ip-location-db MMDB files use a flat schema instead of the standard MaxMind nested format.
|
||||
*/
|
||||
interface IIpLocationDbCityRecord {
|
||||
city?: string;
|
||||
country_code?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
postcode?: string;
|
||||
state1?: string;
|
||||
state2?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified result from all IP intelligence layers
|
||||
*/
|
||||
export interface IIpIntelligenceResult {
|
||||
// ASN (Team Cymru primary, MaxMind fallback)
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
|
||||
// Registration (RDAP)
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
networkRange: string | null;
|
||||
abuseContact: string | null;
|
||||
|
||||
// Geolocation (MaxMind GeoLite2 City)
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadius: number | null;
|
||||
timezone: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for IpIntelligence
|
||||
*/
|
||||
export interface IIpIntelligenceOptions {
|
||||
/** Max age (ms) before triggering background MMDB refresh. Default: 7 days */
|
||||
dbMaxAge?: number;
|
||||
/** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
|
||||
timeout?: number;
|
||||
/**
|
||||
* Optional injected smartdns client. When provided, IpIntelligence will
|
||||
* not create or destroy its own client (the owner — typically SmartNetwork —
|
||||
* manages lifecycle). When omitted, a short-lived client is created per
|
||||
* Team Cymru lookup and destroyed in finally.
|
||||
*/
|
||||
dnsClient?: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
|
||||
}
|
||||
|
||||
// CDN URLs for GeoLite2 MMDB files (served via jsDelivr from npm packages)
|
||||
const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb';
|
||||
const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb';
|
||||
|
||||
// IANA bootstrap for RDAP
|
||||
const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json';
|
||||
|
||||
const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const DEFAULT_TIMEOUT = 5000;
|
||||
|
||||
/**
|
||||
* Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL
|
||||
*/
|
||||
interface IBootstrapEntry {
|
||||
prefix: string;
|
||||
prefixNum: number; // numeric representation of the network address
|
||||
maskBits: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IpIntelligence provides IP address intelligence by combining three data sources:
|
||||
* - RDAP (direct to RIRs) for registration/org data
|
||||
* - Team Cymru DNS for ASN
|
||||
* - MaxMind GeoLite2 (in-memory MMDB) for geolocation
|
||||
*/
|
||||
export class IpIntelligence {
|
||||
private readonly logger = getLogger();
|
||||
private readonly dbMaxAge: number;
|
||||
private readonly timeout: number;
|
||||
|
||||
// MaxMind readers (lazily initialized)
|
||||
private cityReader: Reader<IIpLocationDbCityRecord & Response> | null = null;
|
||||
private asnReader: Reader<AsnResponse> | null = null;
|
||||
private lastFetchTime = 0;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
|
||||
// RDAP bootstrap cache
|
||||
private bootstrapEntries: IBootstrapEntry[] | null = null;
|
||||
private bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
// Optional injected smartdns client (shared by SmartNetwork)
|
||||
private readonly sharedDnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null;
|
||||
|
||||
constructor(options?: IIpIntelligenceOptions) {
|
||||
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
|
||||
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
this.sharedDnsClient = options?.dnsClient ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive IP intelligence for the given IP address.
|
||||
* Runs RDAP, Team Cymru DNS, and MaxMind lookups in parallel.
|
||||
*/
|
||||
public async getIntelligence(ip: string): Promise<IIpIntelligenceResult> {
|
||||
const result: IIpIntelligenceResult = {
|
||||
asn: null,
|
||||
asnOrg: null,
|
||||
registrantOrg: null,
|
||||
registrantCountry: null,
|
||||
networkRange: null,
|
||||
abuseContact: null,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadius: null,
|
||||
timezone: null,
|
||||
};
|
||||
|
||||
// Run all three layers in parallel
|
||||
const [rdapResult, cymruResult, maxmindResult] = await Promise.allSettled([
|
||||
this.queryRdap(ip),
|
||||
this.queryTeamCymru(ip),
|
||||
this.queryMaxMind(ip),
|
||||
]);
|
||||
|
||||
// Merge RDAP results
|
||||
if (rdapResult.status === 'fulfilled' && rdapResult.value) {
|
||||
const rdap = rdapResult.value;
|
||||
result.registrantOrg = rdap.registrantOrg;
|
||||
result.registrantCountry = rdap.registrantCountry;
|
||||
result.networkRange = rdap.networkRange;
|
||||
result.abuseContact = rdap.abuseContact;
|
||||
}
|
||||
|
||||
// Merge Team Cymru results (primary for ASN)
|
||||
if (cymruResult.status === 'fulfilled' && cymruResult.value) {
|
||||
const cymru = cymruResult.value;
|
||||
result.asn = cymru.asn;
|
||||
}
|
||||
|
||||
// Merge MaxMind results
|
||||
if (maxmindResult.status === 'fulfilled' && maxmindResult.value) {
|
||||
const mm = maxmindResult.value;
|
||||
result.country = mm.country;
|
||||
result.countryCode = mm.countryCode;
|
||||
result.city = mm.city;
|
||||
result.latitude = mm.latitude;
|
||||
result.longitude = mm.longitude;
|
||||
result.accuracyRadius = mm.accuracyRadius;
|
||||
result.timezone = mm.timezone;
|
||||
|
||||
// Use MaxMind ASN as fallback if Team Cymru failed
|
||||
if (result.asn === null && mm.asn !== null) {
|
||||
result.asn = mm.asn;
|
||||
}
|
||||
if (mm.asnOrg) {
|
||||
result.asnOrg = mm.asnOrg;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got ASN from Team Cymru but not org, and MaxMind didn't provide org either,
|
||||
// the asnOrg remains null (we don't do an additional lookup)
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── RDAP Subsystem ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and cache the IANA RDAP bootstrap file
|
||||
*/
|
||||
private async ensureBootstrap(): Promise<void> {
|
||||
if (this.bootstrapEntries) return;
|
||||
if (this.bootstrapPromise) {
|
||||
await this.bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bootstrapPromise = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const response = await fetch(IANA_BOOTSTRAP_IPV4_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json() as { services: [string[], string[]][] };
|
||||
|
||||
const entries: IBootstrapEntry[] = [];
|
||||
for (const [prefixes, urls] of data.services) {
|
||||
const baseUrl = urls[0]; // first URL is preferred
|
||||
for (const prefix of prefixes) {
|
||||
const [network, bits] = prefix.split('/');
|
||||
entries.push({
|
||||
prefix,
|
||||
prefixNum: this.ipToNumber(network),
|
||||
maskBits: parseInt(bits, 10),
|
||||
baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by mask bits descending for longest-prefix match
|
||||
entries.sort((a, b) => b.maskBits - a.maskBits);
|
||||
this.bootstrapEntries = entries;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`);
|
||||
this.bootstrapEntries = []; // empty = all RDAP lookups will skip
|
||||
}
|
||||
})();
|
||||
|
||||
await this.bootstrapPromise;
|
||||
this.bootstrapPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the RDAP base URL for a given IP via longest-prefix match
|
||||
*/
|
||||
private matchRir(ip: string): string | null {
|
||||
if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null;
|
||||
|
||||
const ipNum = this.ipToNumber(ip);
|
||||
|
||||
for (const entry of this.bootstrapEntries) {
|
||||
const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0;
|
||||
if ((ipNum & mask) === (entry.prefixNum & mask)) {
|
||||
return entry.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query RDAP for registration data
|
||||
*/
|
||||
private async queryRdap(ip: string): Promise<{
|
||||
registrantOrg: string | null;
|
||||
registrantCountry: string | null;
|
||||
networkRange: string | null;
|
||||
abuseContact: string | null;
|
||||
} | null> {
|
||||
await this.ensureBootstrap();
|
||||
const baseUrl = this.matchRir(ip);
|
||||
if (!baseUrl) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/ip/${ip}`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/rdap+json',
|
||||
'User-Agent': '@push.rocks/smartnetwork',
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as any;
|
||||
|
||||
let registrantOrg: string | null = null;
|
||||
let registrantCountry: string | null = data.country || null;
|
||||
let abuseContact: string | null = null;
|
||||
|
||||
// Parse network range
|
||||
let networkRange: string | null = null;
|
||||
if (data.cidr0_cidrs && data.cidr0_cidrs.length > 0) {
|
||||
const cidr = data.cidr0_cidrs[0];
|
||||
networkRange = `${cidr.v4prefix || cidr.v6prefix}/${cidr.length}`;
|
||||
} else if (data.startAddress && data.endAddress) {
|
||||
networkRange = `${data.startAddress} - ${data.endAddress}`;
|
||||
}
|
||||
|
||||
// Parse entities
|
||||
if (data.entities && Array.isArray(data.entities)) {
|
||||
for (const entity of data.entities) {
|
||||
const roles: string[] = entity.roles || [];
|
||||
|
||||
if (roles.includes('registrant') || roles.includes('administrative')) {
|
||||
const orgName = this.extractVcardFn(entity);
|
||||
if (orgName) registrantOrg = orgName;
|
||||
|
||||
// Try to get country from registrant address if not at top level
|
||||
if (!registrantCountry) {
|
||||
registrantCountry = this.extractVcardCountry(entity);
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.includes('abuse')) {
|
||||
abuseContact = this.extractVcardEmail(entity);
|
||||
// Check nested entities for abuse contact
|
||||
if (!abuseContact && entity.entities) {
|
||||
for (const subEntity of entity.entities) {
|
||||
const subRoles: string[] = subEntity.roles || [];
|
||||
if (subRoles.includes('abuse')) {
|
||||
abuseContact = this.extractVcardEmail(subEntity);
|
||||
if (abuseContact) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { registrantOrg, registrantCountry, networkRange, abuseContact };
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'fn' (formatted name) from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardFn(entity: any): string | null {
|
||||
if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'fn') {
|
||||
return prop[3] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email from an entity's vcardArray
|
||||
*/
|
||||
private extractVcardEmail(entity: any): string | null {
|
||||
if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'email') {
|
||||
return prop[3] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract country from an entity's vcardArray address field
|
||||
*/
|
||||
private extractVcardCountry(entity: any): string | null {
|
||||
if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
||||
const properties = entity.vcardArray[1];
|
||||
if (!Array.isArray(properties)) return null;
|
||||
|
||||
for (const prop of properties) {
|
||||
if (Array.isArray(prop) && prop[0] === 'adr') {
|
||||
// The label parameter often contains the full address with country at the end
|
||||
const label = prop[1]?.label;
|
||||
if (typeof label === 'string') {
|
||||
const lines = label.split('\n');
|
||||
const lastLine = lines[lines.length - 1]?.trim();
|
||||
if (lastLine && lastLine.length > 1) return lastLine;
|
||||
}
|
||||
// Also check the structured value (7-element array, last element is country)
|
||||
const value = prop[3];
|
||||
if (Array.isArray(value) && value.length >= 7 && value[6]) {
|
||||
return value[6];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Team Cymru DNS Subsystem ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Query Team Cymru DNS for ASN information.
|
||||
* Query format: reversed.ip.origin.asn.cymru.com TXT
|
||||
* Response: "ASN | prefix | CC | rir | date"
|
||||
*/
|
||||
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
|
||||
const external = this.sharedDnsClient !== null;
|
||||
let dnsClient = this.sharedDnsClient;
|
||||
try {
|
||||
const reversed = ip.split('.').reverse().join('.');
|
||||
const queryName = `${reversed}.origin.asn.cymru.com`;
|
||||
|
||||
if (!dnsClient) {
|
||||
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||
strategy: 'prefer-system',
|
||||
allowDohFallback: true,
|
||||
timeoutMs: this.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
const records = await dnsClient.getRecordsTxt(queryName);
|
||||
if (!records || records.length === 0) return null;
|
||||
|
||||
// Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
|
||||
const txt = records[0].value || (records[0] as any).data;
|
||||
if (!txt) return null;
|
||||
|
||||
const parts = txt.split('|').map((s: string) => s.trim());
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
const asn = parseInt(parts[0], 10);
|
||||
if (isNaN(asn)) return null;
|
||||
|
||||
return {
|
||||
asn,
|
||||
prefix: parts[1] || '',
|
||||
country: parts[2] || '',
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
// Only destroy clients we created ourselves; leave injected ones alone.
|
||||
if (!external && dnsClient) {
|
||||
dnsClient.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MaxMind GeoLite2 Subsystem ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure MMDB readers are initialized. Downloads on first call,
|
||||
* triggers background refresh if stale.
|
||||
*/
|
||||
private async ensureReaders(): Promise<void> {
|
||||
if (this.cityReader && this.asnReader) {
|
||||
// Check if refresh needed
|
||||
if (Date.now() - this.lastFetchTime > this.dbMaxAge && !this.refreshPromise) {
|
||||
this.refreshPromise = this.downloadAndInitReaders()
|
||||
.catch((err) => this.logger.debug?.(`Background MMDB refresh failed: ${err.message}`))
|
||||
.finally(() => { this.refreshPromise = null; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First time: blocking download
|
||||
if (this.refreshPromise) {
|
||||
await this.refreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshPromise = this.downloadAndInitReaders();
|
||||
await this.refreshPromise;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download MMDB files from CDN and create Reader instances
|
||||
*/
|
||||
private async downloadAndInitReaders(): Promise<void> {
|
||||
const [cityBuffer, asnBuffer] = await Promise.all([
|
||||
this.fetchBuffer(CITY_MMDB_URL),
|
||||
this.fetchBuffer(ASN_MMDB_URL),
|
||||
]);
|
||||
|
||||
this.cityReader = new plugins.maxmind.Reader<IIpLocationDbCityRecord & Response>(cityBuffer);
|
||||
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
|
||||
this.lastFetchTime = Date.now();
|
||||
this.logger.info?.('MaxMind MMDB databases loaded into memory');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL and return the response as a Buffer
|
||||
*/
|
||||
private async fetchBuffer(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query MaxMind for geo + ASN data
|
||||
*/
|
||||
private async queryMaxMind(ip: string): Promise<{
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadius: number | null;
|
||||
timezone: string | null;
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
await this.ensureReaders();
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`Failed to initialize MaxMind readers: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.cityReader || !this.asnReader) return null;
|
||||
|
||||
let country: string | null = null;
|
||||
let countryCode: string | null = null;
|
||||
let city: string | null = null;
|
||||
let latitude: number | null = null;
|
||||
let longitude: number | null = null;
|
||||
let accuracyRadius: number | null = null;
|
||||
let timezone: string | null = null;
|
||||
let asn: number | null = null;
|
||||
let asnOrg: string | null = null;
|
||||
|
||||
// City lookup — @ip-location-db uses flat schema: city, country_code, latitude, longitude, etc.
|
||||
try {
|
||||
const cityResult = this.cityReader.get(ip);
|
||||
if (cityResult) {
|
||||
countryCode = cityResult.country_code || null;
|
||||
city = cityResult.city || null;
|
||||
latitude = cityResult.latitude ?? null;
|
||||
longitude = cityResult.longitude ?? null;
|
||||
timezone = cityResult.timezone || null;
|
||||
// @ip-location-db does not include country name or accuracy_radius
|
||||
// We leave country null (countryCode is available)
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
|
||||
}
|
||||
|
||||
// ASN lookup
|
||||
try {
|
||||
const asnResult = this.asnReader.get(ip);
|
||||
if (asnResult) {
|
||||
asn = asnResult.autonomous_system_number ?? null;
|
||||
asnOrg = asnResult.autonomous_system_organization || null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.debug?.(`MaxMind ASN lookup failed for ${ip}: ${err.message}`);
|
||||
}
|
||||
|
||||
return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
|
||||
}
|
||||
|
||||
// ─── Utilities ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert an IPv4 address string to a 32-bit unsigned number
|
||||
*/
|
||||
private ipToNumber(ip: string): number {
|
||||
const parts = ip.split('.');
|
||||
return (
|
||||
((parseInt(parts[0], 10) << 24) |
|
||||
(parseInt(parts[1], 10) << 16) |
|
||||
(parseInt(parts[2], 10) << 8) |
|
||||
parseInt(parts[3], 10)) >>> 0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { getLogger } from './logging.js';
|
||||
|
||||
/**
|
||||
* Service configuration for IP detection
|
||||
*/
|
||||
interface IpService {
|
||||
name: string;
|
||||
v4Url?: string;
|
||||
v6Url?: string;
|
||||
parseResponse?: (text: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PublicIp class for detecting public IPv4 and IPv6 addresses
|
||||
* Uses multiple fallback services for reliability
|
||||
*/
|
||||
export class PublicIp {
|
||||
private readonly services: IpService[] = [
|
||||
{
|
||||
name: 'ipify',
|
||||
v4Url: 'https://api.ipify.org?format=text',
|
||||
v6Url: 'https://api6.ipify.org?format=text',
|
||||
},
|
||||
{
|
||||
name: 'ident.me',
|
||||
v4Url: 'https://v4.ident.me',
|
||||
v6Url: 'https://v6.ident.me',
|
||||
},
|
||||
{
|
||||
name: 'seeip',
|
||||
v4Url: 'https://ipv4.seeip.org',
|
||||
v6Url: 'https://ipv6.seeip.org',
|
||||
},
|
||||
{
|
||||
name: 'icanhazip',
|
||||
v4Url: 'https://ipv4.icanhazip.com',
|
||||
v6Url: 'https://ipv6.icanhazip.com',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly timeout: number;
|
||||
private readonly logger = getLogger();
|
||||
|
||||
constructor(options?: { timeout?: number }) {
|
||||
this.timeout = options?.timeout ?? 2000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public IPv4 address
|
||||
*/
|
||||
public async getPublicIpv4(): Promise<string | null> {
|
||||
for (const service of this.services) {
|
||||
if (!service.v4Url) continue;
|
||||
|
||||
try {
|
||||
const ip = await this.fetchIpFromService(service.v4Url, service.parseResponse);
|
||||
if (this.isValidIpv4(ip)) {
|
||||
this.logger.info?.(`Got IPv4 from ${service.name}: ${ip}`);
|
||||
return ip;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.debug?.(`Failed to get IPv4 from ${service.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn?.('Could not determine public IPv4 address from any service');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public IPv6 address
|
||||
*/
|
||||
public async getPublicIpv6(): Promise<string | null> {
|
||||
for (const service of this.services) {
|
||||
if (!service.v6Url) continue;
|
||||
|
||||
try {
|
||||
const ip = await this.fetchIpFromService(service.v6Url, service.parseResponse);
|
||||
if (this.isValidIpv6(ip)) {
|
||||
this.logger.info?.(`Got IPv6 from ${service.name}: ${ip}`);
|
||||
return ip;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.debug?.(`Failed to get IPv6 from ${service.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn?.('Could not determine public IPv6 address from any service');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get both IPv4 and IPv6 addresses
|
||||
*/
|
||||
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
|
||||
const [v4, v6] = await Promise.all([
|
||||
this.getPublicIpv4(),
|
||||
this.getPublicIpv6(),
|
||||
]);
|
||||
|
||||
return { v4, v6 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch IP from a service URL
|
||||
*/
|
||||
private async fetchIpFromService(
|
||||
url: string,
|
||||
parseResponse?: (text: string) => string
|
||||
): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': '@push.rocks/smartnetwork',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const ip = parseResponse ? parseResponse(text) : text.trim();
|
||||
|
||||
return ip;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IPv4 address format
|
||||
*/
|
||||
private isValidIpv4(ip: string): boolean {
|
||||
if (!ip) return false;
|
||||
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!ipv4Regex.test(ip)) return false;
|
||||
|
||||
const parts = ip.split('.');
|
||||
return parts.every(part => {
|
||||
const num = parseInt(part, 10);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IPv6 address format
|
||||
*/
|
||||
private isValidIpv6(ip: string): boolean {
|
||||
if (!ip) return false;
|
||||
|
||||
// Simplified IPv6 validation - checks for colon-separated hex values
|
||||
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
||||
const ipv6CompressedRegex = /^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,7}:$/;
|
||||
|
||||
return ipv6Regex.test(ip) || ipv6CompressedRegex.test(ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Command map for the rustnetwork IPC binary.
|
||||
* Each key maps to { params, result } defining the typed IPC protocol.
|
||||
*/
|
||||
type TNetworkCommands = {
|
||||
healthPing: {
|
||||
params: Record<string, never>;
|
||||
result: { pong: boolean };
|
||||
};
|
||||
ping: {
|
||||
params: { host: string; count?: number; timeoutMs?: number };
|
||||
result: {
|
||||
alive: boolean;
|
||||
times: (number | null)[];
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
stddev: number | null;
|
||||
packetLoss: number;
|
||||
};
|
||||
};
|
||||
traceroute: {
|
||||
params: { host: string; maxHops?: number; timeoutMs?: number };
|
||||
result: {
|
||||
hops: Array<{ ttl: number; ip: string; rtt: number | null }>;
|
||||
};
|
||||
};
|
||||
tcpPortCheck: {
|
||||
params: { host: string; port: number; timeoutMs?: number };
|
||||
result: { isOpen: boolean; latencyMs: number | null };
|
||||
};
|
||||
isLocalPortFree: {
|
||||
params: { port: number };
|
||||
result: { free: boolean };
|
||||
};
|
||||
defaultGateway: {
|
||||
params: Record<string, never>;
|
||||
result: {
|
||||
interfaceName: string;
|
||||
addresses: Array<{ family: string; address: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function getPlatformSuffix(): string | null {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
linux: 'linux',
|
||||
darwin: 'macos',
|
||||
win32: 'windows',
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
x64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
};
|
||||
|
||||
const p = platformMap[platform];
|
||||
const a = archMap[arch];
|
||||
|
||||
if (p && a) {
|
||||
return `${p}_${a}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton bridge to the rustnetwork binary.
|
||||
* Manages the IPC lifecycle for network diagnostics operations.
|
||||
*/
|
||||
export class RustNetworkBridge {
|
||||
private static instance: RustNetworkBridge | null = null;
|
||||
|
||||
public static getInstance(): RustNetworkBridge {
|
||||
if (!RustNetworkBridge.instance) {
|
||||
RustNetworkBridge.instance = new RustNetworkBridge();
|
||||
}
|
||||
return RustNetworkBridge.instance;
|
||||
}
|
||||
|
||||
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TNetworkCommands>>;
|
||||
|
||||
private constructor() {
|
||||
const packageDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
|
||||
const platformSuffix = getPlatformSuffix();
|
||||
const localPaths: string[] = [];
|
||||
|
||||
// Platform-specific cross-compiled binary
|
||||
if (platformSuffix) {
|
||||
localPaths.push(path.join(packageDir, 'dist_rust', `rustnetwork_${platformSuffix}`));
|
||||
}
|
||||
// Native build without suffix
|
||||
localPaths.push(path.join(packageDir, 'dist_rust', 'rustnetwork'));
|
||||
// Local dev paths
|
||||
localPaths.push(path.join(packageDir, 'rust', 'target', 'release', 'rustnetwork'));
|
||||
localPaths.push(path.join(packageDir, 'rust', 'target', 'debug', 'rustnetwork'));
|
||||
|
||||
this.bridge = new plugins.smartrust.RustBridge<TNetworkCommands>({
|
||||
binaryName: 'rustnetwork',
|
||||
cliArgs: ['--management'],
|
||||
requestTimeoutMs: 30_000,
|
||||
readyTimeoutMs: 10_000,
|
||||
localPaths,
|
||||
searchSystemPath: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the Rust binary and wait for it to be ready.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
const ok = await this.bridge.spawn();
|
||||
if (!ok) {
|
||||
throw new Error('Failed to spawn rustnetwork binary');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the Rust binary.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.bridge.kill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the bridge is running before sending a command.
|
||||
*/
|
||||
private async ensureRunning(): Promise<void> {
|
||||
// The bridge will throw if not spawned — we just call start() if not yet running
|
||||
}
|
||||
|
||||
// ===== Command wrappers =====
|
||||
|
||||
public async ping(
|
||||
host: string,
|
||||
count?: number,
|
||||
timeoutMs?: number,
|
||||
): Promise<TNetworkCommands['ping']['result']> {
|
||||
return this.bridge.sendCommand('ping', { host, count, timeoutMs });
|
||||
}
|
||||
|
||||
public async traceroute(
|
||||
host: string,
|
||||
maxHops?: number,
|
||||
timeoutMs?: number,
|
||||
): Promise<TNetworkCommands['traceroute']['result']> {
|
||||
return this.bridge.sendCommand('traceroute', { host, maxHops, timeoutMs });
|
||||
}
|
||||
|
||||
public async tcpPortCheck(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs?: number,
|
||||
): Promise<TNetworkCommands['tcpPortCheck']['result']> {
|
||||
return this.bridge.sendCommand('tcpPortCheck', { host, port, timeoutMs });
|
||||
}
|
||||
|
||||
public async isLocalPortFree(
|
||||
port: number,
|
||||
): Promise<TNetworkCommands['isLocalPortFree']['result']> {
|
||||
return this.bridge.sendCommand('isLocalPortFree', { port });
|
||||
}
|
||||
|
||||
public async defaultGateway(): Promise<TNetworkCommands['defaultGateway']['result']> {
|
||||
return this.bridge.sendCommand('defaultGateway', {} as Record<string, never>);
|
||||
}
|
||||
|
||||
public async healthPing(): Promise<TNetworkCommands['healthPing']['result']> {
|
||||
return this.bridge.sendCommand('healthPing', {} as Record<string, never>);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,479 @@
|
||||
import * as plugins from './smartnetwork.plugins';
|
||||
import * as plugins from './smartnetwork.plugins.js';
|
||||
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
|
||||
import { PublicIp } from './smartnetwork.classes.publicip.js';
|
||||
import { IpIntelligence, type IIpIntelligenceResult } from './smartnetwork.classes.ipintelligence.js';
|
||||
import { DomainIntelligence, type IDomainIntelligenceResult } from './smartnetwork.classes.domainintelligence.js';
|
||||
import { getLogger } from './logging.js';
|
||||
import { NetworkError } from './errors.js';
|
||||
import { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
|
||||
|
||||
export interface ISpeedtestData {
|
||||
speeds: {
|
||||
download: number;
|
||||
upload: number;
|
||||
originalDownload: number;
|
||||
originalUpload: number;
|
||||
};
|
||||
client: {
|
||||
ip: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
isp: string;
|
||||
isprating: string;
|
||||
rating: number;
|
||||
ispdlavg: number;
|
||||
ispulavg: number;
|
||||
};
|
||||
server: {
|
||||
host: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
location: string;
|
||||
country: string;
|
||||
cc: string;
|
||||
sponsor: string;
|
||||
distance: number;
|
||||
distanceMi: number;
|
||||
ping: number;
|
||||
id: string;
|
||||
};
|
||||
/** Type alias for the shared Smartdns client instance */
|
||||
type TSmartdnsClient = InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
|
||||
|
||||
/**
|
||||
* Configuration options for SmartNetwork
|
||||
*/
|
||||
export interface SmartNetworkOptions {
|
||||
/** Cache time-to-live in milliseconds for gateway and public IP lookups */
|
||||
cacheTtl?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SmartNetwork simplifies actions within the network
|
||||
* A hop in a traceroute result
|
||||
*/
|
||||
export interface Hop {
|
||||
ttl: number;
|
||||
ip: string;
|
||||
rtt: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the findFreePort method
|
||||
*/
|
||||
export interface IFindFreePortOptions {
|
||||
/** If true, selects a random available port within the range instead of the first one */
|
||||
randomize?: boolean;
|
||||
/** Array of port numbers to exclude from the search */
|
||||
exclude?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SmartNetwork simplifies actions within the network.
|
||||
* Uses a Rust binary for system-dependent operations (ping, traceroute, port scanning, gateway detection).
|
||||
*/
|
||||
export class SmartNetwork {
|
||||
/**
|
||||
* get network speed
|
||||
* @param measurementTime
|
||||
*/
|
||||
public async getSpeed(measurementTime = 5000): Promise<ISpeedtestData> {
|
||||
const done = plugins.smartpromise.defer<ISpeedtestData>();
|
||||
const test = plugins.speedtestNet({ maxTime: measurementTime });
|
||||
test.on('data', (data) => {
|
||||
done.resolve(data);
|
||||
});
|
||||
test.on('error', (err) => {
|
||||
done.reject(err);
|
||||
});
|
||||
return await done.promise;
|
||||
/** Static registry for external plugins */
|
||||
public static pluginsRegistry: Map<string, any> = new Map();
|
||||
/** Register a plugin by name */
|
||||
public static registerPlugin(name: string, ctor: any): void {
|
||||
SmartNetwork.pluginsRegistry.set(name, ctor);
|
||||
}
|
||||
/** Unregister a plugin by name */
|
||||
public static unregisterPlugin(name: string): void {
|
||||
SmartNetwork.pluginsRegistry.delete(name);
|
||||
}
|
||||
|
||||
private options: SmartNetworkOptions;
|
||||
private cache: Map<string, { value: any; expiry: number }>;
|
||||
private rustBridge: RustNetworkBridge;
|
||||
private bridgeStarted = false;
|
||||
private ipIntelligence: IpIntelligence | null = null;
|
||||
private domainIntelligence: DomainIntelligence | null = null;
|
||||
private dnsClient: TSmartdnsClient | null = null;
|
||||
|
||||
constructor(options?: SmartNetworkOptions) {
|
||||
this.options = options || {};
|
||||
this.cache = new Map();
|
||||
this.rustBridge = RustNetworkBridge.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a promise with a boolean answer
|
||||
* note: false also resolves with false as argument
|
||||
* @param port
|
||||
* Start the Rust binary bridge. Must be called before using ping, traceroute,
|
||||
* port scanning, or gateway operations. Safe to call multiple times.
|
||||
*/
|
||||
public async isLocalPortAvailable(port: number): Promise<boolean> {
|
||||
const doneIpV4 = plugins.smartpromise.defer<boolean>();
|
||||
const doneIpV6 = plugins.smartpromise.defer<boolean>();
|
||||
const net = await import('net'); // creates only one instance of net ;) even on multiple calls
|
||||
public async start(): Promise<void> {
|
||||
if (!this.bridgeStarted) {
|
||||
await this.rustBridge.start();
|
||||
this.bridgeStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// test IPv4 space
|
||||
const ipv4Test = net.createServer();
|
||||
ipv4Test.once('error', (err: any) => {
|
||||
if (err.code !== 'EADDRINUSE') {
|
||||
doneIpV4.resolve(false);
|
||||
return;
|
||||
}
|
||||
doneIpV4.resolve(false);
|
||||
});
|
||||
ipv4Test.once('listening', () => {
|
||||
ipv4Test.once('close', () => {
|
||||
doneIpV4.resolve(true);
|
||||
/**
|
||||
* Stop the Rust binary bridge and tear down the shared Smartdns client.
|
||||
* Call this before your Node process exits if you've used any DNS or
|
||||
* Rust-backed features, otherwise the smartdns Rust backend may keep
|
||||
* the event loop alive.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.bridgeStarted) {
|
||||
await this.rustBridge.stop();
|
||||
this.bridgeStarted = false;
|
||||
}
|
||||
if (this.dnsClient) {
|
||||
this.dnsClient.destroy();
|
||||
this.dnsClient = null;
|
||||
// Intelligence instances hold a stale reference to the destroyed
|
||||
// client; drop them so the next call rebuilds with a fresh one.
|
||||
this.ipIntelligence = null;
|
||||
this.domainIntelligence = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Rust bridge is running before sending commands.
|
||||
*/
|
||||
private async ensureBridge(): Promise<void> {
|
||||
if (!this.bridgeStarted) {
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily create the shared Smartdns client. The Rust backend inside
|
||||
* Smartdns is only spawned on first query that requires it (NS/MX/SOA
|
||||
* with prefer-system strategy, or any query with doh/udp strategy).
|
||||
* The client is destroyed by stop().
|
||||
*/
|
||||
private ensureDnsClient(): TSmartdnsClient {
|
||||
if (!this.dnsClient) {
|
||||
this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||
strategy: 'prefer-system',
|
||||
allowDohFallback: true,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
ipv4Test.close();
|
||||
});
|
||||
ipv4Test.listen(port, '0.0.0.0');
|
||||
|
||||
await doneIpV4.promise;
|
||||
|
||||
// test IPv6 space
|
||||
const ipv6Test = net.createServer();
|
||||
ipv6Test.once('error', function (err: any) {
|
||||
if (err.code !== 'EADDRINUSE') {
|
||||
doneIpV6.resolve(false);
|
||||
return;
|
||||
}
|
||||
doneIpV6.resolve(false);
|
||||
});
|
||||
ipv6Test.once('listening', () => {
|
||||
ipv6Test.once('close', () => {
|
||||
doneIpV6.resolve(true);
|
||||
});
|
||||
ipv6Test.close();
|
||||
});
|
||||
ipv6Test.listen(port, '::');
|
||||
|
||||
// lets wait for the result
|
||||
const resultIpV4 = await doneIpV4.promise;
|
||||
const resultIpV6 = await doneIpV6.promise;
|
||||
const result = resultIpV4 === true && resultIpV6 === true;
|
||||
return result;
|
||||
}
|
||||
return this.dnsClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* checks wether a remote port is available
|
||||
* @param domainArg
|
||||
* Get network speed via Cloudflare speed test (pure TS, no Rust needed).
|
||||
*/
|
||||
public async isRemotePortAvailable(domainArg: string, portArg?: number): Promise<boolean> {
|
||||
const done = plugins.smartpromise.defer<boolean>();
|
||||
const domainPart = domainArg.split(':')[0];
|
||||
const port = portArg ? portArg : parseInt(domainArg.split(':')[1], 10);
|
||||
public async getSpeed(opts?: { parallelStreams?: number; duration?: number }) {
|
||||
const cloudflareSpeedInstance = new CloudflareSpeed(opts);
|
||||
return cloudflareSpeedInstance.speedTest();
|
||||
}
|
||||
|
||||
plugins.isopen(domainPart, port, (response) => {
|
||||
console.log(response);
|
||||
if (response[port.toString()].isOpen) {
|
||||
done.resolve(true);
|
||||
} else {
|
||||
done.resolve(false);
|
||||
/**
|
||||
* Send ICMP pings to a host. Optionally specify count for multiple pings.
|
||||
*/
|
||||
public async ping(host: string, opts?: { timeout?: number; count?: number }): Promise<any> {
|
||||
await this.ensureBridge();
|
||||
const timeoutMs = opts?.timeout ?? 5000;
|
||||
const count = opts?.count && opts.count > 1 ? opts.count : 1;
|
||||
|
||||
let result: Awaited<ReturnType<typeof this.rustBridge.ping>>;
|
||||
try {
|
||||
result = await this.rustBridge.ping(host, count, timeoutMs);
|
||||
} catch {
|
||||
// DNS resolution failure or other error — return dead ping
|
||||
if (count === 1) {
|
||||
return { alive: false, time: NaN };
|
||||
}
|
||||
});
|
||||
const result = await done.promise;
|
||||
return result;
|
||||
return {
|
||||
host,
|
||||
count,
|
||||
times: Array(count).fill(NaN),
|
||||
min: NaN,
|
||||
max: NaN,
|
||||
avg: NaN,
|
||||
stddev: NaN,
|
||||
packetLoss: 100,
|
||||
alive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Map times: replace null with NaN for backward compatibility
|
||||
const times = result.times.map((t) => (t === null ? NaN : t));
|
||||
const min = result.min === null ? NaN : result.min;
|
||||
const max = result.max === null ? NaN : result.max;
|
||||
const avg = result.avg === null ? NaN : result.avg;
|
||||
const stddev = result.stddev === null ? NaN : result.stddev;
|
||||
|
||||
if (count === 1) {
|
||||
return {
|
||||
alive: result.alive,
|
||||
time: times[0] ?? NaN,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
count,
|
||||
times,
|
||||
min,
|
||||
max,
|
||||
avg,
|
||||
stddev,
|
||||
packetLoss: result.packetLoss,
|
||||
alive: result.alive,
|
||||
};
|
||||
}
|
||||
|
||||
public async getGateways() {
|
||||
const result = plugins.os.networkInterfaces();
|
||||
return result;
|
||||
/**
|
||||
* Check if a local port is unused (both IPv4 and IPv6)
|
||||
*/
|
||||
public async isLocalPortUnused(port: number): Promise<boolean> {
|
||||
await this.ensureBridge();
|
||||
const result = await this.rustBridge.isLocalPortFree(port);
|
||||
return result.free;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first available port within a given range
|
||||
*/
|
||||
public async findFreePort(startPort: number, endPort: number, options?: IFindFreePortOptions): Promise<number | null> {
|
||||
// Validate port range
|
||||
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
||||
throw new NetworkError('Port numbers must be between 1 and 65535', 'EINVAL');
|
||||
}
|
||||
if (startPort > endPort) {
|
||||
throw new NetworkError('Start port must be less than or equal to end port', 'EINVAL');
|
||||
}
|
||||
|
||||
const excludedPorts = new Set(options?.exclude || []);
|
||||
|
||||
if (options?.randomize) {
|
||||
const availablePorts: number[] = [];
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
if (excludedPorts.has(port)) continue;
|
||||
const isUnused = await this.isLocalPortUnused(port);
|
||||
if (isUnused) {
|
||||
availablePorts.push(port);
|
||||
}
|
||||
}
|
||||
if (availablePorts.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * availablePorts.length);
|
||||
return availablePorts[randomIndex];
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
if (excludedPorts.has(port)) continue;
|
||||
const isUnused = await this.isLocalPortUnused(port);
|
||||
if (isUnused) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a remote port is available
|
||||
*/
|
||||
public async isRemotePortAvailable(
|
||||
target: string,
|
||||
portOrOpts?:
|
||||
| number
|
||||
| { port?: number; protocol?: 'tcp' | 'udp'; timeout?: number; retries?: number },
|
||||
): Promise<boolean> {
|
||||
let hostPart: string;
|
||||
let port: number | undefined;
|
||||
let protocol: string = 'tcp';
|
||||
let retries = 1;
|
||||
let timeout: number | undefined;
|
||||
|
||||
if (typeof portOrOpts === 'number') {
|
||||
[hostPart] = target.split(':');
|
||||
port = portOrOpts;
|
||||
} else {
|
||||
const opts = portOrOpts || {};
|
||||
protocol = opts.protocol ?? 'tcp';
|
||||
retries = opts.retries ?? 1;
|
||||
timeout = opts.timeout;
|
||||
[hostPart] = target.split(':');
|
||||
const portPart = target.split(':')[1];
|
||||
port = opts.port ?? (portPart ? parseInt(portPart, 10) : undefined);
|
||||
}
|
||||
|
||||
if (protocol === 'udp') {
|
||||
throw new NetworkError('UDP port check not supported', 'ENOTSUP');
|
||||
}
|
||||
if (!port) {
|
||||
throw new NetworkError('Port not specified', 'EINVAL');
|
||||
}
|
||||
|
||||
await this.ensureBridge();
|
||||
let last = false;
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const result = await this.rustBridge.tcpPortCheck(hostPart, port, timeout);
|
||||
last = result.isOpen;
|
||||
if (last) return true;
|
||||
} catch {
|
||||
// DNS resolution failure or connection error — treat as not available
|
||||
last = false;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* List network interfaces (gateways) — pure TS, no Rust needed.
|
||||
*/
|
||||
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
|
||||
const fetcher = async () => plugins.os.networkInterfaces() as Record<string, plugins.os.NetworkInterfaceInfo[]>;
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached('gateways', fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default gateway interface and its addresses.
|
||||
*/
|
||||
public async getDefaultGateway(): Promise<{
|
||||
ipv4: plugins.os.NetworkInterfaceInfo;
|
||||
ipv6: plugins.os.NetworkInterfaceInfo;
|
||||
}> {
|
||||
const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
|
||||
if (!defaultGatewayName) {
|
||||
console.log('Cannot determine default gateway');
|
||||
} | null> {
|
||||
await this.ensureBridge();
|
||||
const result = await this.rustBridge.defaultGateway();
|
||||
const interfaceName = result.interfaceName;
|
||||
|
||||
if (!interfaceName) {
|
||||
getLogger().warn?.('Cannot determine default gateway');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use os.networkInterfaces() to get rich interface info
|
||||
const gateways = await this.getGateways();
|
||||
const defaultGateway = gateways[defaultGatewayName];
|
||||
const defaultGateway = gateways[interfaceName];
|
||||
if (!defaultGateway) {
|
||||
getLogger().warn?.(`Interface ${interfaceName} not found in os.networkInterfaces()`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ipv4: defaultGateway[0],
|
||||
ipv6: defaultGateway[1],
|
||||
};
|
||||
}
|
||||
|
||||
public async getPublicIps() {
|
||||
return {
|
||||
v4: await plugins.publicIp.v4({
|
||||
timeout: 1000,
|
||||
onlyHttps: true,
|
||||
}),
|
||||
/**
|
||||
* Lookup public IPv4 and IPv6 — pure TS, no Rust needed.
|
||||
*/
|
||||
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
|
||||
const fetcher = async () => {
|
||||
const publicIp = new PublicIp({ timeout: 1000 });
|
||||
return publicIp.getPublicIps();
|
||||
};
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached('publicIps', fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS records (A, AAAA, MX) via the shared smartdns client.
|
||||
* The client is lifecycle-managed by start()/stop() — MX queries spawn
|
||||
* the smartdns Rust bridge, which is torn down by stop().
|
||||
*/
|
||||
public async resolveDns(
|
||||
host: string,
|
||||
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
|
||||
try {
|
||||
const dnsClient = this.ensureDnsClient();
|
||||
|
||||
const [aRecords, aaaaRecords, mxRecords] = await Promise.all([
|
||||
dnsClient.getRecordsA(host).catch((): any[] => []),
|
||||
dnsClient.getRecordsAAAA(host).catch((): any[] => []),
|
||||
dnsClient.getRecords(host, 'MX').catch((): any[] => []),
|
||||
]);
|
||||
|
||||
const A = aRecords.map((record: any) => record.value);
|
||||
const AAAA = aaaaRecords.map((record: any) => record.value);
|
||||
const MX = mxRecords.map((record: any) => {
|
||||
const parts = record.value.split(' ');
|
||||
return {
|
||||
priority: parseInt(parts[0], 10),
|
||||
exchange: parts[1] || '',
|
||||
};
|
||||
});
|
||||
|
||||
return { A, AAAA, MX };
|
||||
} catch (err: any) {
|
||||
throw new NetworkError(err.message, err.code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a simple HTTP/HTTPS endpoint health check — pure TS.
|
||||
*/
|
||||
public async checkEndpoint(
|
||||
urlString: string,
|
||||
opts?: { timeout?: number; rejectUnauthorized?: boolean },
|
||||
): Promise<{ status: number; headers: Record<string, string>; rtt: number }> {
|
||||
const start = plugins.perfHooks.performance.now();
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const lib = url.protocol === 'https:' ? plugins.https : await import('node:http');
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = lib.request(
|
||||
url,
|
||||
{ method: 'GET', timeout: opts?.timeout, agent: false, rejectUnauthorized: opts?.rejectUnauthorized ?? true },
|
||||
(res: any) => {
|
||||
res.on('data', () => {});
|
||||
res.once('end', () => {
|
||||
const rtt = plugins.perfHooks.performance.now() - start;
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(res.headers)) {
|
||||
headers[k] = Array.isArray(v) ? v.join(',') : String(v);
|
||||
}
|
||||
resolve({ status: res.statusCode, headers, rtt });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (err: any) => reject(new NetworkError(err.message, err.code)));
|
||||
req.end();
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new NetworkError(err.message, err.code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a traceroute: hop-by-hop latency using the Rust binary.
|
||||
*/
|
||||
public async traceroute(
|
||||
host: string,
|
||||
opts?: { maxHops?: number; timeout?: number },
|
||||
): Promise<Hop[]> {
|
||||
await this.ensureBridge();
|
||||
const maxHops = opts?.maxHops ?? 30;
|
||||
const timeoutMs = opts?.timeout ?? 5000;
|
||||
|
||||
const result = await this.rustBridge.traceroute(host, maxHops, timeoutMs);
|
||||
return result.hops.map((h) => ({
|
||||
ttl: h.ttl,
|
||||
ip: h.ip || '*',
|
||||
rtt: h.rtt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IP intelligence: ASN, organization, geolocation, and RDAP registration data.
|
||||
* Combines RDAP (RIRs), Team Cymru DNS, and MaxMind GeoLite2 — all run in parallel.
|
||||
* Pure TS, no Rust needed.
|
||||
*/
|
||||
public async getIpIntelligence(ip: string): Promise<IIpIntelligenceResult> {
|
||||
if (!this.ipIntelligence) {
|
||||
this.ipIntelligence = new IpIntelligence({ dnsClient: this.ensureDnsClient() });
|
||||
}
|
||||
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached(`ipIntelligence:${ip}`, fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain intelligence: registrar, registrant, nameservers, registration
|
||||
* events, status flags, DNSSEC, and abuse contact via RDAP. Pure TS, no
|
||||
* Rust needed.
|
||||
*/
|
||||
public async getDomainIntelligence(domain: string): Promise<IDomainIntelligenceResult> {
|
||||
if (!this.domainIntelligence) {
|
||||
this.domainIntelligence = new DomainIntelligence({ dnsClient: this.ensureDnsClient() });
|
||||
}
|
||||
const fetcher = () => this.domainIntelligence!.getIntelligence(domain);
|
||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||
return this.getCached(`domainIntelligence:${domain}`, fetcher);
|
||||
}
|
||||
return fetcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal caching helper
|
||||
*/
|
||||
private async getCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const now = Date.now();
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && entry.expiry > now) {
|
||||
return entry.value;
|
||||
}
|
||||
const value = await fetcher();
|
||||
const ttl = this.options.cacheTtl || 0;
|
||||
this.cache.set(key, { value, expiry: now + ttl });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-11
@@ -1,18 +1,17 @@
|
||||
// native scope
|
||||
import * as os from 'os';
|
||||
import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import * as perfHooks from 'node:perf_hooks';
|
||||
|
||||
export { os };
|
||||
export { os, https, perfHooks };
|
||||
|
||||
// @pushrocks scope
|
||||
import * as smartpromise from '@pushrocks/smartpromise';
|
||||
import * as smartstring from '@pushrocks/smartstring';
|
||||
import * as smartdns from '@push.rocks/smartdns';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
|
||||
export { smartpromise, smartstring };
|
||||
export { smartdns, smartrust };
|
||||
|
||||
// @third party scope
|
||||
import isopen from 'isopen';
|
||||
import publicIp from 'public-ip';
|
||||
import speedtestNet from 'speedtest-net';
|
||||
import * as systeminformation from 'systeminformation';
|
||||
// third party
|
||||
import * as maxmind from 'maxmind';
|
||||
|
||||
export { isopen, publicIp, speedtestNet, systeminformation };
|
||||
export { maxmind };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": ["tslint:latest", "tslint-config-prettier"],
|
||||
"rules": {
|
||||
"semicolon": [true, "always"],
|
||||
"no-console": false,
|
||||
"ordered-imports": false,
|
||||
"object-literal-sort-keys": false,
|
||||
"member-ordering": {
|
||||
"options":{
|
||||
"order": [
|
||||
"static-method"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultSeverity": "warning"
|
||||
}
|
||||
Reference in New Issue
Block a user