From 769ac1f623837bd2b786b7b638fcda70b63fcdf1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Feb 2026 10:04:30 -0600 Subject: [PATCH] Full rewrite in golang --- .dockerignore | 4 +- .github/dependabot.yml | 6 +- .github/workflows/ci.yml | 55 +- .gitignore | 14 +- CLAUDE.md | 365 ---- Cargo.lock | 3819 ----------------------------------- Cargo.toml | 43 - Dockerfile | 91 +- agent.go | 314 +++ agent_test.go | 69 + build.rs | 42 - flake.lock | 29 +- flake.nix | 35 +- fly.toml | 26 - go.mod | 11 + go.sum | 20 + main.go | 77 + main_test.go | 22 + mikrotik.go | 328 +++ mikrotik_test.go | 64 + native/snmp_helper.c | 953 --------- native/snmp_helper.h | 197 -- pb/agent.pb.go | 2287 +++++++++++++++++++++ ping.go | 57 + ping_test.go | 56 + proto/agent.proto | 2 + snmp.go | 269 +++ snmp_test.go | 134 ++ src/config.rs | 58 - src/main.rs | 608 ------ src/mikrotik/client.rs | 646 ------ src/mikrotik/mod.rs | 6 - src/mikrotik/types.rs | 107 - src/ping.rs | 113 -- src/proto.rs | 5 - src/secret.rs | 87 - src/snmp/client.rs | 1305 ------------ src/snmp/client_v2.rs | 99 - src/snmp/device_poller.rs | 334 --- src/snmp/mod.rs | 11 - src/snmp/poller_registry.rs | 154 -- src/snmp/trap.rs | 1485 -------------- src/snmp/types.rs | 124 -- src/ssh/client.rs | 150 -- src/ssh/mod.rs | 3 - src/version.rs | 45 - src/websocket_client.rs | 1580 --------------- ssh.go | 74 + tests/snmp_crash_test.rs | 1009 --------- tests/tls_provider.rs | 142 -- update.go | 63 + update_test.go | 50 + websocket.go | 215 ++ websocket_test.go | 136 ++ 54 files changed, 4293 insertions(+), 13705 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml create mode 100644 agent.go create mode 100644 agent_test.go delete mode 100644 build.rs delete mode 100644 fly.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 mikrotik.go create mode 100644 mikrotik_test.go delete mode 100644 native/snmp_helper.c delete mode 100644 native/snmp_helper.h create mode 100644 pb/agent.pb.go create mode 100644 ping.go create mode 100644 ping_test.go create mode 100644 snmp.go create mode 100644 snmp_test.go delete mode 100644 src/config.rs delete mode 100644 src/main.rs delete mode 100644 src/mikrotik/client.rs delete mode 100644 src/mikrotik/mod.rs delete mode 100644 src/mikrotik/types.rs delete mode 100644 src/ping.rs delete mode 100644 src/proto.rs delete mode 100644 src/secret.rs delete mode 100644 src/snmp/client.rs delete mode 100644 src/snmp/client_v2.rs delete mode 100644 src/snmp/device_poller.rs delete mode 100644 src/snmp/mod.rs delete mode 100644 src/snmp/poller_registry.rs delete mode 100644 src/snmp/trap.rs delete mode 100644 src/snmp/types.rs delete mode 100644 src/ssh/client.rs delete mode 100644 src/ssh/mod.rs delete mode 100644 src/version.rs delete mode 100644 src/websocket_client.rs create mode 100644 ssh.go delete mode 100644 tests/snmp_crash_test.rs delete mode 100644 tests/tls_provider.rs create mode 100644 update.go create mode 100644 update_test.go create mode 100644 websocket.go create mode 100644 websocket_test.go diff --git a/.dockerignore b/.dockerignore index 117d5f9..d77c7e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -target/ .git/ .gitignore Dockerfile @@ -7,3 +6,6 @@ README.md *.db *.db-shm *.db-wal +src/ +native/ +target/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 53f8242..c507d44 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,10 @@ version: 2 updates: - - package-ecosystem: "cargo" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 459c27f..5157ab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,6 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} DOCKERHUB_IMAGE: gmcintire/towerops-agent - CARGO_TERM_COLOR: always - RUSTFLAGS: -C link-arg=-fuse-ld=lld jobs: test: @@ -33,54 +31,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Mount Cargo target - uses: useblacksmith/stickydisk@v1 + - name: Setup Go + uses: actions/setup-go@v5 with: - key: ${{ github.repository }}-cargo-target - path: ./target + go-version: "1.25" - - name: Mount Cargo registry - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-cargo-registry - path: ~/.cargo/registry - - - name: Mount Cargo git - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-cargo-git - path: ~/.cargo/git - - - name: Mount Rust toolchain - uses: useblacksmith/stickydisk@v1 - with: - key: ${{ github.repository }}-rustup - path: ~/.rustup - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.93" - components: rustfmt, clippy - - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: protobuf-compiler lld libsnmp-dev - version: 1.0 - - - name: Format check - run: cargo fmt -- --check - - - name: Check - run: cargo check --release + - name: Vet + run: go vet ./... - name: Test - run: cargo test + run: go test -v ./... - # Temporarily disabled: - # - name: Clippy - # run: cargo clippy -- -D warnings + - name: Build + run: CGO_ENABLED=0 go build -o /dev/null . build-branch: name: Build (Branch) @@ -172,7 +135,7 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - platform: linux/arm64 arch: arm64 - runner: blacksmith-4vcpu-ubuntu-2404-arm # Use Blacksmith ARM64 runners + runner: blacksmith-4vcpu-ubuntu-2404-arm steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index e6050a9..ee74461 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ -# Rust -/target/ -Cargo.lock -**/*.rs.bk -*.pdb +# Go +towerops-agent +*.test # Database files *.db @@ -23,9 +21,15 @@ data/ .DS_Store Thumbs.db +# Rust (legacy) +target/ + # Nix .direnv/ result # Local scripts monitor-deploy.sh + +# Claude +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7a80609..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# Towerops Agent - Development Notes - -This file provides context for Claude Code when working on the Rust agent. - -## Project Overview - -Lightweight Rust agent for remote SNMP polling. Deployed on customer networks to poll local SNMP devices and report metrics to Towerops API via HTTPS. - -## What's Complete ✅ - -### Architecture & Design -- [x] Complete module structure (13 source files) -- [x] Configuration types matching Phoenix API responses -- [x] Metric types (SensorReading, InterfaceStat, NeighborDiscovery) with proper serialization -- [x] Event loop with 5 concurrent tasks (tokio::select!) -- [x] SQLite buffering with 24-hour retention -- [x] Error types and result handling throughout - -### Core Functionality -- [x] **API Client** (`api_client.rs`) - - fetch_config() - GET /api/v1/agent/config (Protocol Buffers) - - submit_metrics() - POST /api/v1/agent/metrics (Protocol Buffers) - - heartbeat() - POST /api/v1/agent/heartbeat (Protocol Buffers) - - Uses ureq with rustls-tls (30s timeout) - - Full Protocol Buffers integration for all endpoints - -- [x] **Storage** (`buffer/storage.rs`) - - store_metric() - Save metrics to SQLite - - get_pending_metrics() - Retrieve unsent metrics - - mark_metrics_sent() - Track submission - - cleanup_old_metrics() - Remove old data - - Last poll time tracking per equipment - -- [x] **Scheduler** (`poller/scheduler.rs`) - - Config refresh every 5 minutes - - Metrics flush every 30 seconds - - Heartbeat every 60 seconds - - Cleanup every hour - - Poll check every 5 seconds - - Update check and auto-update every hour - -- [x] **Executor** (`poller/executor.rs`) - - poll_sensors() - Poll configured sensors - - poll_interfaces() - Poll interface statistics - - poll_neighbors() - Poll LLDP and CDP neighbors - - Parallel polling with tokio::join! - - Applies sensor divisors - -- [x] **Neighbor Discovery** (`snmp/neighbor.rs`) - - discover_neighbors() - Main discovery function - - discover_lldp_neighbors() - LLDP-MIB (IEEE 802.1AB) - - discover_cdp_neighbors() - CISCO-CDP-MIB - - Parses remote device information and capabilities - - Associates neighbors with local interfaces - -- [x] **Main** (`main.rs`) - - CLI with clap (--api-url, --token, etc.) - - Environment variable support - - Logging with tracing - - Graceful startup/shutdown - - Docker image version checking on startup - -- [x] **Version Checking & Auto-Update** (`version.rs`) - - Checks Docker Hub for newer image versions on startup - - Performs periodic checks every hour (configurable in scheduler) - - Automatically pulls new image and restarts when update available - - Compares current version with latest available - - Logs warnings if updates are available - - Non-blocking, fails gracefully if Docker Hub unavailable - - Requires Docker socket mount (`/var/run/docker.sock`) for self-update - -### Protocol Buffers Integration -- [x] **Protobuf Definitions** (`proto/agent.proto`) - - AgentConfig, Equipment, SnmpConfig, Sensor, Interface - - MetricBatch, Metric, SensorReading, InterfaceStat, NeighborDiscovery - - HeartbeatMetadata, HeartbeatResponse -- [x] **Code Generation** (`build.rs`) - - Uses prost-build to compile protobuf definitions - - Generates Rust types at build time -- [x] **API Communication** - - Config endpoint: Accepts `application/x-protobuf`, decodes response - - Metrics endpoint: Encodes batch to protobuf, sends with proper content-type - - Heartbeat endpoint: Encodes metadata to protobuf - - Conversion functions between protobuf and internal types - -### Build & Deployment -- [x] Cargo.toml with optimized release profile - - opt-level = "z" - - lto = true - - codegen-units = 1 - - strip = true -- [x] Multi-stage Dockerfile (Alpine, ~10-20 MB) -- [x] docker-compose.example.yml -- [x] README with user documentation -- [x] .gitignore and .dockerignore -- [x] GitLab CI/CD configured for Docker Hub - -### Build Status -```bash -✅ cargo build --release - SUCCESS -✅ cargo clippy - 0 warnings, 0 errors -📦 Target size optimized for minimal footprint -🚀 Protobuf integration complete -``` - -## Testing Gaps - -- [ ] Unit tests for SNMP client -- [ ] Unit tests for storage (SQLite operations) -- [ ] Unit tests for API client (mock server) -- [ ] Integration test with real SNMP device - -## Development Workflow - -### Quick Start - -1. **Build the agent**: -```bash -cargo build --release -``` - -2. **Run locally** (needs Phoenix backend running): -```bash -cargo run -- \ - --api-url http://localhost:4000 \ - --token \ - --database-path ./test.db -``` - -3. **Watch logs**: -```bash -RUST_LOG=debug cargo run -- ... -``` - -### Testing Changes - -1. **Check compilation**: -```bash -cargo check -``` - -2. **Run tests**: -```bash -cargo test -``` - -3. **Format code**: -```bash -cargo fmt -``` - -4. **Check for issues**: -```bash -cargo clippy -``` - -### Docker Testing - -1. **Build image**: -```bash -docker build -t towerops-agent:test . -``` - -2. **Run container**: -```bash -docker run --rm \ - -e TOWEROPS_API_URL=http://host.docker.internal:4000 \ - -e TOWEROPS_AGENT_TOKEN= \ - -e RUST_LOG=info \ - -v $(pwd)/data:/data \ - towerops-agent:test -``` - -### CI/CD Pipeline - -**Automated builds** via GitLab CI (`.gitlab-ci.yml`): -- Push to branch → test + build with branch tag -- Push to main → test + build + tag as `latest` -- Create tag (e.g., `v0.1.0`) → test + build + release - -**Registry**: `registry.gitlab.com/towerops/towerops-agent` - -**See**: `DEPLOYMENT.md` for complete CI/CD documentation - -## Integration with Phoenix Backend - -### API Endpoints (from agent perspective) - -**GET /api/v1/agent/config** -- Headers: `Authorization: Bearer ` -- Response: Equipment list with sensors and interfaces -- Called every 5 minutes - -**POST /api/v1/agent/metrics** -- Headers: `Authorization: Bearer ` -- Body: `{"metrics": [...]}` -- Response: `{"status": "accepted", "received": N}` -- Called every 30 seconds with pending metrics - -**POST /api/v1/agent/heartbeat** -- Headers: `Authorization: Bearer ` -- Body: `{"version": "0.1.0", "hostname": "...", "uptime_seconds": 3600}` -- Response: `{"status": "ok"}` -- Called every 60 seconds - -### Getting a Test Token - -1. Start Phoenix: `mix phx.server` -2. Navigate to: `http://localhost:4000/orgs/:slug/agents` -3. Click "Create New Agent" -4. Copy the token (shown only once) -5. Use in agent: `--token ` - -## Architecture Decisions - -### Why Tokio? -- Async event loop for efficient I/O -- Multiple concurrent timers (config, metrics, heartbeat) -- Non-blocking SNMP operations via spawn_blocking - -### Why SQLite? -- Embedded, no external dependencies -- Persist metrics during API outages -- Small footprint (~100 MB for 24h of metrics) -- No configuration needed - -### Why Rust? -- Small binary size (~10-20 MB with Alpine) -- Low memory usage (<256 MB typical) -- Cross-compile to multiple architectures -- Strong type safety for reliability - -### Why Async SNMP with spawn_blocking? -- SNMP crate uses synchronous I/O -- spawn_blocking moves sync operations to thread pool -- Keeps main event loop non-blocking -- Allows concurrent polling without blocking other tasks - -## Common Issues - -### "Failed to fetch config" Error -**Check**: -1. Is Phoenix backend running? -2. Is the token valid (not revoked)? -3. Is the API URL correct? -4. Is there network connectivity? - -### High Memory Usage -**Check**: -1. Database size: `ls -lh /data/towerops-agent.db` -2. Are metrics being submitted? (check logs) -3. Is cleanup running? (should see log every hour) - -### Agent Not Showing as Online -**Check**: -1. Is heartbeat working? (check Phoenix logs) -2. Check `last_seen_at` in database: `SELECT last_seen_at FROM agent_tokens WHERE token_hash = ...` -3. Time sync between agent and server - -## File Organization - -``` -towerops-agent/ -├── src/ -│ ├── main.rs # Entry point, CLI, initialization -│ ├── config.rs # Types matching API responses -│ ├── api_client.rs # HTTP client for Towerops API -│ ├── version.rs # Docker image version checking -│ ├── metrics/ -│ │ └── mod.rs # Metric types (SensorReading, InterfaceStat, NeighborDiscovery) -│ ├── snmp/ -│ │ ├── mod.rs # Module exports -│ │ ├── client.rs # ✅ SNMP client (GET and WALK) -│ │ ├── neighbor.rs # ✅ LLDP and CDP neighbor discovery -│ │ └── types.rs # SNMP types and errors -│ ├── buffer/ -│ │ ├── mod.rs # Module exports -│ │ └── storage.rs # SQLite buffering -│ └── poller/ -│ ├── mod.rs # Module exports -│ ├── executor.rs # Poll execution logic -│ └── scheduler.rs # Main event loop -├── Cargo.toml # Dependencies and build config -├── Dockerfile # Multi-stage build -├── README.md # User documentation -└── CLAUDE.md # This file -``` - -## Dependencies - -**Key Crates**: -- `tokio` - Async runtime with full features -- `reqwest` - HTTP client (rustls-tls, no default features) -- `rusqlite` - SQLite (bundled) -- `serde` + `serde_json` - Serialization -- `snmp` - SNMP operations (v0.2) ⚠️ needs integration -- `tracing` + `tracing-subscriber` - Logging -- `clap` - CLI argument parsing -- `chrono` - Timestamps -- `anyhow` + `thiserror` - Error handling -- `hostname` - Get system hostname - -## Next Actions - -**Immediate** (for production readiness): -1. Add more comprehensive unit tests -2. Integration test with mock SNMP device -3. Load test with 100 devices -4. Stability test (7+ days continuous) - -**Long-term** (nice to have): -1. SNMPv3 support -2. Agent-side threshold filtering -3. Configurable sampling rates -4. Agent health metrics endpoint - -## Resources - -- **Main Implementation Doc**: `/Users/graham/dev/towerops/AGENT_IMPLEMENTATION.md` -- **Next Steps Guide**: `/Users/graham/dev/towerops/AGENT_NEXT_STEPS.md` -- **SNMP Crate Docs**: https://docs.rs/snmp/0.2.2/snmp/ -- **SNMP Crate Source**: https://github.com/hroi/snmp-rs - -## Success Criteria - -Agent is production-ready when: -- [x] Compiles successfully -- [x] Docker image builds -- [x] API client works (config, metrics, heartbeat) -- [x] SQLite buffering works -- [x] Event loop runs without panics -- [x] **SNMP polling works** -- [x] **Neighbor discovery works (LLDP/CDP)** -- [ ] **Integration testing complete** ← CURRENT FOCUS -- [ ] Metrics appear in Phoenix database -- [ ] Neighbor data appears in Phoenix database -- [ ] Survives 24h API outage -- [ ] Uses <256 MB memory with 50 devices -- [ ] Runs for 7+ days without issues - -## Notes for Future Development - -### Adding New Metric Types -1. Add variant to `Metric` enum in `src/metrics/mod.rs` -2. Update `metric_type()` and `timestamp()` methods -3. Update Phoenix API to accept new type -4. Add serialization test - -### Adding New Configuration Fields -1. Update structs in `src/config.rs` -2. Update Phoenix API `build_equipment_config/1` -3. Consider backwards compatibility - -### Debugging SNMP Issues -- Set `RUST_LOG=debug` to see all SNMP operations -- Check IP reachability: `ping ` -- Test SNMP manually: `snmpget -v2c -c public ` -- Verify community string is correct -- Check firewall rules (UDP port 161) - ---- - -**Last Updated**: January 14, 2026 -**Status**: All code complete, integration testing needed -**Version**: 0.1.0 (pre-release) diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 405d280..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3819 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array 0.14.7", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[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 = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bcrypt-pbkdf" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" -dependencies = [ - "blowfish", - "pbkdf2", - "sha2 0.10.9", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "block-buffer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.2.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "clap" -version = "4.5.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" -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 = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.0-pre.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-models" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444" -dependencies = [ - "hax-lib", - "pastey", - "rand 0.9.2", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-bigint" -version = "0.7.0-rc.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37387ceb32048ff590f2cbd24d8b05fffe63c3f69a5cfa089d4f722ca4385a19" -dependencies = [ - "ctutils", - "num-traits", - "rand_core 0.10.0-rc-3", - "serdect", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array 0.14.7", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211f05e03c7d03754740fd9e585de910a095d6b99f8bcfffdef8319fa02a8331" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "crypto-primes" -version = "0.7.0-pre.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79c98a281f9441200b24e3151407a629bfbe720399186e50516da939195e482" -dependencies = [ - "crypto-bigint 0.7.0-rc.18", - "libm", - "rand_core 0.10.0-rc-3", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "ctutils" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758e5ed90be3c8abff7f9a6f37ab7f6d8c59c2210d448b81f3f508134aec84e4" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "delegate" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c1d73e9668ea6b6a28172aa55f3ebec38507131ce179051c8033b5c6037653" -dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.0-rc.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b42f1d9edf5207c137646b568a0168ca0ec25b7f9eaf7f9961da51a3d91cea" -dependencies = [ - "block-buffer 0.11.0", - "const-oid 0.10.2", - "crypto-common 0.2.0", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der 0.7.10", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature 2.2.0", - "spki 0.7.3", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", - "digest 0.10.7", - "ff", - "generic-array 0.14.7", - "group", - "hkdf", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[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 = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "generic-array" -version = "1.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" -dependencies = [ - "generic-array 0.14.7", - "rustversion", - "typenum", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "hax-lib" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" -dependencies = [ - "hax-lib-macros", - "num-bigint", - "num-traits", -] - -[[package]] -name = "hax-lib-macros" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" -dependencies = [ - "hax-lib-macros-types", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "hax-lib-macros-types" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "uuid", -] - -[[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 = "hex-literal" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hybrid-array" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b229d73f5803b562cc26e4da0396c8610a4ee209f4fac8fa4f8d709166dc45" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.6", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array 0.14.7", -] - -[[package]] -name = "internal-russh-forked-ssh-key" -version = "0.6.16+upstream-0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe44f2bbd99fcb302e246e2d6bcf51aeda346d02a365f80296a07a8c711b6da6" -dependencies = [ - "argon2", - "bcrypt-pbkdf", - "digest 0.11.0-rc.11", - "ecdsa", - "ed25519-dalek", - "hex", - "hmac", - "num-bigint-dig", - "p256", - "p384", - "p521", - "rand_core 0.6.4", - "rsa", - "sec1", - "sha1 0.10.6", - "sha1 0.11.0-rc.5", - "sha2 0.10.9", - "signature 2.2.0", - "signature 3.0.0-rc.6", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.181" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" - -[[package]] -name = "libcrux-intrinsics" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632" -dependencies = [ - "core-models", - "hax-lib", -] - -[[package]] -name = "libcrux-ml-kem" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-secrets", - "libcrux-sha3", - "libcrux-traits", - "rand 0.9.2", - "tls_codec", -] - -[[package]] -name = "libcrux-platform" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778" -dependencies = [ - "libc", -] - -[[package]] -name = "libcrux-secrets" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307" -dependencies = [ - "hax-lib", -] - -[[package]] -name = "libcrux-sha3" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-traits", -] - -[[package]] -name = "libcrux-traits" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034" -dependencies = [ - "libcrux-secrets", - "rand 0.9.2", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[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 = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "netsnmp-sys" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "631b53a54f07e7f8e390ba494a5fa4cac4c92abed56506025714c62a237c27c3" -dependencies = [ - "libc", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[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 = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "serde", - "smallvec", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[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 = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p521" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" -dependencies = [ - "base16ct 0.2.0", - "ecdsa", - "elliptic-curve", - "primeorder", - "rand_core 0.6.4", - "sha2 0.10.9", -] - -[[package]] -name = "pageant" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b537f975f6d8dcf48db368d7ec209d583b015713b5df0f5d92d2631e4ff5595" -dependencies = [ - "byteorder", - "bytes", - "delegate", - "futures", - "log", - "rand 0.8.5", - "sha2 0.10.9", - "thiserror 1.0.69", - "tokio", - "windows", - "windows-strings", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs1" -version = "0.8.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" -dependencies = [ - "der 0.8.0-rc.10", - "spki 0.8.0-rc.4", -] - -[[package]] -name = "pkcs5" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" -dependencies = [ - "aes", - "cbc", - "der 0.7.10", - "pbkdf2", - "scrypt", - "sha2 0.10.9", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "pkcs5", - "rand_core 0.6.4", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b226d2cc389763951db8869584fd800cbbe2962bf454e2edeb5172b31ee99774" -dependencies = [ - "der 0.8.0-rc.10", - "spki 0.8.0-rc.4", -] - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - -[[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 = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" -dependencies = [ - "prost", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[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.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[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 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.0-rc-3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66ee92bc15280519ef199a274fe0cafff4245d31bc39aaa31c011ad56cb1f05" - -[[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 = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.6", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsa" -version = "0.10.0-rc.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9a2b1eacbc34fbaf77f6f1db1385518446008d49b9f9f59dc9d1340fce4ca9e" -dependencies = [ - "const-oid 0.10.2", - "crypto-bigint 0.7.0-rc.18", - "crypto-primes", - "digest 0.11.0-rc.11", - "pkcs1", - "pkcs8 0.11.0-rc.10", - "rand_core 0.10.0-rc-3", - "sha2 0.11.0-rc.5", - "signature 3.0.0-rc.6", - "spki 0.8.0-rc.4", - "zeroize", -] - -[[package]] -name = "russh" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fe22d10a0e39c1134a971d5b8db8a40357b48ef22d81fa8d6eac22202dd782" -dependencies = [ - "aes", - "bitflags", - "block-padding", - "byteorder", - "bytes", - "cbc", - "ctr", - "curve25519-dalek", - "data-encoding", - "delegate", - "der 0.7.10", - "digest 0.10.7", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", - "enum_dispatch", - "flate2", - "futures", - "generic-array 1.3.5", - "getrandom 0.2.17", - "hex-literal", - "hmac", - "home", - "inout", - "internal-russh-forked-ssh-key", - "libcrux-ml-kem", - "log", - "md5", - "num-bigint", - "p256", - "p384", - "p521", - "pageant", - "pbkdf2", - "pkcs1", - "pkcs5", - "pkcs8 0.10.2", - "rand 0.9.2", - "rand_core 0.10.0-rc-3", - "ring", - "rsa", - "russh-cryptovec", - "russh-util", - "sec1", - "sha1 0.10.6", - "sha2 0.10.9", - "signature 2.2.0", - "spki 0.7.3", - "ssh-encoding", - "subtle", - "thiserror 1.0.69", - "tokio", - "typenum", - "zeroize", -] - -[[package]] -name = "russh-cryptovec" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb0ed583ff0f6b4aa44c7867dd7108df01b30571ee9423e250b4cc939f8c6cf" -dependencies = [ - "libc", - "log", - "nix", - "ssh-encoding", - "winapi", -] - -[[package]] -name = "russh-util" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" -dependencies = [ - "chrono", - "tokio", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", - "generic-array 0.14.7", - "pkcs8 0.10.2", - "subtle", - "zeroize", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[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 = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serdect" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" -dependencies = [ - "base16ct 1.0.0", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b167252f3c126be0d8926639c4c4706950f01445900c4b3db0fd7e89fcb750a" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.0-rc.11", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.0-rc.11", -] - -[[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 = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[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 = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "signature" -version = "3.0.0-rc.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d" -dependencies = [ - "digest 0.11.0-rc.11", - "rand_core 0.10.0-rc-3", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[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 = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" -dependencies = [ - "base64ct", - "der 0.8.0-rc.10", -] - -[[package]] -name = "ssh-cipher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" -dependencies = [ - "aes", - "aes-gcm", - "cbc", - "chacha20", - "cipher", - "ctr", - "poly1305", - "ssh-encoding", - "subtle", -] - -[[package]] -name = "ssh-encoding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" -dependencies = [ - "base64ct", - "bytes", - "pem-rfc7468 0.7.0", - "sha2 0.10.9", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[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 = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" -dependencies = [ - "fastrand", - "getrandom 0.4.1", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[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 = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -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 = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "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 = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite", - "webpki-roots 0.26.11", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "towerops-agent" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "cc", - "chrono", - "clap", - "futures", - "home", - "libc", - "netsnmp-sys", - "prost", - "prost-build", - "prost-types", - "regex", - "reqwest", - "russh", - "rustls", - "serde", - "serde_json", - "sha2 0.10.9", - "thiserror 2.0.18", - "tokio", - "tokio-rustls", - "tokio-tungstenite", - "tracing", - "tracing-subscriber", - "webpki-roots 1.0.6", - "zeroize", -] - -[[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 = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "rustls", - "rustls-pki-types", - "sha1 0.10.6", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[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 = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[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-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[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" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[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 = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index b83e6dd..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,43 +0,0 @@ -[package] -name = "towerops-agent" -version = "0.1.0" -edition = "2021" - -[dependencies] -netsnmp-sys = "0.1" -libc = "0.2" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "signal", "io-util", "process"] } -thiserror = "2" -tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } -tokio-rustls = "0.26" -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -futures = "0.3" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -clap = { version = "4.5", features = ["derive", "env"] } -prost = "0.14" -prost-types = "0.14" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -russh = { version = "0.57", default-features = false, features = ["ring", "flate2", "rsa"] } -async-trait = "0.1" -home = "=0.5.12" -zeroize = { version = "1", features = ["derive"] } -anyhow = "1.0" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } -sha2 = "0.10" - -[dev-dependencies] -regex = "1" -webpki-roots = "1" - -[build-dependencies] -prost-build = "0.14" -cc = "1.0" -chrono = "0.4" - -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -strip = true diff --git a/Dockerfile b/Dockerfile index 36edbda..7f8ebeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,85 +1,14 @@ -# syntax=docker/dockerfile:1.4 -# Build stage - Debian bookworm with glibc (fixes musl fork-safety SIGSEGV) -FROM rust:1.93-bookworm AS builder - -# Build arguments provided by Docker buildx -ARG TARGETPLATFORM -ARG TARGETARCH -ARG VERSION=0.1.0-unknown - +FROM golang:1.25-alpine AS builder WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o towerops-agent . -# Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - protobuf-compiler \ - libsnmp-dev \ - cmake \ - g++ \ - pkg-config \ - libssl-dev \ - && rm -rf /var/lib/apt/lists/* - -# Determine Rust target based on platform -RUN case "$TARGETPLATFORM" in \ - "linux/amd64") RUST_TARGET="x86_64-unknown-linux-gnu" ;; \ - "linux/arm64") RUST_TARGET="aarch64-unknown-linux-gnu" ;; \ - *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ - esac && \ - echo "$RUST_TARGET" > /tmp/rust-target - -# Copy manifests and build files -COPY Cargo.toml Cargo.lock build.rs ./ -COPY proto ./proto -COPY native ./native - -# Create a dummy main.rs to build dependencies -RUN mkdir src && echo "fn main() {}" > src/main.rs - -# Build dependencies (cached layer) with BuildKit cache mounts -# Cache is separated by target architecture for multi-platform builds -RUN --mount=type=cache,id=cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry \ - --mount=type=cache,id=cargo-git-${TARGETARCH},target=/usr/local/cargo/git \ - --mount=type=cache,id=cargo-target-${TARGETARCH},target=/app/target \ - RUST_TARGET=$(cat /tmp/rust-target) && \ - BUILD_VERSION="$VERSION" cargo build --release --target "$RUST_TARGET" - -# Remove dummy src -RUN rm -rf src - -# Copy actual source code -COPY src ./src - -# Build the actual application with BuildKit cache mounts -RUN --mount=type=cache,id=cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry \ - --mount=type=cache,id=cargo-git-${TARGETARCH},target=/usr/local/cargo/git \ - --mount=type=cache,id=cargo-target-${TARGETARCH},target=/app/target \ - RUST_TARGET=$(cat /tmp/rust-target) && \ - touch src/main.rs && \ - BUILD_VERSION="$VERSION" cargo build --release --target "$RUST_TARGET" && \ - cp "target/$RUST_TARGET/release/towerops-agent" /tmp/towerops-agent - -# Runtime stage - Debian slim with glibc -FROM debian:12-slim - -# Install runtime dependencies -# iputils-ping provides ping with setuid root (doesn't require CAP_NET_RAW) -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - iputils-ping \ - libsnmp40 \ - openssl \ - && rm -rf /var/lib/apt/lists/* - -# Copy binary from builder -COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent - -# Create non-root user -RUN groupadd -g 1000 towerops && \ - useradd -u 1000 -g towerops -s /bin/false towerops - -# Allow non-root user to overwrite binary during self-update -RUN chown towerops /usr/local/bin/towerops-agent - +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates iputils +COPY --from=builder /app/towerops-agent /usr/local/bin/towerops-agent +RUN adduser -D -u 1000 towerops && chown towerops /usr/local/bin/towerops-agent USER towerops - CMD ["towerops-agent"] diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..7b2605e --- /dev/null +++ b/agent.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/towerops-app/towerops-agent/pb" + "google.golang.org/protobuf/proto" +) + +// phoenixMsg is the Phoenix channel message format (JSON wrapper around binary protobuf). +type phoenixMsg struct { + Topic string `json:"topic"` + Event string `json:"event"` + Payload json.RawMessage `json:"payload"` + Ref *string `json:"ref"` +} + +// runAgent connects to the Phoenix server and runs the event loop with reconnect. +func runAgent(ctx context.Context, wsURL, token string) { + baseURL := strings.TrimRight(wsURL, "/") + retryDelay := time.Second + maxRetry := 60 * time.Second + + for { + select { + case <-ctx.Done(): + return + default: + } + + err := runSession(ctx, baseURL, token) + if ctx.Err() != nil { + return + } + if err != nil { + slog.Error("agent disconnected", "error", err) + } + + slog.Info("reconnecting", "delay", retryDelay) + select { + case <-ctx.Done(): + return + case <-time.After(retryDelay): + } + retryDelay = min(retryDelay*2, maxRetry) + } +} + +// runSession runs a single WebSocket session. Returns when disconnected or ctx cancelled. +func runSession(ctx context.Context, baseURL, token string) error { + endpoint := baseURL + "/socket/agent/websocket" + slog.Info("connecting", "url", endpoint) + + ws, err := WSDial(endpoint) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer ws.Close() + + agentID := fmt.Sprintf("agent-%d", time.Now().Unix()) + topic := "agent:" + agentID + + slog.Info("connected", "agent_id", agentID) + + // Channel for serializing WebSocket writes + writeCh := make(chan []byte, 500) + + // Result channels + snmpResultCh := make(chan *pb.SnmpResult, 1000) + mikrotikResultCh := make(chan *pb.MikrotikResult, 1000) + credTestResultCh := make(chan *pb.CredentialTestResult, 1000) + monitoringCheckCh := make(chan *pb.MonitoringCheck, 1000) + + // Ref counter for outbound messages + var refCounter atomic.Uint64 + refCounter.Store(1) + + nextRef := func() string { + r := refCounter.Add(1) + return fmt.Sprintf("%d", r) + } + + sendMsg := func(event string, payload json.RawMessage) { + msg := phoenixMsg{ + Topic: topic, + Event: event, + Payload: payload, + } + data, err := json.Marshal(msg) + if err != nil { + slog.Error("marshal message", "error", err) + return + } + select { + case writeCh <- data: + default: + slog.Warn("write channel full, dropping message", "event", event) + } + } + + sendBinaryResult := func(event string, msg proto.Message) { + bin, err := proto.Marshal(msg) + if err != nil { + slog.Error("marshal protobuf", "error", err) + return + } + payload, _ := json.Marshal(map[string]string{"binary": base64.StdEncoding.EncodeToString(bin)}) + sendMsg(event, payload) + } + + // Join channel + joinPayload, _ := json.Marshal(map[string]string{"token": token}) + joinMsg := phoenixMsg{ + Topic: topic, + Event: "phx_join", + Payload: joinPayload, + Ref: strPtr("1"), + } + joinData, _ := json.Marshal(joinMsg) + if err := ws.WriteText(joinData); err != nil { + return fmt.Errorf("send join: %w", err) + } + slog.Debug("sent channel join request") + + // Writer goroutine - serializes all writes to the WebSocket + var writerWg sync.WaitGroup + writerWg.Add(1) + go func() { + defer writerWg.Done() + for data := range writeCh { + if err := ws.WriteText(data); err != nil { + slog.Error("websocket write", "error", err) + return + } + } + }() + + // Reader goroutine - reads messages and dispatches + msgCh := make(chan []byte, 100) + errCh := make(chan error, 1) + go func() { + for { + data, _, err := ws.ReadMessage() + if err != nil { + errCh <- err + return + } + msgCh <- data + } + }() + + heartbeatTicker := time.NewTicker(60 * time.Second) + defer heartbeatTicker.Stop() + phxHeartbeatTicker := time.NewTicker(25 * time.Second) + defer phxHeartbeatTicker.Stop() + startTime := time.Now() + + defer func() { + close(writeCh) + writerWg.Wait() + }() + + for { + select { + case <-ctx.Done(): + slog.Info("shutdown signal, closing connection") + return nil + + case err := <-errCh: + return fmt.Errorf("read: %w", err) + + case data := <-msgCh: + var msg phoenixMsg + if err := json.Unmarshal(data, &msg); err != nil { + slog.Warn("invalid message", "error", err) + continue + } + handleMessage(msg, snmpResultCh, mikrotikResultCh, credTestResultCh, monitoringCheckCh) + + case result := <-snmpResultCh: + sendBinaryResult("result", result) + slog.Info("sent snmp result", "device", result.DeviceId, "oids", len(result.OidValues)) + + case result := <-mikrotikResultCh: + sendBinaryResult("mikrotik_result", result) + slog.Info("sent mikrotik result", "device", result.DeviceId, "job", result.JobId) + + case result := <-credTestResultCh: + sendBinaryResult("credential_test_result", result) + slog.Info("sent credential test result", "test_id", result.TestId, "success", result.Success) + + case result := <-monitoringCheckCh: + sendBinaryResult("monitoring_check", result) + slog.Info("sent monitoring check", "device", result.DeviceId, "status", result.Status) + + case <-heartbeatTicker.C: + hb := &pb.AgentHeartbeat{ + Version: version, + UptimeSeconds: uint64(time.Since(startTime).Seconds()), + Arch: runtime.GOARCH, + } + sendBinaryResult("heartbeat", hb) + slog.Debug("sent heartbeat") + + case <-phxHeartbeatTicker.C: + ref := nextRef() + msg := phoenixMsg{ + Topic: "phoenix", + Event: "heartbeat", + Payload: json.RawMessage(`{}`), + Ref: &ref, + } + data, _ := json.Marshal(msg) + select { + case writeCh <- data: + default: + } + slog.Debug("sent phoenix heartbeat", "ref", ref) + } + } +} + +// handleMessage dispatches incoming Phoenix channel messages. +func handleMessage( + msg phoenixMsg, + snmpResultCh chan<- *pb.SnmpResult, + mikrotikResultCh chan<- *pb.MikrotikResult, + credTestResultCh chan<- *pb.CredentialTestResult, + monitoringCheckCh chan<- *pb.MonitoringCheck, +) { + switch msg.Event { + case "phx_reply": + slog.Debug("channel reply", "topic", msg.Topic) + + case "jobs", "discovery_job", "backup_job": + var payload struct { + Binary string `json:"binary"` + } + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + slog.Error("decode job payload", "error", err) + return + } + bin, err := base64.StdEncoding.DecodeString(payload.Binary) + if err != nil { + slog.Error("decode base64", "error", err) + return + } + var jobList pb.AgentJobList + if err := proto.Unmarshal(bin, &jobList); err != nil { + slog.Error("unmarshal job list", "error", err) + return + } + slog.Info("received jobs", "count", len(jobList.Jobs)) + for _, job := range jobList.Jobs { + dispatchJob(job, snmpResultCh, mikrotikResultCh, credTestResultCh, monitoringCheckCh) + } + + case "restart": + slog.Info("restart requested by server, exiting") + os.Exit(0) + + case "update": + var payload struct { + URL string `json:"url"` + Checksum string `json:"checksum"` + } + if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.URL == "" { + slog.Error("invalid update payload") + return + } + slog.Info("update requested", "url", payload.URL) + if err := selfUpdate(payload.URL, payload.Checksum); err != nil { + slog.Error("self-update failed", "error", err) + } + + default: + slog.Debug("ignoring event", "event", msg.Event) + } +} + +// dispatchJob routes a job to the appropriate handler goroutine. +func dispatchJob( + job *pb.AgentJob, + snmpResultCh chan<- *pb.SnmpResult, + mikrotikResultCh chan<- *pb.MikrotikResult, + credTestResultCh chan<- *pb.CredentialTestResult, + monitoringCheckCh chan<- *pb.MonitoringCheck, +) { + slog.Info("starting job", "job_id", job.JobId, "type", job.JobType) + + switch job.JobType { + case pb.JobType_MIKROTIK: + go executeMikrotikJob(job, mikrotikResultCh) + case pb.JobType_TEST_CREDENTIALS: + go executeCredentialTest(job, credTestResultCh) + case pb.JobType_PING: + go executePingJob(job, monitoringCheckCh) + default: + // DISCOVER, POLL + go executeSnmpJob(job, snmpResultCh) + } +} + +func strPtr(s string) *string { return &s } diff --git a/agent_test.go b/agent_test.go new file mode 100644 index 0000000..00e51aa --- /dev/null +++ b/agent_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestPhoenixMsgSerialization(t *testing.T) { + msg := phoenixMsg{ + Topic: "agent:123", + Event: "phx_join", + Payload: json.RawMessage(`{"token":"test"}`), + Ref: strPtr("1"), + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + + s := string(data) + checks := []string{"agent:123", "phx_join", "token", "test"} + for _, c := range checks { + if !contains(s, c) { + t.Errorf("expected %q in JSON output %q", c, s) + } + } +} + +func TestPhoenixMsgDeserialization(t *testing.T) { + raw := `{"topic":"agent:123","event":"phx_reply","payload":{"status":"ok"},"ref":"1"}` + var msg phoenixMsg + if err := json.Unmarshal([]byte(raw), &msg); err != nil { + t.Fatal(err) + } + if msg.Topic != "agent:123" { + t.Errorf("topic: got %q, want %q", msg.Topic, "agent:123") + } + if msg.Event != "phx_reply" { + t.Errorf("event: got %q, want %q", msg.Event, "phx_reply") + } + if msg.Ref == nil || *msg.Ref != "1" { + t.Errorf("ref: got %v, want %q", msg.Ref, "1") + } +} + +func TestPhoenixMsgNullRef(t *testing.T) { + raw := `{"topic":"agent:123","event":"job","payload":{},"ref":null}` + var msg phoenixMsg + if err := json.Unmarshal([]byte(raw), &msg); err != nil { + t.Fatal(err) + } + if msg.Ref != nil { + t.Errorf("expected nil ref, got %q", *msg.Ref) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/build.rs b/build.rs deleted file mode 100644 index dd8aaf8..0000000 --- a/build.rs +++ /dev/null @@ -1,42 +0,0 @@ -fn main() { - // Compile protobuf definitions - prost_build::compile_protos(&["proto/agent.proto"], &["proto/"]).unwrap(); - - // Compile C helper for SNMP - cc::Build::new() - .file("native/snmp_helper.c") - .include("native") - .define("SNMP_HELPER_TEST", None) - .compile("snmp_helper"); - - // Link against netsnmp library - println!("cargo:rustc-link-lib=netsnmp"); - - // On macOS with Homebrew, net-snmp depends on OpenSSL which is keg-only - // (not linked into /usr/local/lib). Add the OpenSSL library path so the - // linker can find libcrypto. - #[cfg(target_os = "macos")] - { - if let Ok(output) = std::process::Command::new("brew") - .args(["--prefix", "openssl@3"]) - .output() - { - if output.status.success() { - let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); - println!("cargo:rustc-link-search={}/lib", prefix); - } - } - } - - // Inject compile timestamp as version - // This allows tracking when a specific agent binary was built - let version = get_version(); - println!("cargo:rustc-env=BUILD_VERSION={}", version); -} - -fn get_version() -> String { - // Generate RFC 3339 timestamp at compile time - // Format: YYYY-MM-DDTHH:MM:SSZ - let now = chrono::Utc::now(); - now.format("%Y-%m-%dT%H:%M:%SZ").to_string() -} diff --git a/flake.lock b/flake.lock index 570c2a9..206afed 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770537093, - "narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=", + "lastModified": 1770781623, + "narHash": "sha256-RYEMTlGCVc67pxVxjOlGd8w6fpF7Bur7gKL88FB0WTs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51", + "rev": "c05d2232d2feaa4c7a07f1168606917402868195", "type": "github" }, "original": { @@ -37,28 +37,7 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1770520253, - "narHash": "sha256-6rWuHgSENXKnC6HGGAdRolQrnp/8IzscDn7FQEo1uEQ=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "ebb8a141f60bb0ec33836333e0ca7928a072217f", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" + "nixpkgs": "nixpkgs" } }, "systems": { diff --git a/flake.nix b/flake.nix index ef6064e..03819ca 100644 --- a/flake.nix +++ b/flake.nix @@ -1,53 +1,28 @@ { - description = "towerops-agent - Rust SNMP polling agent"; + description = "towerops-agent - Go SNMP polling agent"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, rust-overlay, flake-utils }: + outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let - overlays = [ (import rust-overlay) ]; - pkgs = import nixpkgs { inherit system overlays; }; - - rustToolchain = pkgs.rust-bin.stable.latest.default.override { - extensions = [ "rust-src" "rust-analyzer" ]; - }; + pkgs = import nixpkgs { inherit system; }; in { devShells.default = pkgs.mkShell { buildInputs = [ - rustToolchain + pkgs.go pkgs.protobuf - pkgs.net-snmp - pkgs.openssl - pkgs.pkg-config + pkgs.protoc-gen-go pkgs.git - ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ - pkgs.apple-sdk_15 ]; env = { PROTOC = "${pkgs.protobuf}/bin/protoc"; - # Help netsnmp-sys find the library - NET_SNMP_CONFIG = "${pkgs.net-snmp}/bin/net-snmp-config"; - # Help cargo find OpenSSL for linking - PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; - OPENSSL_DIR = "${pkgs.openssl.dev}"; - OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; - OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; }; - - shellHook = '' - # Set RUSTFLAGS to find OpenSSL libraries at link time - export RUSTFLAGS="-L ${pkgs.openssl.out}/lib" - ''; }; }); } diff --git a/fly.toml b/fly.toml deleted file mode 100644 index bae2c03..0000000 --- a/fly.toml +++ /dev/null @@ -1,26 +0,0 @@ -# fly.toml app configuration file generated for towerops-agent on 2026-01-24T12:26:30-06:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'towerops-agent' -primary_region = 'dfw' - -[build] - -[env] - RUST_LOG = 'info' - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = 'stop' - auto_start_machines = true - min_machines_running = 0 - processes = ['app'] - -[[vm]] - memory = '256mb' - cpu_kind = 'shared' - cpus = 1 - memory_mb = 256 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..25bb5c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/towerops-app/towerops-agent + +go 1.25.6 + +require ( + github.com/gosnmp/gosnmp v1.43.2 + golang.org/x/crypto v0.48.0 + google.golang.org/protobuf v1.36.11 +) + +require golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a8070f5 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw= +github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1e62ead --- /dev/null +++ b/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" +) + +var version = "dev" + +func main() { + apiURL := flag.String("api-url", os.Getenv("TOWEROPS_API_URL"), "API URL (e.g., wss://towerops.net)") + token := flag.String("token", os.Getenv("TOWEROPS_AGENT_TOKEN"), "Agent authentication token") + logLevel := flag.String("log-level", envOrDefault("LOG_LEVEL", "info"), "Log level (debug, info, warn, error)") + flag.Parse() + + // Setup structured logging + var level slog.Level + switch strings.ToLower(*logLevel) { + case "debug": + level = slog.LevelDebug + case "warn", "warning": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + + if *apiURL == "" || *token == "" { + fmt.Fprintln(os.Stderr, "error: --api-url and --token are required (or set TOWEROPS_API_URL and TOWEROPS_AGENT_TOKEN)") + flag.Usage() + os.Exit(1) + } + + slog.Info("towerops agent starting", "version", version) + + // Convert HTTP(S) to WebSocket URL + wsURL := toWebSocketURL(*apiURL) + slog.Info("websocket url", "url", wsURL) + + // Signal handling + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + + // Run agent with reconnect loop + runAgent(ctx, wsURL, *token) + + slog.Info("towerops agent stopped") +} + +// toWebSocketURL converts an HTTP(S) URL to a WebSocket URL. +func toWebSocketURL(url string) string { + switch { + case strings.HasPrefix(url, "http://"): + return "ws://" + strings.TrimPrefix(url, "http://") + case strings.HasPrefix(url, "https://"): + return "wss://" + strings.TrimPrefix(url, "https://") + case strings.HasPrefix(url, "ws://"), strings.HasPrefix(url, "wss://"): + return url + default: + return "wss://" + url + } +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..af0b492 --- /dev/null +++ b/main_test.go @@ -0,0 +1,22 @@ +package main + +import "testing" + +func TestToWebSocketURL(t *testing.T) { + tests := []struct { + input, want string + }{ + {"http://localhost:4000", "ws://localhost:4000"}, + {"https://towerops.net", "wss://towerops.net"}, + {"ws://localhost:4000", "ws://localhost:4000"}, + {"wss://towerops.net", "wss://towerops.net"}, + {"towerops.net", "wss://towerops.net"}, + {"localhost:4000", "wss://localhost:4000"}, + } + for _, tt := range tests { + got := toWebSocketURL(tt.input) + if got != tt.want { + t.Errorf("toWebSocketURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/mikrotik.go b/mikrotik.go new file mode 100644 index 0000000..dfc9e1e --- /dev/null +++ b/mikrotik.go @@ -0,0 +1,328 @@ +package main + +import ( + "crypto/tls" + "fmt" + "io" + "log/slog" + "net" + "strings" + "time" + + "github.com/towerops-app/towerops-agent/pb" +) + +const ( + mikrotikConnTimeout = 30 * time.Second + mikrotikReadTimeout = 30 * time.Second +) + +// mikrotikClient is a RouterOS binary API client. +type mikrotikClient struct { + conn io.ReadWriteCloser +} + +type mikrotikSentence struct { + attributes map[string]string +} + +type mikrotikResponse struct { + sentences []mikrotikSentence + err string +} + +// mikrotikConnect connects and authenticates to a MikroTik device. +func mikrotikConnect(ip string, port uint32, username, password string, useSSL bool) (*mikrotikClient, error) { + addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port)) + var conn net.Conn + var err error + + if useSSL { + dialer := &tls.Dialer{ + NetDialer: &net.Dialer{Timeout: mikrotikConnTimeout}, + Config: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, + } + conn, err = dialer.DialContext(nil, "tcp", addr) + } else { + conn, err = net.DialTimeout("tcp", addr, mikrotikConnTimeout) + } + if err != nil { + return nil, fmt.Errorf("connect %s: %w", addr, err) + } + + c := &mikrotikClient{conn: conn} + + // Authenticate + resp, err := c.execute("/login", map[string]string{"name": username, "password": password}) + if err != nil { + conn.Close() + return nil, fmt.Errorf("auth: %w", err) + } + if resp.err != "" { + conn.Close() + return nil, fmt.Errorf("auth failed: %s", resp.err) + } + + return c, nil +} + +// execute sends a command and reads the full response. +func (c *mikrotikClient) execute(command string, args map[string]string) (*mikrotikResponse, error) { + words := []string{command} + for k, v := range args { + if strings.HasPrefix(k, "?") || strings.HasPrefix(k, ".") { + words = append(words, k+"="+v) + } else { + words = append(words, "="+k+"="+v) + } + } + + if err := c.writeSentence(words); err != nil { + return nil, err + } + + return c.readResponse() +} + +func (c *mikrotikClient) close() error { + c.execute("/quit", nil) // best-effort + return c.conn.Close() +} + +func (c *mikrotikClient) writeSentence(words []string) error { + var buf []byte + for _, w := range words { + buf = append(buf, encodeLength(len(w))...) + buf = append(buf, w...) + } + buf = append(buf, 0) // empty word terminates sentence + + _, err := c.conn.Write(buf) + return err +} + +func (c *mikrotikClient) readResponse() (*mikrotikResponse, error) { + resp := &mikrotikResponse{} + + for { + words, err := c.readSentence() + if err != nil { + return nil, err + } + if len(words) == 0 { + continue + } + + switch words[0] { + case "!done": + attrs := parseMikrotikAttrs(words[1:]) + if len(attrs) > 0 { + resp.sentences = append(resp.sentences, mikrotikSentence{attributes: attrs}) + } + return resp, nil + case "!re": + resp.sentences = append(resp.sentences, mikrotikSentence{attributes: parseMikrotikAttrs(words[1:])}) + case "!trap": + attrs := parseMikrotikAttrs(words[1:]) + if msg, ok := attrs["message"]; ok { + resp.err = msg + } else { + resp.err = "unknown error" + } + // Continue reading until !done + case "!fatal": + attrs := parseMikrotikAttrs(words[1:]) + msg := "fatal error" + if m, ok := attrs["message"]; ok { + msg = m + } + return nil, fmt.Errorf("fatal: %s", msg) + } + } +} + +func (c *mikrotikClient) readSentence() ([]string, error) { + var words []string + for { + if tc, ok := c.conn.(net.Conn); ok { + tc.SetReadDeadline(time.Now().Add(mikrotikReadTimeout)) + } + word, err := c.readWord() + if err != nil { + return nil, err + } + if word == "" { + break + } + words = append(words, word) + } + return words, nil +} + +func (c *mikrotikClient) readWord() (string, error) { + length, err := c.readLength() + if err != nil { + return "", err + } + if length == 0 { + return "", nil + } + buf := make([]byte, length) + if _, err := io.ReadFull(c.conn, buf); err != nil { + return "", fmt.Errorf("read word: %w", err) + } + return string(buf), nil +} + +func (c *mikrotikClient) readLength() (int, error) { + var first [1]byte + if _, err := io.ReadFull(c.conn, first[:]); err != nil { + return 0, err + } + b := first[0] + + if b < 0x80 { + return int(b), nil + } else if b < 0xC0 { + var extra [1]byte + if _, err := io.ReadFull(c.conn, extra[:]); err != nil { + return 0, err + } + return int(b&0x3F)<<8 | int(extra[0]), nil + } else if b < 0xE0 { + var extra [2]byte + if _, err := io.ReadFull(c.conn, extra[:]); err != nil { + return 0, err + } + return int(b&0x1F)<<16 | int(extra[0])<<8 | int(extra[1]), nil + } else if b < 0xF0 { + var extra [3]byte + if _, err := io.ReadFull(c.conn, extra[:]); err != nil { + return 0, err + } + return int(b&0x0F)<<24 | int(extra[0])<<16 | int(extra[1])<<8 | int(extra[2]), nil + } else { + var extra [4]byte + if _, err := io.ReadFull(c.conn, extra[:]); err != nil { + return 0, err + } + return int(extra[0])<<24 | int(extra[1])<<16 | int(extra[2])<<8 | int(extra[3]), nil + } +} + +// encodeLength encodes a RouterOS API length prefix. +func encodeLength(n int) []byte { + switch { + case n < 0x80: + return []byte{byte(n)} + case n < 0x4000: + return []byte{byte(n>>8) | 0x80, byte(n & 0xFF)} + case n < 0x200000: + return []byte{byte(n>>16) | 0xC0, byte(n >> 8 & 0xFF), byte(n & 0xFF)} + case n < 0x10000000: + return []byte{byte(n>>24) | 0xE0, byte(n >> 16 & 0xFF), byte(n >> 8 & 0xFF), byte(n & 0xFF)} + default: + return []byte{0xF0, byte(n >> 24 & 0xFF), byte(n >> 16 & 0xFF), byte(n >> 8 & 0xFF), byte(n & 0xFF)} + } +} + +// parseMikrotikAttrs parses =key=value words into a map. +func parseMikrotikAttrs(words []string) map[string]string { + attrs := make(map[string]string) + for _, w := range words { + kv, found := strings.CutPrefix(w, "=") + if !found { + continue + } + k, v, _ := strings.Cut(kv, "=") + attrs[k] = v + } + return attrs +} + +// executeMikrotikJob handles a MikroTik API job including backup-via-SSH. +func executeMikrotikJob(job *pb.AgentJob, resultCh chan<- *pb.MikrotikResult) { + dev := job.MikrotikDevice + if dev == nil { + slog.Error("job missing mikrotik device", "job_id", job.JobId) + return + } + + timestamp := time.Now().Unix() + + // Backup jobs use SSH + if strings.HasPrefix(job.JobId, "backup:") { + executeMikrotikBackupViaSSH(job, dev, resultCh, timestamp) + return + } + + slog.Debug("executing mikrotik job", "job_id", job.JobId, "device", dev.Ip, "port", dev.Port, "ssl", dev.UseSsl) + + client, err := mikrotikConnect(dev.Ip, dev.Port, dev.Username, dev.Password, dev.UseSsl) + if err != nil { + resultCh <- &pb.MikrotikResult{ + DeviceId: job.DeviceId, + JobId: job.JobId, + Error: fmt.Sprintf("connection failed: %v", err), + Timestamp: timestamp, + } + return + } + defer client.close() + + var allSentences []*pb.MikrotikSentence + var errorMessage string + + for _, cmd := range job.MikrotikCommands { + slog.Debug("executing mikrotik command", "command", cmd.Command, "args", len(cmd.Args)) + + resp, err := client.execute(cmd.Command, cmd.Args) + if err != nil { + errorMessage = fmt.Sprintf("command '%s' failed: %v", cmd.Command, err) + slog.Error("mikrotik command failed", "device", job.DeviceId, "error", errorMessage) + break + } + if resp.err != "" { + errorMessage = fmt.Sprintf("command '%s' error: %s", cmd.Command, resp.err) + slog.Error("mikrotik command error", "device", job.DeviceId, "error", errorMessage) + break + } + + for _, s := range resp.sentences { + allSentences = append(allSentences, &pb.MikrotikSentence{Attributes: s.attributes}) + } + } + + resultCh <- &pb.MikrotikResult{ + DeviceId: job.DeviceId, + JobId: job.JobId, + Sentences: allSentences, + Error: errorMessage, + Timestamp: timestamp, + } +} + +// executeMikrotikBackupViaSSH runs /export compact over SSH. +func executeMikrotikBackupViaSSH(job *pb.AgentJob, dev *pb.MikrotikDevice, resultCh chan<- *pb.MikrotikResult, timestamp int64) { + slog.Debug("executing backup via ssh", "device", job.DeviceId, "ip", dev.Ip, "ssh_port", dev.SshPort) + + config, err := executeMikrotikBackup(dev.Ip, uint16(dev.SshPort), dev.Username, dev.Password) + if err != nil { + resultCh <- &pb.MikrotikResult{ + DeviceId: job.DeviceId, + JobId: job.JobId, + Error: fmt.Sprintf("SSH backup failed: %v", err), + Timestamp: timestamp, + } + return + } + + resultCh <- &pb.MikrotikResult{ + DeviceId: job.DeviceId, + JobId: job.JobId, + Sentences: []*pb.MikrotikSentence{ + {Attributes: map[string]string{"config": config}}, + }, + Timestamp: timestamp, + } +} diff --git a/mikrotik_test.go b/mikrotik_test.go new file mode 100644 index 0000000..e62af6c --- /dev/null +++ b/mikrotik_test.go @@ -0,0 +1,64 @@ +package main + +import "testing" + +func TestEncodeLength(t *testing.T) { + tests := []struct { + n int + want []byte + }{ + {0, []byte{0x00}}, + {1, []byte{0x01}}, + {127, []byte{0x7F}}, + {128, []byte{0x80, 0x80}}, + {255, []byte{0x80, 0xFF}}, + {256, []byte{0x81, 0x00}}, + {16383, []byte{0xBF, 0xFF}}, + {16384, []byte{0xC0, 0x40, 0x00}}, + {2097151, []byte{0xDF, 0xFF, 0xFF}}, + {2097152, []byte{0xE0, 0x20, 0x00, 0x00}}, + {268435456, []byte{0xF0, 0x10, 0x00, 0x00, 0x00}}, + } + for _, tt := range tests { + got := encodeLength(tt.n) + if len(got) != len(tt.want) { + t.Errorf("encodeLength(%d) = %v, want %v", tt.n, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("encodeLength(%d) = %v, want %v", tt.n, got, tt.want) + break + } + } + } +} + +func TestParseMikrotikAttrs(t *testing.T) { + tests := []struct { + name string + words []string + want map[string]string + }{ + {"empty", nil, map[string]string{}}, + {"single", []string{"=name=MyRouter"}, map[string]string{"name": "MyRouter"}}, + {"multiple", []string{"=name=MyRouter", "=model=RB450Gx4"}, map[string]string{"name": "MyRouter", "model": "RB450Gx4"}}, + {"equals in value", []string{"=comment=a=b=c"}, map[string]string{"comment": "a=b=c"}}, + {"ignores non-attr", []string{"!re", "=name=test"}, map[string]string{"name": "test"}}, + {"empty value", []string{"=disabled="}, map[string]string{"disabled": ""}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseMikrotikAttrs(tt.words) + if len(got) != len(tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + return + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("key %q: got %q, want %q", k, got[k], v) + } + } + }) + } +} diff --git a/native/snmp_helper.c b/native/snmp_helper.c deleted file mode 100644 index 68897a4..0000000 --- a/native/snmp_helper.c +++ /dev/null @@ -1,953 +0,0 @@ -#include "snmp_helper.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static pthread_once_t init_once = PTHREAD_ONCE_INIT; - -static void init_snmp_once(void) { - // Initialize the SNMP library - init_snmp("towerops-agent"); - - // Configure to output numeric OIDs only (no MIB names) - // This ensures OIDs are in format "1.3.6.1.2.1.1.1.0" not "SNMPv2-MIB::sysDescr.0" - netsnmp_ds_set_int(NETSNMP_DS_LIBRARY_ID, NETSNMP_DS_LIB_OID_OUTPUT_FORMAT, - NETSNMP_OID_OUTPUT_NUMERIC); -} - -int snmp_init_library(void) { - pthread_once(&init_once, init_snmp_once); - return 0; -} - -void* snmp_open_session( - const char* ip_address, - uint16_t port, - const char* community, - int version, - int64_t timeout_us, - int retries, - const snmp_v3_config_t* v3_config, - char* error_buf, - size_t error_buf_len -) { - struct snmp_session session, *sess_handle; - - // Ensure library is initialized - snmp_init_library(); - - // Initialize session structure - snmp_sess_init(&session); - - // Set peer address with port (e.g., "192.168.1.1:161") - // This is the modern way - remote_port field is deprecated - char peername[256]; - snprintf(peername, sizeof(peername), "%s:%u", ip_address, port); - session.peername = strdup(peername); - if (!session.peername) { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to allocate memory for peer address"); - } - return NULL; - } - - // Set SNMP version - switch (version) { - case 1: - session.version = SNMP_VERSION_1; - break; - case 2: - session.version = SNMP_VERSION_2c; - break; - case 3: - session.version = SNMP_VERSION_3; - break; - default: - free(session.peername); - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Unsupported SNMP version: %d", version); - } - return NULL; - } - - // Configure version-specific parameters - if (version == 3) { - // SNMPv3 configuration - if (!v3_config || !v3_config->username) { - free(session.peername); - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "SNMPv3 requires username"); - } - return NULL; - } - - // Set security name (username) - session.securityName = strdup(v3_config->username); - session.securityNameLen = strlen(v3_config->username); - - // Set security level - if (v3_config->security_level) { - if (strcmp(v3_config->security_level, "authPriv") == 0) { - session.securityLevel = SNMP_SEC_LEVEL_AUTHPRIV; - } else if (strcmp(v3_config->security_level, "authNoPriv") == 0) { - session.securityLevel = SNMP_SEC_LEVEL_AUTHNOPRIV; - } else { - session.securityLevel = SNMP_SEC_LEVEL_NOAUTH; - } - } else { - session.securityLevel = SNMP_SEC_LEVEL_NOAUTH; - } - - // Set authentication protocol and password - if (session.securityLevel >= SNMP_SEC_LEVEL_AUTHNOPRIV) { - if (v3_config->auth_password) { - session.securityAuthProto = usmHMACMD5AuthProtocol; - session.securityAuthProtoLen = USM_AUTH_PROTO_MD5_LEN; - - if (v3_config->auth_protocol) { - if (strcmp(v3_config->auth_protocol, "SHA") == 0) { - session.securityAuthProto = usmHMACSHA1AuthProtocol; - session.securityAuthProtoLen = USM_AUTH_PROTO_SHA_LEN; - } - } - - session.securityAuthKeyLen = USM_AUTH_KU_LEN; - if (generate_Ku(session.securityAuthProto, - session.securityAuthProtoLen, - (u_char*)v3_config->auth_password, - strlen(v3_config->auth_password), - session.securityAuthKey, - &session.securityAuthKeyLen) != SNMPERR_SUCCESS) { - free(session.peername); - free((void*)session.securityName); - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to generate auth key"); - } - return NULL; - } - } - } - - // Set privacy protocol and password - if (session.securityLevel >= SNMP_SEC_LEVEL_AUTHPRIV) { - if (v3_config->priv_password) { - session.securityPrivProto = usmDESPrivProtocol; - session.securityPrivProtoLen = USM_PRIV_PROTO_DES_LEN; - - if (v3_config->priv_protocol) { - if (strcmp(v3_config->priv_protocol, "AES") == 0) { - session.securityPrivProto = usmAESPrivProtocol; - session.securityPrivProtoLen = USM_PRIV_PROTO_AES_LEN; - } - } - - session.securityPrivKeyLen = USM_PRIV_KU_LEN; - if (generate_Ku(session.securityAuthProto, - session.securityAuthProtoLen, - (u_char*)v3_config->priv_password, - strlen(v3_config->priv_password), - session.securityPrivKey, - &session.securityPrivKeyLen) != SNMPERR_SUCCESS) { - free(session.peername); - free((void*)session.securityName); - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to generate priv key"); - } - return NULL; - } - } - } - } else { - // v1/v2c: Set community string - if (community && community[0]) { - session.community = (u_char*)strdup(community); - if (!session.community) { - free(session.peername); - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to allocate memory for community string"); - } - return NULL; - } - session.community_len = strlen(community); - } - } - - // Set timeout and retries - session.timeout = timeout_us; - session.retries = retries; - - // Open the session - sess_handle = snmp_sess_open(&session); - - // Clean up temporary allocations - free(session.peername); - if (session.community) { - // Zero out community string before freeing - memset((void*)session.community, 0, session.community_len); - free((void*)session.community); - } - if (session.securityName) { - free((void*)session.securityName); - } - - // Check for errors - if (!sess_handle) { - if (error_buf && error_buf_len > 0) { - // Get error message from library - int liberr, syserr; - char *errstr; - snmp_error(&session, &liberr, &syserr, &errstr); - snprintf(error_buf, error_buf_len, "%s", errstr); - free(errstr); - } - return NULL; - } - - return sess_handle; -} - -void snmp_close_session(void* sess_handle) { - if (sess_handle) { - snmp_sess_close(sess_handle); - } -} - -int snmp_get( - void* sess_handle, - const char* oid_str, - void* value_buf, - size_t value_buf_len, - int* value_type, - char* error_buf, - size_t error_buf_len -) { - if (!sess_handle || !oid_str || !value_buf || !value_type) { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Invalid parameters"); - } - return -1; - } - - oid anOID[MAX_OID_LEN]; - size_t anOID_len = MAX_OID_LEN; - - // Parse OID string - if (!read_objid(oid_str, anOID, &anOID_len)) { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to parse OID: %s", oid_str); - } - return -1; - } - - // Create GET PDU - struct snmp_pdu *pdu = snmp_pdu_create(SNMP_MSG_GET); - if (!pdu) { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to create PDU"); - } - return -1; - } - - // Add OID to PDU - snmp_add_null_var(pdu, anOID, anOID_len); - - // Send request - struct snmp_pdu *response = NULL; - int status = snmp_sess_synch_response(sess_handle, pdu, &response); - - if (status != STAT_SUCCESS || !response) { - if (error_buf && error_buf_len > 0) { - if (status == STAT_TIMEOUT) { - snprintf(error_buf, error_buf_len, "Request timeout"); - } else { - snprintf(error_buf, error_buf_len, "Request failed"); - } - } - if (response) { - snmp_free_pdu(response); - } - return -1; - } - - // Extract value from response - int result = -1; - if (response->variables) { - struct variable_list *var = response->variables; - *value_type = var->type; - - // Handle SNMP exception types (NoSuchObject, NoSuchInstance, EndOfMibView) - if (var->type == SNMP_NOSUCHOBJECT || - var->type == SNMP_NOSUCHINSTANCE || - var->type == SNMP_ENDOFMIBVIEW) { - if (error_buf && error_buf_len > 0) { - const char *label = var->type == SNMP_NOSUCHOBJECT ? "noSuchObject" : - var->type == SNMP_NOSUCHINSTANCE ? "noSuchInstance" : - "endOfMibView"; - snprintf(error_buf, error_buf_len, "%s", label); - } - snmp_free_pdu(response); - return -1; - } - - switch (var->type) { - case ASN_OCTET_STR: - case ASN_OPAQUE: - case ASN_IPADDRESS: - if (var->val.string && var->val_len > 0 && - var->val_len <= value_buf_len) { - memcpy(value_buf, var->val.string, var->val_len); - result = (int)var->val_len; - } else if (!var->val.string || var->val_len == 0) { - result = 0; // Empty string - } else { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Buffer too small"); - } - } - break; - - case ASN_INTEGER: - case ASN_COUNTER: - case ASN_GAUGE: - case ASN_TIMETICKS: - case ASN_UINTEGER: - if (var->val.integer && sizeof(long) <= value_buf_len) { - *((long*)value_buf) = *var->val.integer; - result = sizeof(long); - } - break; - - case ASN_COUNTER64: - if (var->val.counter64 && - sizeof(struct counter64) <= value_buf_len) { - memcpy(value_buf, var->val.counter64, sizeof(struct counter64)); - result = sizeof(struct counter64); - } - break; - - case ASN_OBJECT_ID: - if (var->val.objid && var->val_len > 0) { - char oid_buf[256]; - snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, - var->val_len / sizeof(oid)); - size_t oid_str_len = strlen(oid_buf); - if (oid_str_len <= value_buf_len) { - memcpy(value_buf, oid_buf, oid_str_len); - result = (int)oid_str_len; - } else { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, - "Buffer too small for OID string"); - } - } - } - break; - - case ASN_NULL: - // NULL values are valid but contain no data - result = 0; - break; - - default: - // Unknown type - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, - "Unsupported type: %d", var->type); - } - break; - } - } - - snmp_free_pdu(response); - return result; -} - -int snmp_walk( - void* sess_handle, - const char* oid_str, - snmp_walk_result_t* results, - size_t max_results, - size_t* num_results, - char* error_buf, - size_t error_buf_len -) { - if (!sess_handle || !oid_str || !results || !num_results) { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Invalid parameters"); - } - return -1; - } - - oid root[MAX_OID_LEN]; - size_t rootlen = MAX_OID_LEN; - - // Parse starting OID - if (!read_objid(oid_str, root, &rootlen)) { - if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Failed to parse OID: %s", oid_str); - } - return -1; - } - - oid name[MAX_OID_LEN]; - size_t name_length = rootlen; - memcpy(name, root, rootlen * sizeof(oid)); - - *num_results = 0; - int running = 1; - - while (running && *num_results < max_results) { - // Create GETNEXT PDU - struct snmp_pdu *pdu = snmp_pdu_create(SNMP_MSG_GETNEXT); - if (!pdu) { - break; - } - - snmp_add_null_var(pdu, name, name_length); - - // Send request - struct snmp_pdu *response = NULL; - int status = snmp_sess_synch_response(sess_handle, pdu, &response); - - if (status != STAT_SUCCESS || !response || !response->variables) { - if (response) { - snmp_free_pdu(response); - } - break; - } - - struct variable_list *var = response->variables; - - // Check if we've walked past the root OID - if (var->name_length < rootlen || - snmp_oid_ncompare(var->name, var->name_length, root, rootlen, rootlen) != 0) { - snmp_free_pdu(response); - break; - } - - // Handle SNMP exception types that indicate end-of-data or missing values. - // These have type 0x80 (NoSuchObject), 0x81 (NoSuchInstance), - // 0x82 (EndOfMibView) and their val pointers may be NULL. - if (var->type == SNMP_NOSUCHOBJECT || - var->type == SNMP_NOSUCHINSTANCE) { - // Object/instance doesn't exist at this index - skip and continue walk - // Update OID for next iteration - if (var->name_length <= MAX_OID_LEN) { - memcpy(name, var->name, var->name_length * sizeof(oid)); - name_length = var->name_length; - } else { - running = 0; - } - snmp_free_pdu(response); - continue; - } - - if (var->type == SNMP_ENDOFMIBVIEW) { - // No more data available - terminate the walk - snmp_free_pdu(response); - break; - } - - // Store result - snmp_walk_result_t *res = &results[*num_results]; - - // Convert OID to string - snprint_objid(res->oid, sizeof(res->oid), var->name, var->name_length); - - // Store value - check for NULL val pointers before every dereference. - // After fork() from a multi-threaded process, net-snmp's internal state - // can be inconsistent, potentially leaving val pointers NULL even for - // standard types. - res->value_type = var->type; - res->value_len = 0; - - switch (var->type) { - case ASN_OCTET_STR: - case ASN_OPAQUE: - case ASN_IPADDRESS: - if (var->val.string && var->val_len > 0 && - var->val_len <= sizeof(res->value)) { - memcpy(res->value, var->val.string, var->val_len); - res->value_len = var->val_len; - } - break; - - case ASN_OBJECT_ID: - if (var->val.objid && var->val_len > 0) { - char oid_buf[256]; - snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, - var->val_len / sizeof(oid)); - size_t oid_str_len = strlen(oid_buf); - if (oid_str_len < sizeof(res->value)) { - memcpy(res->value, oid_buf, oid_str_len); - res->value_len = oid_str_len; - } - } - break; - - case ASN_INTEGER: - case ASN_COUNTER: - case ASN_GAUGE: - case ASN_TIMETICKS: - case ASN_UINTEGER: - if (var->val.integer && sizeof(long) <= sizeof(res->value)) { - *((long*)res->value) = *var->val.integer; - res->value_len = sizeof(long); - } - break; - - case ASN_COUNTER64: - if (var->val.counter64 && - sizeof(struct counter64) <= sizeof(res->value)) { - memcpy(res->value, var->val.counter64, sizeof(struct counter64)); - res->value_len = sizeof(struct counter64); - } - break; - - case ASN_NULL: - // NULL values are valid but contain no data - skip - break; - - default: - // Unknown or unsupported type - skip silently - break; - } - - if (res->value_len > 0) { - (*num_results)++; - } - - // Update OID for next iteration - if (var->name_length <= MAX_OID_LEN) { - memcpy(name, var->name, var->name_length * sizeof(oid)); - name_length = var->name_length; - } else { - running = 0; - } - - snmp_free_pdu(response); - } - - return 0; -} - -/* --- Process-isolated (fork-based) operations --- */ - -/** - * Write exactly `len` bytes to fd, retrying on EINTR. - * Returns 0 on success, -1 on error. - */ -static int write_full(int fd, const void* buf, size_t len) { - const uint8_t* p = (const uint8_t*)buf; - size_t remaining = len; - while (remaining > 0) { - ssize_t n = write(fd, p, remaining); - if (n < 0) { - if (errno == EINTR) continue; - return -1; - } - p += n; - remaining -= (size_t)n; - } - return 0; -} - -/** - * Read exactly `len` bytes from fd, retrying on EINTR. - * Returns 0 on success, -1 on error/EOF. - */ -static int read_full(int fd, void* buf, size_t len) { - uint8_t* p = (uint8_t*)buf; - size_t remaining = len; - while (remaining > 0) { - ssize_t n = read(fd, p, remaining); - if (n < 0) { - if (errno == EINTR) continue; - return -1; - } - if (n == 0) return -1; /* unexpected EOF */ - p += n; - remaining -= (size_t)n; - } - return 0; -} - -/** - * Reset signal handlers to defaults in the child process. - * This ensures any crash handler installed by the parent doesn't interfere. - */ -static void child_reset_signals(void) { - signal(SIGSEGV, SIG_DFL); - signal(SIGBUS, SIG_DFL); - signal(SIGABRT, SIG_DFL); - signal(SIGPIPE, SIG_DFL); -} - -/* - * Serialize fork operations to avoid issues with concurrent forks - * in multi-threaded processes. On macOS, concurrent fork() from - * multiple threads can trigger Objective-C runtime crashes (SIGKILL). - * On Linux this is still beneficial as it prevents resource exhaustion. - */ -static pthread_mutex_t fork_mutex = PTHREAD_MUTEX_INITIALIZER; - -#ifdef __APPLE__ -/* - * On macOS, the Objective-C runtime kills forked children from - * multi-threaded parents by default. Our children never use Objective-C - * and _exit() after SNMP work, so this is safe to disable. - * Set before main() to ensure it's in place before any threads start. - */ -__attribute__((constructor)) -static void disable_objc_fork_safety(void) { - setenv("OBJC_DISABLE_INITIALIZE_FORK_SAFETY", "YES", 0); -} -#endif - -void snmp_get_isolated( - const char* ip_address, - uint16_t port, - const char* community, - int version, - int64_t timeout_us, - int retries, - const snmp_v3_config_t* v3_config, - const char* oid_str, - snmp_isolated_get_result_t* result -) { - /* Initialize result to error state */ - memset(result, 0, sizeof(*result)); - result->status = -1; - - pthread_mutex_lock(&fork_mutex); - - int pipefd[2]; - if (pipe(pipefd) != 0) { - snprintf(result->error_buf, sizeof(result->error_buf), - "pipe() failed: %s", strerror(errno)); - pthread_mutex_unlock(&fork_mutex); - return; - } - - pid_t pid = fork(); - if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - snprintf(result->error_buf, sizeof(result->error_buf), - "fork() failed: %s", strerror(errno)); - pthread_mutex_unlock(&fork_mutex); - return; - } - - if (pid == 0) { - /* === CHILD PROCESS === */ - close(pipefd[0]); /* close read end */ - child_reset_signals(); - alarm(60); /* watchdog: kill child if stuck */ - - /* Disable MIB loading to prevent crashes from missing/corrupt MIB files. - * Set env vars BEFORE init_snmp() runs (via snmp_open_session below). - * Do NOT call init_snmp() directly here - snmp_open_session() calls - * snmp_init_library() which uses pthread_once to initialize exactly once. */ - setenv("MIBS", "", 1); - setenv("MIBDIRS", "", 1); - - snmp_isolated_get_result_t child_result; - memset(&child_result, 0, sizeof(child_result)); - child_result.status = -1; - - /* Open session */ - char error_buf[512] = {0}; - void* sess = snmp_open_session(ip_address, port, community, version, - timeout_us, retries, v3_config, - error_buf, sizeof(error_buf)); - if (!sess) { - snprintf(child_result.error_buf, sizeof(child_result.error_buf), - "%s", error_buf); - write_full(pipefd[1], &child_result, sizeof(child_result)); - close(pipefd[1]); - _exit(1); - } - - /* Perform GET */ - int value_type = 0; - int ret = snmp_get(sess, oid_str, - child_result.value_buf, sizeof(child_result.value_buf), - &value_type, child_result.error_buf, - sizeof(child_result.error_buf)); - snmp_close_session(sess); - - child_result.status = ret; - child_result.value_type = value_type; - write_full(pipefd[1], &child_result, sizeof(child_result)); - close(pipefd[1]); - _exit(0); - } - - /* === PARENT PROCESS === */ - close(pipefd[1]); /* close write end */ - - /* Unlock after fork so other threads can proceed */ - pthread_mutex_unlock(&fork_mutex); - - /* Try to read the result from the child */ - snmp_isolated_get_result_t pipe_result; - memset(&pipe_result, 0, sizeof(pipe_result)); - int read_ok = read_full(pipefd[0], &pipe_result, sizeof(pipe_result)); - close(pipefd[0]); - - /* Wait for child to exit */ - int wstatus = 0; - pid_t waited; - do { - waited = waitpid(pid, &wstatus, 0); - } while (waited < 0 && errno == EINTR); - - if (waited < 0) { - snprintf(result->error_buf, sizeof(result->error_buf), - "waitpid() failed: %s", strerror(errno)); - result->status = -1; - return; - } - - if (WIFSIGNALED(wstatus)) { - /* Child was killed by a signal (crash) */ - result->status = -2; - result->child_signal = WTERMSIG(wstatus); - snprintf(result->error_buf, sizeof(result->error_buf), - "SNMP child process killed by signal %d", result->child_signal); - return; - } - - if (read_ok != 0) { - /* Could not read from pipe but child exited normally - unexpected */ - result->status = -1; - snprintf(result->error_buf, sizeof(result->error_buf), - "Failed to read result from child (exit code %d)", - WEXITSTATUS(wstatus)); - return; - } - - /* Successfully read result from child */ - memcpy(result, &pipe_result, sizeof(*result)); -} - -void snmp_walk_isolated( - const char* ip_address, - uint16_t port, - const char* community, - int version, - int64_t timeout_us, - int retries, - const snmp_v3_config_t* v3_config, - const char* oid_str, - snmp_isolated_walk_header_t* header, - snmp_walk_result_t* results, - size_t max_results -) { - /* Initialize header to error state */ - memset(header, 0, sizeof(*header)); - header->status = -1; - - pthread_mutex_lock(&fork_mutex); - - int pipefd[2]; - if (pipe(pipefd) != 0) { - snprintf(header->error_buf, sizeof(header->error_buf), - "pipe() failed: %s", strerror(errno)); - pthread_mutex_unlock(&fork_mutex); - return; - } - - pid_t pid = fork(); - if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - snprintf(header->error_buf, sizeof(header->error_buf), - "fork() failed: %s", strerror(errno)); - pthread_mutex_unlock(&fork_mutex); - return; - } - - if (pid == 0) { - /* === CHILD PROCESS === */ - close(pipefd[0]); /* close read end */ - child_reset_signals(); - alarm(60); /* watchdog */ - - /* Disable MIB loading to prevent crashes from missing/corrupt MIB files. - * Set env vars BEFORE init_snmp() runs (via snmp_open_session below). - * Do NOT call init_snmp() directly here - snmp_open_session() calls - * snmp_init_library() which uses pthread_once to initialize exactly once. */ - setenv("MIBS", "", 1); - setenv("MIBDIRS", "", 1); - - snmp_isolated_walk_header_t child_header; - memset(&child_header, 0, sizeof(child_header)); - child_header.status = -1; - - /* Open session */ - char error_buf[512] = {0}; - void* sess = snmp_open_session(ip_address, port, community, version, - timeout_us, retries, v3_config, - error_buf, sizeof(error_buf)); - if (!sess) { - snprintf(child_header.error_buf, sizeof(child_header.error_buf), - "%s", error_buf); - write_full(pipefd[1], &child_header, sizeof(child_header)); - close(pipefd[1]); - _exit(1); - } - - /* Allocate results buffer in child */ - snmp_walk_result_t* child_results = (snmp_walk_result_t*)calloc( - max_results, sizeof(snmp_walk_result_t)); - if (!child_results) { - snprintf(child_header.error_buf, sizeof(child_header.error_buf), - "Failed to allocate walk results buffer"); - snmp_close_session(sess); - write_full(pipefd[1], &child_header, sizeof(child_header)); - close(pipefd[1]); - _exit(1); - } - - /* Perform WALK */ - size_t num_results = 0; - int ret = snmp_walk(sess, oid_str, child_results, max_results, - &num_results, child_header.error_buf, - sizeof(child_header.error_buf)); - snmp_close_session(sess); - - child_header.status = ret; - child_header.num_results = (uint32_t)num_results; - - /* Write header first */ - write_full(pipefd[1], &child_header, sizeof(child_header)); - - /* Write each result individually (each < PIPE_BUF) */ - for (size_t i = 0; i < num_results; i++) { - write_full(pipefd[1], &child_results[i], sizeof(snmp_walk_result_t)); - } - - free(child_results); - close(pipefd[1]); - _exit(0); - } - - /* === PARENT PROCESS === */ - close(pipefd[1]); /* close write end */ - - /* Unlock after fork so other threads can proceed */ - pthread_mutex_unlock(&fork_mutex); - - /* Read header from child */ - snmp_isolated_walk_header_t pipe_header; - memset(&pipe_header, 0, sizeof(pipe_header)); - int read_ok = read_full(pipefd[0], &pipe_header, sizeof(pipe_header)); - - uint32_t results_read = 0; - if (read_ok == 0 && pipe_header.status >= 0 && pipe_header.num_results > 0) { - /* Read individual results, capping at max_results */ - uint32_t to_read = pipe_header.num_results; - if (to_read > (uint32_t)max_results) { - to_read = (uint32_t)max_results; - } - for (uint32_t i = 0; i < to_read; i++) { - if (read_full(pipefd[0], &results[i], sizeof(snmp_walk_result_t)) != 0) { - break; - } - results_read++; - } - } - close(pipefd[0]); - - /* Wait for child to exit */ - int wstatus = 0; - pid_t waited; - do { - waited = waitpid(pid, &wstatus, 0); - } while (waited < 0 && errno == EINTR); - - if (waited < 0) { - snprintf(header->error_buf, sizeof(header->error_buf), - "waitpid() failed: %s", strerror(errno)); - header->status = -1; - return; - } - - if (WIFSIGNALED(wstatus)) { - /* Child was killed by a signal (crash) */ - header->status = -2; - header->child_signal = WTERMSIG(wstatus); - snprintf(header->error_buf, sizeof(header->error_buf), - "SNMP child process killed by signal %d", header->child_signal); - return; - } - - if (read_ok != 0) { - header->status = -1; - snprintf(header->error_buf, sizeof(header->error_buf), - "Failed to read header from child (exit code %d)", - WEXITSTATUS(wstatus)); - return; - } - - /* Successfully read from child */ - memcpy(header, &pipe_header, sizeof(*header)); - header->num_results = results_read; -} - -#ifdef SNMP_HELPER_TEST -int snmp_test_crash_in_child(int* child_signal) { - if (!child_signal) return -1; - *child_signal = 0; - - int pipefd[2]; - if (pipe(pipefd) != 0) return -1; - - pid_t pid = fork(); - if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - return -1; - } - - if (pid == 0) { - /* Child: close pipe ends and deliberately crash */ - close(pipefd[0]); - close(pipefd[1]); - child_reset_signals(); - - /* Trigger SIGSEGV by writing to a null pointer */ - volatile int* null_ptr = NULL; - *null_ptr = 42; - _exit(99); /* should not reach here */ - } - - /* Parent */ - close(pipefd[0]); - close(pipefd[1]); - - int wstatus = 0; - pid_t waited; - do { - waited = waitpid(pid, &wstatus, 0); - } while (waited < 0 && errno == EINTR); - - if (waited < 0) return -1; - - if (WIFSIGNALED(wstatus)) { - *child_signal = WTERMSIG(wstatus); - return 0; - } - - return -1; /* child didn't crash as expected */ -} -#endif diff --git a/native/snmp_helper.h b/native/snmp_helper.h deleted file mode 100644 index 11adc9f..0000000 --- a/native/snmp_helper.h +++ /dev/null @@ -1,197 +0,0 @@ -#ifndef SNMP_HELPER_H -#define SNMP_HELPER_H - -#include -#include - -/** - * Initialize the SNMP library (call once at startup) - * Returns 0 on success, -1 on failure - */ -int snmp_init_library(void); - -/** - * SNMPv3 configuration - */ -typedef struct { - const char* username; - const char* auth_password; - const char* priv_password; - const char* auth_protocol; // "MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512" - const char* priv_protocol; // "DES", "AES", "AES-192", "AES-256" - const char* security_level; // "noAuthNoPriv", "authNoPriv", "authPriv" -} snmp_v3_config_t; - -/** - * Open an SNMP session - * - * @param ip_address IP address of the device - * @param port UDP port (usually 161) - * @param community Community string for SNMPv1/v2c (ignored for v3) - * @param version SNMP version: 1 (SNMPv1), 2 (SNMPv2c), 3 (SNMPv3) - * @param timeout_us Timeout in microseconds - * @param retries Number of retries - * @param v3_config SNMPv3 configuration (NULL for v1/v2c) - * @param error_buf Buffer for error messages (can be NULL) - * @param error_buf_len Length of error buffer - * @return Session handle on success, NULL on failure - */ -void* snmp_open_session( - const char* ip_address, - uint16_t port, - const char* community, - int version, - int64_t timeout_us, - int retries, - const snmp_v3_config_t* v3_config, - char* error_buf, - size_t error_buf_len -); - -/** - * Close an SNMP session - * @param sess_handle Session handle from snmp_open_session - */ -void snmp_close_session(void* sess_handle); - -/** - * Perform SNMP GET operation - * - * @param sess_handle Session handle from snmp_open_session - * @param oid_str OID string (e.g., "1.3.6.1.2.1.1.1.0") - * @param value_buf Buffer for result value - * @param value_buf_len Length of value buffer - * @param value_type Output: type of value (see ASN_* constants) - * @param error_buf Buffer for error messages (can be NULL) - * @param error_buf_len Length of error buffer - * @return 0 on success, -1 on error - */ -int snmp_get( - void* sess_handle, - const char* oid_str, - void* value_buf, - size_t value_buf_len, - int* value_type, - char* error_buf, - size_t error_buf_len -); - -/** - * Result from SNMP WALK operation - */ -typedef struct { - char oid[256]; - uint8_t value[1024]; - size_t value_len; - int value_type; -} snmp_walk_result_t; - -/** - * Perform SNMP WALK operation - * - * @param sess_handle Session handle from snmp_open_session - * @param oid_str Starting OID string - * @param results Buffer for results - * @param max_results Maximum number of results to return - * @param num_results Output: actual number of results - * @param error_buf Buffer for error messages (can be NULL) - * @param error_buf_len Length of error buffer - * @return 0 on success, -1 on error - */ -int snmp_walk( - void* sess_handle, - const char* oid_str, - snmp_walk_result_t* results, - size_t max_results, - size_t* num_results, - char* error_buf, - size_t error_buf_len -); - -/** - * Result from isolated (fork-based) SNMP GET operation. - * status >= 0: value_len (success), -1: error, -2: child crash - */ -typedef struct { - int status; - int value_type; - int child_signal; - char error_buf[512]; - uint8_t value_buf[1024]; -} snmp_isolated_get_result_t; - -/** - * Header for isolated SNMP WALK result stream. - * status 0: success, -1: error, -2: child crash - */ -typedef struct { - int status; - uint32_t num_results; - int child_signal; - char error_buf[512]; -} snmp_isolated_walk_header_t; - -/** - * Perform an SNMP GET in a forked child process for crash isolation. - * - * @param ip_address IP address of the device - * @param port UDP port (usually 161) - * @param community Community string for SNMPv1/v2c - * @param version SNMP version: 1 (SNMPv1), 2 (SNMPv2c), 3 (SNMPv3) - * @param timeout_us Timeout in microseconds - * @param retries Number of retries - * @param v3_config SNMPv3 configuration (NULL for v1/v2c) - * @param oid_str OID string to GET - * @param result Output result structure - */ -void snmp_get_isolated( - const char* ip_address, - uint16_t port, - const char* community, - int version, - int64_t timeout_us, - int retries, - const snmp_v3_config_t* v3_config, - const char* oid_str, - snmp_isolated_get_result_t* result -); - -/** - * Perform an SNMP WALK in a forked child process for crash isolation. - * - * @param ip_address IP address of the device - * @param port UDP port (usually 161) - * @param community Community string for SNMPv1/v2c - * @param version SNMP version: 1 (SNMPv1), 2 (SNMPv2c), 3 (SNMPv3) - * @param timeout_us Timeout in microseconds - * @param retries Number of retries - * @param v3_config SNMPv3 configuration (NULL for v1/v2c) - * @param oid_str Starting OID string to WALK - * @param header Output header (status, num_results, error) - * @param results Output buffer for walk results - * @param max_results Maximum number of results - */ -void snmp_walk_isolated( - const char* ip_address, - uint16_t port, - const char* community, - int version, - int64_t timeout_us, - int retries, - const snmp_v3_config_t* v3_config, - const char* oid_str, - snmp_isolated_walk_header_t* header, - snmp_walk_result_t* results, - size_t max_results -); - -#ifdef SNMP_HELPER_TEST -/** - * Test helper: deliberately crashes (SIGSEGV) in a forked child. - * Returns 0 on success (child crashed as expected), -1 on error. - * child_signal receives the signal that killed the child. - */ -int snmp_test_crash_in_child(int* child_signal); -#endif - -#endif // SNMP_HELPER_H diff --git a/pb/agent.pb.go b/pb/agent.pb.go new file mode 100644 index 0000000..21fd5b7 --- /dev/null +++ b/pb/agent.pb.go @@ -0,0 +1,2287 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.32.1 +// source: proto/agent.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type JobType int32 + +const ( + JobType_DISCOVER JobType = 0 + JobType_POLL JobType = 1 + JobType_MIKROTIK JobType = 2 + JobType_TEST_CREDENTIALS JobType = 3 + JobType_PING JobType = 4 +) + +// Enum value maps for JobType. +var ( + JobType_name = map[int32]string{ + 0: "DISCOVER", + 1: "POLL", + 2: "MIKROTIK", + 3: "TEST_CREDENTIALS", + 4: "PING", + } + JobType_value = map[string]int32{ + "DISCOVER": 0, + "POLL": 1, + "MIKROTIK": 2, + "TEST_CREDENTIALS": 3, + "PING": 4, + } +) + +func (x JobType) Enum() *JobType { + p := new(JobType) + *p = x + return p +} + +func (x JobType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (JobType) Descriptor() protoreflect.EnumDescriptor { + return file_proto_agent_proto_enumTypes[0].Descriptor() +} + +func (JobType) Type() protoreflect.EnumType { + return &file_proto_agent_proto_enumTypes[0] +} + +func (x JobType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use JobType.Descriptor instead. +func (JobType) EnumDescriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{0} +} + +type QueryType int32 + +const ( + QueryType_GET QueryType = 0 + QueryType_WALK QueryType = 1 +) + +// Enum value maps for QueryType. +var ( + QueryType_name = map[int32]string{ + 0: "GET", + 1: "WALK", + } + QueryType_value = map[string]int32{ + "GET": 0, + "WALK": 1, + } +) + +func (x QueryType) Enum() *QueryType { + p := new(QueryType) + *p = x + return p +} + +func (x QueryType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (QueryType) Descriptor() protoreflect.EnumDescriptor { + return file_proto_agent_proto_enumTypes[1].Descriptor() +} + +func (QueryType) Type() protoreflect.EnumType { + return &file_proto_agent_proto_enumTypes[1] +} + +func (x QueryType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use QueryType.Descriptor instead. +func (QueryType) EnumDescriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{1} +} + +// Configuration received from the API +type AgentConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + PollIntervalSeconds uint32 `protobuf:"varint,2,opt,name=poll_interval_seconds,json=pollIntervalSeconds,proto3" json:"poll_interval_seconds,omitempty"` + Devices []*Device `protobuf:"bytes,3,rep,name=devices,proto3" json:"devices,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentConfig) Reset() { + *x = AgentConfig{} + mi := &file_proto_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentConfig) ProtoMessage() {} + +func (x *AgentConfig) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentConfig.ProtoReflect.Descriptor instead. +func (*AgentConfig) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentConfig) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AgentConfig) GetPollIntervalSeconds() uint32 { + if x != nil { + return x.PollIntervalSeconds + } + return 0 +} + +func (x *AgentConfig) GetDevices() []*Device { + if x != nil { + return x.Devices + } + return nil +} + +type Device struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + IpAddress string `protobuf:"bytes,3,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + Snmp *SnmpConfig `protobuf:"bytes,4,opt,name=snmp,proto3" json:"snmp,omitempty"` + PollIntervalSeconds uint32 `protobuf:"varint,5,opt,name=poll_interval_seconds,json=pollIntervalSeconds,proto3" json:"poll_interval_seconds,omitempty"` + Sensors []*Sensor `protobuf:"bytes,6,rep,name=sensors,proto3" json:"sensors,omitempty"` + Interfaces []*Interface `protobuf:"bytes,7,rep,name=interfaces,proto3" json:"interfaces,omitempty"` + MonitoringEnabled bool `protobuf:"varint,8,opt,name=monitoring_enabled,json=monitoringEnabled,proto3" json:"monitoring_enabled,omitempty"` + CheckIntervalSeconds uint32 `protobuf:"varint,9,opt,name=check_interval_seconds,json=checkIntervalSeconds,proto3" json:"check_interval_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Device) Reset() { + *x = Device{} + mi := &file_proto_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Device) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Device) ProtoMessage() {} + +func (x *Device) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Device.ProtoReflect.Descriptor instead. +func (*Device) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Device) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Device) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Device) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +func (x *Device) GetSnmp() *SnmpConfig { + if x != nil { + return x.Snmp + } + return nil +} + +func (x *Device) GetPollIntervalSeconds() uint32 { + if x != nil { + return x.PollIntervalSeconds + } + return 0 +} + +func (x *Device) GetSensors() []*Sensor { + if x != nil { + return x.Sensors + } + return nil +} + +func (x *Device) GetInterfaces() []*Interface { + if x != nil { + return x.Interfaces + } + return nil +} + +func (x *Device) GetMonitoringEnabled() bool { + if x != nil { + return x.MonitoringEnabled + } + return false +} + +func (x *Device) GetCheckIntervalSeconds() uint32 { + if x != nil { + return x.CheckIntervalSeconds + } + return 0 +} + +type SnmpConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Community string `protobuf:"bytes,3,opt,name=community,proto3" json:"community,omitempty"` + Port uint32 `protobuf:"varint,4,opt,name=port,proto3" json:"port,omitempty"` + Transport string `protobuf:"bytes,5,opt,name=transport,proto3" json:"transport,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SnmpConfig) Reset() { + *x = SnmpConfig{} + mi := &file_proto_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SnmpConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SnmpConfig) ProtoMessage() {} + +func (x *SnmpConfig) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SnmpConfig.ProtoReflect.Descriptor instead. +func (*SnmpConfig) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *SnmpConfig) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *SnmpConfig) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *SnmpConfig) GetCommunity() string { + if x != nil { + return x.Community + } + return "" +} + +func (x *SnmpConfig) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *SnmpConfig) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + +type Sensor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Oid string `protobuf:"bytes,3,opt,name=oid,proto3" json:"oid,omitempty"` + Divisor float64 `protobuf:"fixed64,4,opt,name=divisor,proto3" json:"divisor,omitempty"` + Unit string `protobuf:"bytes,5,opt,name=unit,proto3" json:"unit,omitempty"` + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Sensor) Reset() { + *x = Sensor{} + mi := &file_proto_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Sensor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sensor) ProtoMessage() {} + +func (x *Sensor) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Sensor.ProtoReflect.Descriptor instead. +func (*Sensor) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *Sensor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Sensor) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Sensor) GetOid() string { + if x != nil { + return x.Oid + } + return "" +} + +func (x *Sensor) GetDivisor() float64 { + if x != nil { + return x.Divisor + } + return 0 +} + +func (x *Sensor) GetUnit() string { + if x != nil { + return x.Unit + } + return "" +} + +func (x *Sensor) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +type Interface struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + IfIndex uint32 `protobuf:"varint,2,opt,name=if_index,json=ifIndex,proto3" json:"if_index,omitempty"` + IfName string `protobuf:"bytes,3,opt,name=if_name,json=ifName,proto3" json:"if_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Interface) Reset() { + *x = Interface{} + mi := &file_proto_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Interface) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Interface) ProtoMessage() {} + +func (x *Interface) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Interface.ProtoReflect.Descriptor instead. +func (*Interface) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *Interface) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Interface) GetIfIndex() uint32 { + if x != nil { + return x.IfIndex + } + return 0 +} + +func (x *Interface) GetIfName() string { + if x != nil { + return x.IfName + } + return "" +} + +// Metrics submitted to the API +type MetricBatch struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metrics []*Metric `protobuf:"bytes,1,rep,name=metrics,proto3" json:"metrics,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricBatch) Reset() { + *x = MetricBatch{} + mi := &file_proto_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricBatch) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricBatch) ProtoMessage() {} + +func (x *MetricBatch) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricBatch.ProtoReflect.Descriptor instead. +func (*MetricBatch) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *MetricBatch) GetMetrics() []*Metric { + if x != nil { + return x.Metrics + } + return nil +} + +type Metric struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to MetricType: + // + // *Metric_SensorReading + // *Metric_InterfaceStat + // *Metric_NeighborDiscovery + // *Metric_MonitoringCheck + MetricType isMetric_MetricType `protobuf_oneof:"metric_type"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Metric) Reset() { + *x = Metric{} + mi := &file_proto_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Metric) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Metric) ProtoMessage() {} + +func (x *Metric) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Metric.ProtoReflect.Descriptor instead. +func (*Metric) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *Metric) GetMetricType() isMetric_MetricType { + if x != nil { + return x.MetricType + } + return nil +} + +func (x *Metric) GetSensorReading() *SensorReading { + if x != nil { + if x, ok := x.MetricType.(*Metric_SensorReading); ok { + return x.SensorReading + } + } + return nil +} + +func (x *Metric) GetInterfaceStat() *InterfaceStat { + if x != nil { + if x, ok := x.MetricType.(*Metric_InterfaceStat); ok { + return x.InterfaceStat + } + } + return nil +} + +func (x *Metric) GetNeighborDiscovery() *NeighborDiscovery { + if x != nil { + if x, ok := x.MetricType.(*Metric_NeighborDiscovery); ok { + return x.NeighborDiscovery + } + } + return nil +} + +func (x *Metric) GetMonitoringCheck() *MonitoringCheck { + if x != nil { + if x, ok := x.MetricType.(*Metric_MonitoringCheck); ok { + return x.MonitoringCheck + } + } + return nil +} + +type isMetric_MetricType interface { + isMetric_MetricType() +} + +type Metric_SensorReading struct { + SensorReading *SensorReading `protobuf:"bytes,1,opt,name=sensor_reading,json=sensorReading,proto3,oneof"` +} + +type Metric_InterfaceStat struct { + InterfaceStat *InterfaceStat `protobuf:"bytes,2,opt,name=interface_stat,json=interfaceStat,proto3,oneof"` +} + +type Metric_NeighborDiscovery struct { + NeighborDiscovery *NeighborDiscovery `protobuf:"bytes,3,opt,name=neighbor_discovery,json=neighborDiscovery,proto3,oneof"` +} + +type Metric_MonitoringCheck struct { + MonitoringCheck *MonitoringCheck `protobuf:"bytes,4,opt,name=monitoring_check,json=monitoringCheck,proto3,oneof"` +} + +func (*Metric_SensorReading) isMetric_MetricType() {} + +func (*Metric_InterfaceStat) isMetric_MetricType() {} + +func (*Metric_NeighborDiscovery) isMetric_MetricType() {} + +func (*Metric_MonitoringCheck) isMetric_MetricType() {} + +type SensorReading struct { + state protoimpl.MessageState `protogen:"open.v1"` + SensorId string `protobuf:"bytes,1,opt,name=sensor_id,json=sensorId,proto3" json:"sensor_id,omitempty"` + Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Unix timestamp in seconds + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SensorReading) Reset() { + *x = SensorReading{} + mi := &file_proto_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SensorReading) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SensorReading) ProtoMessage() {} + +func (x *SensorReading) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SensorReading.ProtoReflect.Descriptor instead. +func (*SensorReading) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *SensorReading) GetSensorId() string { + if x != nil { + return x.SensorId + } + return "" +} + +func (x *SensorReading) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *SensorReading) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *SensorReading) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type InterfaceStat struct { + state protoimpl.MessageState `protogen:"open.v1"` + InterfaceId string `protobuf:"bytes,1,opt,name=interface_id,json=interfaceId,proto3" json:"interface_id,omitempty"` + IfInOctets int64 `protobuf:"varint,2,opt,name=if_in_octets,json=ifInOctets,proto3" json:"if_in_octets,omitempty"` + IfOutOctets int64 `protobuf:"varint,3,opt,name=if_out_octets,json=ifOutOctets,proto3" json:"if_out_octets,omitempty"` + IfInErrors int64 `protobuf:"varint,4,opt,name=if_in_errors,json=ifInErrors,proto3" json:"if_in_errors,omitempty"` + IfOutErrors int64 `protobuf:"varint,5,opt,name=if_out_errors,json=ifOutErrors,proto3" json:"if_out_errors,omitempty"` + IfInDiscards int64 `protobuf:"varint,6,opt,name=if_in_discards,json=ifInDiscards,proto3" json:"if_in_discards,omitempty"` + IfOutDiscards int64 `protobuf:"varint,7,opt,name=if_out_discards,json=ifOutDiscards,proto3" json:"if_out_discards,omitempty"` + Timestamp int64 `protobuf:"varint,8,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Unix timestamp in seconds + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InterfaceStat) Reset() { + *x = InterfaceStat{} + mi := &file_proto_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InterfaceStat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InterfaceStat) ProtoMessage() {} + +func (x *InterfaceStat) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InterfaceStat.ProtoReflect.Descriptor instead. +func (*InterfaceStat) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *InterfaceStat) GetInterfaceId() string { + if x != nil { + return x.InterfaceId + } + return "" +} + +func (x *InterfaceStat) GetIfInOctets() int64 { + if x != nil { + return x.IfInOctets + } + return 0 +} + +func (x *InterfaceStat) GetIfOutOctets() int64 { + if x != nil { + return x.IfOutOctets + } + return 0 +} + +func (x *InterfaceStat) GetIfInErrors() int64 { + if x != nil { + return x.IfInErrors + } + return 0 +} + +func (x *InterfaceStat) GetIfOutErrors() int64 { + if x != nil { + return x.IfOutErrors + } + return 0 +} + +func (x *InterfaceStat) GetIfInDiscards() int64 { + if x != nil { + return x.IfInDiscards + } + return 0 +} + +func (x *InterfaceStat) GetIfOutDiscards() int64 { + if x != nil { + return x.IfOutDiscards + } + return 0 +} + +func (x *InterfaceStat) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type NeighborDiscovery struct { + state protoimpl.MessageState `protogen:"open.v1"` + InterfaceId string `protobuf:"bytes,1,opt,name=interface_id,json=interfaceId,proto3" json:"interface_id,omitempty"` + Protocol string `protobuf:"bytes,2,opt,name=protocol,proto3" json:"protocol,omitempty"` // "lldp" or "cdp" + RemoteChassisId string `protobuf:"bytes,3,opt,name=remote_chassis_id,json=remoteChassisId,proto3" json:"remote_chassis_id,omitempty"` + RemoteSystemName string `protobuf:"bytes,4,opt,name=remote_system_name,json=remoteSystemName,proto3" json:"remote_system_name,omitempty"` + RemoteSystemDescription string `protobuf:"bytes,5,opt,name=remote_system_description,json=remoteSystemDescription,proto3" json:"remote_system_description,omitempty"` + RemotePlatform string `protobuf:"bytes,6,opt,name=remote_platform,json=remotePlatform,proto3" json:"remote_platform,omitempty"` + RemotePortId string `protobuf:"bytes,7,opt,name=remote_port_id,json=remotePortId,proto3" json:"remote_port_id,omitempty"` + RemotePortDescription string `protobuf:"bytes,8,opt,name=remote_port_description,json=remotePortDescription,proto3" json:"remote_port_description,omitempty"` + RemoteAddress string `protobuf:"bytes,9,opt,name=remote_address,json=remoteAddress,proto3" json:"remote_address,omitempty"` + RemoteCapabilities []string `protobuf:"bytes,10,rep,name=remote_capabilities,json=remoteCapabilities,proto3" json:"remote_capabilities,omitempty"` + Timestamp int64 `protobuf:"varint,11,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Unix timestamp in seconds + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NeighborDiscovery) Reset() { + *x = NeighborDiscovery{} + mi := &file_proto_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NeighborDiscovery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NeighborDiscovery) ProtoMessage() {} + +func (x *NeighborDiscovery) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NeighborDiscovery.ProtoReflect.Descriptor instead. +func (*NeighborDiscovery) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *NeighborDiscovery) GetInterfaceId() string { + if x != nil { + return x.InterfaceId + } + return "" +} + +func (x *NeighborDiscovery) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *NeighborDiscovery) GetRemoteChassisId() string { + if x != nil { + return x.RemoteChassisId + } + return "" +} + +func (x *NeighborDiscovery) GetRemoteSystemName() string { + if x != nil { + return x.RemoteSystemName + } + return "" +} + +func (x *NeighborDiscovery) GetRemoteSystemDescription() string { + if x != nil { + return x.RemoteSystemDescription + } + return "" +} + +func (x *NeighborDiscovery) GetRemotePlatform() string { + if x != nil { + return x.RemotePlatform + } + return "" +} + +func (x *NeighborDiscovery) GetRemotePortId() string { + if x != nil { + return x.RemotePortId + } + return "" +} + +func (x *NeighborDiscovery) GetRemotePortDescription() string { + if x != nil { + return x.RemotePortDescription + } + return "" +} + +func (x *NeighborDiscovery) GetRemoteAddress() string { + if x != nil { + return x.RemoteAddress + } + return "" +} + +func (x *NeighborDiscovery) GetRemoteCapabilities() []string { + if x != nil { + return x.RemoteCapabilities + } + return nil +} + +func (x *NeighborDiscovery) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type MonitoringCheck struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // "success" or "failure" + ResponseTimeMs float64 `protobuf:"fixed64,3,opt,name=response_time_ms,json=responseTimeMs,proto3" json:"response_time_ms,omitempty"` // Optional - only present on success + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Unix timestamp in seconds + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MonitoringCheck) Reset() { + *x = MonitoringCheck{} + mi := &file_proto_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MonitoringCheck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MonitoringCheck) ProtoMessage() {} + +func (x *MonitoringCheck) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MonitoringCheck.ProtoReflect.Descriptor instead. +func (*MonitoringCheck) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *MonitoringCheck) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *MonitoringCheck) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *MonitoringCheck) GetResponseTimeMs() float64 { + if x != nil { + return x.ResponseTimeMs + } + return 0 +} + +func (x *MonitoringCheck) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +// Heartbeat metadata +type HeartbeatMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` + UptimeSeconds uint64 `protobuf:"varint,3,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatMetadata) Reset() { + *x = HeartbeatMetadata{} + mi := &file_proto_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatMetadata) ProtoMessage() {} + +func (x *HeartbeatMetadata) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatMetadata.ProtoReflect.Descriptor instead. +func (*HeartbeatMetadata) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *HeartbeatMetadata) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *HeartbeatMetadata) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *HeartbeatMetadata) GetUptimeSeconds() uint64 { + if x != nil { + return x.UptimeSeconds + } + return 0 +} + +type HeartbeatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatResponse) Reset() { + *x = HeartbeatResponse{} + mi := &file_proto_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatResponse) ProtoMessage() {} + +func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead. +func (*HeartbeatResponse) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *HeartbeatResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +type AgentJobList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Jobs []*AgentJob `protobuf:"bytes,1,rep,name=jobs,proto3" json:"jobs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentJobList) Reset() { + *x = AgentJobList{} + mi := &file_proto_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentJobList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentJobList) ProtoMessage() {} + +func (x *AgentJobList) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentJobList.ProtoReflect.Descriptor instead. +func (*AgentJobList) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *AgentJobList) GetJobs() []*AgentJob { + if x != nil { + return x.Jobs + } + return nil +} + +type AgentJob struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + JobType JobType `protobuf:"varint,2,opt,name=job_type,json=jobType,proto3,enum=towerops.agent.JobType" json:"job_type,omitempty"` + DeviceId string `protobuf:"bytes,3,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + SnmpDevice *SnmpDevice `protobuf:"bytes,4,opt,name=snmp_device,json=snmpDevice,proto3" json:"snmp_device,omitempty"` + Queries []*SnmpQuery `protobuf:"bytes,5,rep,name=queries,proto3" json:"queries,omitempty"` + MikrotikDevice *MikrotikDevice `protobuf:"bytes,6,opt,name=mikrotik_device,json=mikrotikDevice,proto3" json:"mikrotik_device,omitempty"` + MikrotikCommands []*MikrotikCommand `protobuf:"bytes,7,rep,name=mikrotik_commands,json=mikrotikCommands,proto3" json:"mikrotik_commands,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentJob) Reset() { + *x = AgentJob{} + mi := &file_proto_agent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentJob) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentJob) ProtoMessage() {} + +func (x *AgentJob) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentJob.ProtoReflect.Descriptor instead. +func (*AgentJob) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{14} +} + +func (x *AgentJob) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *AgentJob) GetJobType() JobType { + if x != nil { + return x.JobType + } + return JobType_DISCOVER +} + +func (x *AgentJob) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *AgentJob) GetSnmpDevice() *SnmpDevice { + if x != nil { + return x.SnmpDevice + } + return nil +} + +func (x *AgentJob) GetQueries() []*SnmpQuery { + if x != nil { + return x.Queries + } + return nil +} + +func (x *AgentJob) GetMikrotikDevice() *MikrotikDevice { + if x != nil { + return x.MikrotikDevice + } + return nil +} + +func (x *AgentJob) GetMikrotikCommands() []*MikrotikCommand { + if x != nil { + return x.MikrotikCommands + } + return nil +} + +type SnmpDevice struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ip string `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"` + Community string `protobuf:"bytes,2,opt,name=community,proto3" json:"community,omitempty"` // v1/v2c only (deprecated for v3) + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Port uint32 `protobuf:"varint,4,opt,name=port,proto3" json:"port,omitempty"` + // SNMPv3 credentials (optional, backward compatible) + V3SecurityLevel string `protobuf:"bytes,5,opt,name=v3_security_level,json=v3SecurityLevel,proto3" json:"v3_security_level,omitempty"` // "noAuthNoPriv" | "authNoPriv" | "authPriv" + V3Username string `protobuf:"bytes,6,opt,name=v3_username,json=v3Username,proto3" json:"v3_username,omitempty"` + V3AuthProtocol string `protobuf:"bytes,7,opt,name=v3_auth_protocol,json=v3AuthProtocol,proto3" json:"v3_auth_protocol,omitempty"` // "MD5" | "SHA" | "SHA-256" | etc. + V3AuthPassword string `protobuf:"bytes,8,opt,name=v3_auth_password,json=v3AuthPassword,proto3" json:"v3_auth_password,omitempty"` // Decrypted before sending + V3PrivProtocol string `protobuf:"bytes,9,opt,name=v3_priv_protocol,json=v3PrivProtocol,proto3" json:"v3_priv_protocol,omitempty"` // "DES" | "AES" | "AES-256" | etc. + V3PrivPassword string `protobuf:"bytes,10,opt,name=v3_priv_password,json=v3PrivPassword,proto3" json:"v3_priv_password,omitempty"` // Decrypted before sending + Transport string `protobuf:"bytes,11,opt,name=transport,proto3" json:"transport,omitempty"` // "udp" | "tcp" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SnmpDevice) Reset() { + *x = SnmpDevice{} + mi := &file_proto_agent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SnmpDevice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SnmpDevice) ProtoMessage() {} + +func (x *SnmpDevice) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SnmpDevice.ProtoReflect.Descriptor instead. +func (*SnmpDevice) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{15} +} + +func (x *SnmpDevice) GetIp() string { + if x != nil { + return x.Ip + } + return "" +} + +func (x *SnmpDevice) GetCommunity() string { + if x != nil { + return x.Community + } + return "" +} + +func (x *SnmpDevice) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *SnmpDevice) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *SnmpDevice) GetV3SecurityLevel() string { + if x != nil { + return x.V3SecurityLevel + } + return "" +} + +func (x *SnmpDevice) GetV3Username() string { + if x != nil { + return x.V3Username + } + return "" +} + +func (x *SnmpDevice) GetV3AuthProtocol() string { + if x != nil { + return x.V3AuthProtocol + } + return "" +} + +func (x *SnmpDevice) GetV3AuthPassword() string { + if x != nil { + return x.V3AuthPassword + } + return "" +} + +func (x *SnmpDevice) GetV3PrivProtocol() string { + if x != nil { + return x.V3PrivProtocol + } + return "" +} + +func (x *SnmpDevice) GetV3PrivPassword() string { + if x != nil { + return x.V3PrivPassword + } + return "" +} + +func (x *SnmpDevice) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + +type SnmpQuery struct { + state protoimpl.MessageState `protogen:"open.v1"` + QueryType QueryType `protobuf:"varint,1,opt,name=query_type,json=queryType,proto3,enum=towerops.agent.QueryType" json:"query_type,omitempty"` + Oids []string `protobuf:"bytes,2,rep,name=oids,proto3" json:"oids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SnmpQuery) Reset() { + *x = SnmpQuery{} + mi := &file_proto_agent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SnmpQuery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SnmpQuery) ProtoMessage() {} + +func (x *SnmpQuery) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SnmpQuery.ProtoReflect.Descriptor instead. +func (*SnmpQuery) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{16} +} + +func (x *SnmpQuery) GetQueryType() QueryType { + if x != nil { + return x.QueryType + } + return QueryType_GET +} + +func (x *SnmpQuery) GetOids() []string { + if x != nil { + return x.Oids + } + return nil +} + +type SnmpResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + JobType JobType `protobuf:"varint,2,opt,name=job_type,json=jobType,proto3,enum=towerops.agent.JobType" json:"job_type,omitempty"` + OidValues map[string]string `protobuf:"bytes,3,rep,name=oid_values,json=oidValues,proto3" json:"oid_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + JobId string `protobuf:"bytes,5,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SnmpResult) Reset() { + *x = SnmpResult{} + mi := &file_proto_agent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SnmpResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SnmpResult) ProtoMessage() {} + +func (x *SnmpResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SnmpResult.ProtoReflect.Descriptor instead. +func (*SnmpResult) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{17} +} + +func (x *SnmpResult) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *SnmpResult) GetJobType() JobType { + if x != nil { + return x.JobType + } + return JobType_DISCOVER +} + +func (x *SnmpResult) GetOidValues() map[string]string { + if x != nil { + return x.OidValues + } + return nil +} + +func (x *SnmpResult) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *SnmpResult) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +type AgentHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` + UptimeSeconds uint64 `protobuf:"varint,3,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` + IpAddress string `protobuf:"bytes,4,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + Arch string `protobuf:"bytes,5,opt,name=arch,proto3" json:"arch,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentHeartbeat) Reset() { + *x = AgentHeartbeat{} + mi := &file_proto_agent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentHeartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentHeartbeat) ProtoMessage() {} + +func (x *AgentHeartbeat) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentHeartbeat.ProtoReflect.Descriptor instead. +func (*AgentHeartbeat) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{18} +} + +func (x *AgentHeartbeat) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AgentHeartbeat) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *AgentHeartbeat) GetUptimeSeconds() uint64 { + if x != nil { + return x.UptimeSeconds + } + return 0 +} + +func (x *AgentHeartbeat) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +func (x *AgentHeartbeat) GetArch() string { + if x != nil { + return x.Arch + } + return "" +} + +type AgentError struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + ErrorMessage string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + Timestamp int64 `protobuf:"varint,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentError) Reset() { + *x = AgentError{} + mi := &file_proto_agent_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentError) ProtoMessage() {} + +func (x *AgentError) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentError.ProtoReflect.Descriptor instead. +func (*AgentError) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{19} +} + +func (x *AgentError) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *AgentError) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *AgentError) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type CredentialTestResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TestId string `protobuf:"bytes,1,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"` + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + ErrorMessage string `protobuf:"bytes,3,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` // Empty if success + SystemDescription string `protobuf:"bytes,4,opt,name=system_description,json=systemDescription,proto3" json:"system_description,omitempty"` // sysDescr.0 value if success + Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CredentialTestResult) Reset() { + *x = CredentialTestResult{} + mi := &file_proto_agent_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CredentialTestResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CredentialTestResult) ProtoMessage() {} + +func (x *CredentialTestResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CredentialTestResult.ProtoReflect.Descriptor instead. +func (*CredentialTestResult) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{20} +} + +func (x *CredentialTestResult) GetTestId() string { + if x != nil { + return x.TestId + } + return "" +} + +func (x *CredentialTestResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CredentialTestResult) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *CredentialTestResult) GetSystemDescription() string { + if x != nil { + return x.SystemDescription + } + return "" +} + +func (x *CredentialTestResult) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type MikrotikDevice struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ip string `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + UseSsl bool `protobuf:"varint,5,opt,name=use_ssl,json=useSsl,proto3" json:"use_ssl,omitempty"` + SshPort uint32 `protobuf:"varint,6,opt,name=ssh_port,json=sshPort,proto3" json:"ssh_port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MikrotikDevice) Reset() { + *x = MikrotikDevice{} + mi := &file_proto_agent_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MikrotikDevice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MikrotikDevice) ProtoMessage() {} + +func (x *MikrotikDevice) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MikrotikDevice.ProtoReflect.Descriptor instead. +func (*MikrotikDevice) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{21} +} + +func (x *MikrotikDevice) GetIp() string { + if x != nil { + return x.Ip + } + return "" +} + +func (x *MikrotikDevice) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *MikrotikDevice) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *MikrotikDevice) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *MikrotikDevice) GetUseSsl() bool { + if x != nil { + return x.UseSsl + } + return false +} + +func (x *MikrotikDevice) GetSshPort() uint32 { + if x != nil { + return x.SshPort + } + return 0 +} + +type MikrotikCommand struct { + state protoimpl.MessageState `protogen:"open.v1"` + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + Args map[string]string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MikrotikCommand) Reset() { + *x = MikrotikCommand{} + mi := &file_proto_agent_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MikrotikCommand) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MikrotikCommand) ProtoMessage() {} + +func (x *MikrotikCommand) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MikrotikCommand.ProtoReflect.Descriptor instead. +func (*MikrotikCommand) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{22} +} + +func (x *MikrotikCommand) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *MikrotikCommand) GetArgs() map[string]string { + if x != nil { + return x.Args + } + return nil +} + +type MikrotikResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + JobId string `protobuf:"bytes,2,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + Sentences []*MikrotikSentence `protobuf:"bytes,3,rep,name=sentences,proto3" json:"sentences,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MikrotikResult) Reset() { + *x = MikrotikResult{} + mi := &file_proto_agent_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MikrotikResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MikrotikResult) ProtoMessage() {} + +func (x *MikrotikResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MikrotikResult.ProtoReflect.Descriptor instead. +func (*MikrotikResult) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{23} +} + +func (x *MikrotikResult) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *MikrotikResult) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *MikrotikResult) GetSentences() []*MikrotikSentence { + if x != nil { + return x.Sentences + } + return nil +} + +func (x *MikrotikResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *MikrotikResult) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type MikrotikSentence struct { + state protoimpl.MessageState `protogen:"open.v1"` + Attributes map[string]string `protobuf:"bytes,1,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MikrotikSentence) Reset() { + *x = MikrotikSentence{} + mi := &file_proto_agent_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MikrotikSentence) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MikrotikSentence) ProtoMessage() {} + +func (x *MikrotikSentence) ProtoReflect() protoreflect.Message { + mi := &file_proto_agent_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MikrotikSentence.ProtoReflect.Descriptor instead. +func (*MikrotikSentence) Descriptor() ([]byte, []int) { + return file_proto_agent_proto_rawDescGZIP(), []int{24} +} + +func (x *MikrotikSentence) GetAttributes() map[string]string { + if x != nil { + return x.Attributes + } + return nil +} + +var File_proto_agent_proto protoreflect.FileDescriptor + +const file_proto_agent_proto_rawDesc = "" + + "\n" + + "\x11proto/agent.proto\x12\x0etowerops.agent\"\x8d\x01\n" + + "\vAgentConfig\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x122\n" + + "\x15poll_interval_seconds\x18\x02 \x01(\rR\x13pollIntervalSeconds\x120\n" + + "\adevices\x18\x03 \x03(\v2\x16.towerops.agent.DeviceR\adevices\"\x81\x03\n" + + "\x06Device\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "ip_address\x18\x03 \x01(\tR\tipAddress\x12.\n" + + "\x04snmp\x18\x04 \x01(\v2\x1a.towerops.agent.SnmpConfigR\x04snmp\x122\n" + + "\x15poll_interval_seconds\x18\x05 \x01(\rR\x13pollIntervalSeconds\x120\n" + + "\asensors\x18\x06 \x03(\v2\x16.towerops.agent.SensorR\asensors\x129\n" + + "\n" + + "interfaces\x18\a \x03(\v2\x19.towerops.agent.InterfaceR\n" + + "interfaces\x12-\n" + + "\x12monitoring_enabled\x18\b \x01(\bR\x11monitoringEnabled\x124\n" + + "\x16check_interval_seconds\x18\t \x01(\rR\x14checkIntervalSeconds\"\x90\x01\n" + + "\n" + + "SnmpConfig\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12\x1c\n" + + "\tcommunity\x18\x03 \x01(\tR\tcommunity\x12\x12\n" + + "\x04port\x18\x04 \x01(\rR\x04port\x12\x1c\n" + + "\ttransport\x18\x05 \x01(\tR\ttransport\"\xeb\x01\n" + + "\x06Sensor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x10\n" + + "\x03oid\x18\x03 \x01(\tR\x03oid\x12\x18\n" + + "\adivisor\x18\x04 \x01(\x01R\adivisor\x12\x12\n" + + "\x04unit\x18\x05 \x01(\tR\x04unit\x12@\n" + + "\bmetadata\x18\x06 \x03(\v2$.towerops.agent.Sensor.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"O\n" + + "\tInterface\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" + + "\bif_index\x18\x02 \x01(\rR\aifIndex\x12\x17\n" + + "\aif_name\x18\x03 \x01(\tR\x06ifName\"?\n" + + "\vMetricBatch\x120\n" + + "\ametrics\x18\x01 \x03(\v2\x16.towerops.agent.MetricR\ametrics\"\xc9\x02\n" + + "\x06Metric\x12F\n" + + "\x0esensor_reading\x18\x01 \x01(\v2\x1d.towerops.agent.SensorReadingH\x00R\rsensorReading\x12F\n" + + "\x0einterface_stat\x18\x02 \x01(\v2\x1d.towerops.agent.InterfaceStatH\x00R\rinterfaceStat\x12R\n" + + "\x12neighbor_discovery\x18\x03 \x01(\v2!.towerops.agent.NeighborDiscoveryH\x00R\x11neighborDiscovery\x12L\n" + + "\x10monitoring_check\x18\x04 \x01(\v2\x1f.towerops.agent.MonitoringCheckH\x00R\x0fmonitoringCheckB\r\n" + + "\vmetric_type\"x\n" + + "\rSensorReading\x12\x1b\n" + + "\tsensor_id\x18\x01 \x01(\tR\bsensorId\x12\x14\n" + + "\x05value\x18\x02 \x01(\x01R\x05value\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\"\xaa\x02\n" + + "\rInterfaceStat\x12!\n" + + "\finterface_id\x18\x01 \x01(\tR\vinterfaceId\x12 \n" + + "\fif_in_octets\x18\x02 \x01(\x03R\n" + + "ifInOctets\x12\"\n" + + "\rif_out_octets\x18\x03 \x01(\x03R\vifOutOctets\x12 \n" + + "\fif_in_errors\x18\x04 \x01(\x03R\n" + + "ifInErrors\x12\"\n" + + "\rif_out_errors\x18\x05 \x01(\x03R\vifOutErrors\x12$\n" + + "\x0eif_in_discards\x18\x06 \x01(\x03R\fifInDiscards\x12&\n" + + "\x0fif_out_discards\x18\a \x01(\x03R\rifOutDiscards\x12\x1c\n" + + "\ttimestamp\x18\b \x01(\x03R\ttimestamp\"\xe5\x03\n" + + "\x11NeighborDiscovery\x12!\n" + + "\finterface_id\x18\x01 \x01(\tR\vinterfaceId\x12\x1a\n" + + "\bprotocol\x18\x02 \x01(\tR\bprotocol\x12*\n" + + "\x11remote_chassis_id\x18\x03 \x01(\tR\x0fremoteChassisId\x12,\n" + + "\x12remote_system_name\x18\x04 \x01(\tR\x10remoteSystemName\x12:\n" + + "\x19remote_system_description\x18\x05 \x01(\tR\x17remoteSystemDescription\x12'\n" + + "\x0fremote_platform\x18\x06 \x01(\tR\x0eremotePlatform\x12$\n" + + "\x0eremote_port_id\x18\a \x01(\tR\fremotePortId\x126\n" + + "\x17remote_port_description\x18\b \x01(\tR\x15remotePortDescription\x12%\n" + + "\x0eremote_address\x18\t \x01(\tR\rremoteAddress\x12/\n" + + "\x13remote_capabilities\x18\n" + + " \x03(\tR\x12remoteCapabilities\x12\x1c\n" + + "\ttimestamp\x18\v \x01(\x03R\ttimestamp\"\x8e\x01\n" + + "\x0fMonitoringCheck\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12(\n" + + "\x10response_time_ms\x18\x03 \x01(\x01R\x0eresponseTimeMs\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\"p\n" + + "\x11HeartbeatMetadata\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1a\n" + + "\bhostname\x18\x02 \x01(\tR\bhostname\x12%\n" + + "\x0euptime_seconds\x18\x03 \x01(\x04R\ruptimeSeconds\"+\n" + + "\x11HeartbeatResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\tR\x06status\"<\n" + + "\fAgentJobList\x12,\n" + + "\x04jobs\x18\x01 \x03(\v2\x18.towerops.agent.AgentJobR\x04jobs\"\xfb\x02\n" + + "\bAgentJob\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x122\n" + + "\bjob_type\x18\x02 \x01(\x0e2\x17.towerops.agent.JobTypeR\ajobType\x12\x1b\n" + + "\tdevice_id\x18\x03 \x01(\tR\bdeviceId\x12;\n" + + "\vsnmp_device\x18\x04 \x01(\v2\x1a.towerops.agent.SnmpDeviceR\n" + + "snmpDevice\x123\n" + + "\aqueries\x18\x05 \x03(\v2\x19.towerops.agent.SnmpQueryR\aqueries\x12G\n" + + "\x0fmikrotik_device\x18\x06 \x01(\v2\x1e.towerops.agent.MikrotikDeviceR\x0emikrotikDevice\x12L\n" + + "\x11mikrotik_commands\x18\a \x03(\v2\x1f.towerops.agent.MikrotikCommandR\x10mikrotikCommands\"\xfb\x02\n" + + "\n" + + "SnmpDevice\x12\x0e\n" + + "\x02ip\x18\x01 \x01(\tR\x02ip\x12\x1c\n" + + "\tcommunity\x18\x02 \x01(\tR\tcommunity\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion\x12\x12\n" + + "\x04port\x18\x04 \x01(\rR\x04port\x12*\n" + + "\x11v3_security_level\x18\x05 \x01(\tR\x0fv3SecurityLevel\x12\x1f\n" + + "\vv3_username\x18\x06 \x01(\tR\n" + + "v3Username\x12(\n" + + "\x10v3_auth_protocol\x18\a \x01(\tR\x0ev3AuthProtocol\x12(\n" + + "\x10v3_auth_password\x18\b \x01(\tR\x0ev3AuthPassword\x12(\n" + + "\x10v3_priv_protocol\x18\t \x01(\tR\x0ev3PrivProtocol\x12(\n" + + "\x10v3_priv_password\x18\n" + + " \x01(\tR\x0ev3PrivPassword\x12\x1c\n" + + "\ttransport\x18\v \x01(\tR\ttransport\"Y\n" + + "\tSnmpQuery\x128\n" + + "\n" + + "query_type\x18\x01 \x01(\x0e2\x19.towerops.agent.QueryTypeR\tqueryType\x12\x12\n" + + "\x04oids\x18\x02 \x03(\tR\x04oids\"\x9a\x02\n" + + "\n" + + "SnmpResult\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x122\n" + + "\bjob_type\x18\x02 \x01(\x0e2\x17.towerops.agent.JobTypeR\ajobType\x12H\n" + + "\n" + + "oid_values\x18\x03 \x03(\v2).towerops.agent.SnmpResult.OidValuesEntryR\toidValues\x12\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12\x15\n" + + "\x06job_id\x18\x05 \x01(\tR\x05jobId\x1a<\n" + + "\x0eOidValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa0\x01\n" + + "\x0eAgentHeartbeat\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1a\n" + + "\bhostname\x18\x02 \x01(\tR\bhostname\x12%\n" + + "\x0euptime_seconds\x18\x03 \x01(\x04R\ruptimeSeconds\x12\x1d\n" + + "\n" + + "ip_address\x18\x04 \x01(\tR\tipAddress\x12\x12\n" + + "\x04arch\x18\x05 \x01(\tR\x04arch\"l\n" + + "\n" + + "AgentError\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12#\n" + + "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\x12\x1c\n" + + "\ttimestamp\x18\x03 \x01(\x03R\ttimestamp\"\xbb\x01\n" + + "\x14CredentialTestResult\x12\x17\n" + + "\atest_id\x18\x01 \x01(\tR\x06testId\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess\x12#\n" + + "\rerror_message\x18\x03 \x01(\tR\ferrorMessage\x12-\n" + + "\x12system_description\x18\x04 \x01(\tR\x11systemDescription\x12\x1c\n" + + "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"\xa0\x01\n" + + "\x0eMikrotikDevice\x12\x0e\n" + + "\x02ip\x18\x01 \x01(\tR\x02ip\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x17\n" + + "\ause_ssl\x18\x05 \x01(\bR\x06useSsl\x12\x19\n" + + "\bssh_port\x18\x06 \x01(\rR\asshPort\"\xa3\x01\n" + + "\x0fMikrotikCommand\x12\x18\n" + + "\acommand\x18\x01 \x01(\tR\acommand\x12=\n" + + "\x04args\x18\x02 \x03(\v2).towerops.agent.MikrotikCommand.ArgsEntryR\x04args\x1a7\n" + + "\tArgsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb8\x01\n" + + "\x0eMikrotikResult\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x15\n" + + "\x06job_id\x18\x02 \x01(\tR\x05jobId\x12>\n" + + "\tsentences\x18\x03 \x03(\v2 .towerops.agent.MikrotikSentenceR\tsentences\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x12\x1c\n" + + "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"\xa3\x01\n" + + "\x10MikrotikSentence\x12P\n" + + "\n" + + "attributes\x18\x01 \x03(\v20.towerops.agent.MikrotikSentence.AttributesEntryR\n" + + "attributes\x1a=\n" + + "\x0fAttributesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*O\n" + + "\aJobType\x12\f\n" + + "\bDISCOVER\x10\x00\x12\b\n" + + "\x04POLL\x10\x01\x12\f\n" + + "\bMIKROTIK\x10\x02\x12\x14\n" + + "\x10TEST_CREDENTIALS\x10\x03\x12\b\n" + + "\x04PING\x10\x04*\x1e\n" + + "\tQueryType\x12\a\n" + + "\x03GET\x10\x00\x12\b\n" + + "\x04WALK\x10\x01B+Z)github.com/towerops-app/towerops-agent/pbb\x06proto3" + +var ( + file_proto_agent_proto_rawDescOnce sync.Once + file_proto_agent_proto_rawDescData []byte +) + +func file_proto_agent_proto_rawDescGZIP() []byte { + file_proto_agent_proto_rawDescOnce.Do(func() { + file_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc))) + }) + return file_proto_agent_proto_rawDescData +} + +var file_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_proto_agent_proto_goTypes = []any{ + (JobType)(0), // 0: towerops.agent.JobType + (QueryType)(0), // 1: towerops.agent.QueryType + (*AgentConfig)(nil), // 2: towerops.agent.AgentConfig + (*Device)(nil), // 3: towerops.agent.Device + (*SnmpConfig)(nil), // 4: towerops.agent.SnmpConfig + (*Sensor)(nil), // 5: towerops.agent.Sensor + (*Interface)(nil), // 6: towerops.agent.Interface + (*MetricBatch)(nil), // 7: towerops.agent.MetricBatch + (*Metric)(nil), // 8: towerops.agent.Metric + (*SensorReading)(nil), // 9: towerops.agent.SensorReading + (*InterfaceStat)(nil), // 10: towerops.agent.InterfaceStat + (*NeighborDiscovery)(nil), // 11: towerops.agent.NeighborDiscovery + (*MonitoringCheck)(nil), // 12: towerops.agent.MonitoringCheck + (*HeartbeatMetadata)(nil), // 13: towerops.agent.HeartbeatMetadata + (*HeartbeatResponse)(nil), // 14: towerops.agent.HeartbeatResponse + (*AgentJobList)(nil), // 15: towerops.agent.AgentJobList + (*AgentJob)(nil), // 16: towerops.agent.AgentJob + (*SnmpDevice)(nil), // 17: towerops.agent.SnmpDevice + (*SnmpQuery)(nil), // 18: towerops.agent.SnmpQuery + (*SnmpResult)(nil), // 19: towerops.agent.SnmpResult + (*AgentHeartbeat)(nil), // 20: towerops.agent.AgentHeartbeat + (*AgentError)(nil), // 21: towerops.agent.AgentError + (*CredentialTestResult)(nil), // 22: towerops.agent.CredentialTestResult + (*MikrotikDevice)(nil), // 23: towerops.agent.MikrotikDevice + (*MikrotikCommand)(nil), // 24: towerops.agent.MikrotikCommand + (*MikrotikResult)(nil), // 25: towerops.agent.MikrotikResult + (*MikrotikSentence)(nil), // 26: towerops.agent.MikrotikSentence + nil, // 27: towerops.agent.Sensor.MetadataEntry + nil, // 28: towerops.agent.SnmpResult.OidValuesEntry + nil, // 29: towerops.agent.MikrotikCommand.ArgsEntry + nil, // 30: towerops.agent.MikrotikSentence.AttributesEntry +} +var file_proto_agent_proto_depIdxs = []int32{ + 3, // 0: towerops.agent.AgentConfig.devices:type_name -> towerops.agent.Device + 4, // 1: towerops.agent.Device.snmp:type_name -> towerops.agent.SnmpConfig + 5, // 2: towerops.agent.Device.sensors:type_name -> towerops.agent.Sensor + 6, // 3: towerops.agent.Device.interfaces:type_name -> towerops.agent.Interface + 27, // 4: towerops.agent.Sensor.metadata:type_name -> towerops.agent.Sensor.MetadataEntry + 8, // 5: towerops.agent.MetricBatch.metrics:type_name -> towerops.agent.Metric + 9, // 6: towerops.agent.Metric.sensor_reading:type_name -> towerops.agent.SensorReading + 10, // 7: towerops.agent.Metric.interface_stat:type_name -> towerops.agent.InterfaceStat + 11, // 8: towerops.agent.Metric.neighbor_discovery:type_name -> towerops.agent.NeighborDiscovery + 12, // 9: towerops.agent.Metric.monitoring_check:type_name -> towerops.agent.MonitoringCheck + 16, // 10: towerops.agent.AgentJobList.jobs:type_name -> towerops.agent.AgentJob + 0, // 11: towerops.agent.AgentJob.job_type:type_name -> towerops.agent.JobType + 17, // 12: towerops.agent.AgentJob.snmp_device:type_name -> towerops.agent.SnmpDevice + 18, // 13: towerops.agent.AgentJob.queries:type_name -> towerops.agent.SnmpQuery + 23, // 14: towerops.agent.AgentJob.mikrotik_device:type_name -> towerops.agent.MikrotikDevice + 24, // 15: towerops.agent.AgentJob.mikrotik_commands:type_name -> towerops.agent.MikrotikCommand + 1, // 16: towerops.agent.SnmpQuery.query_type:type_name -> towerops.agent.QueryType + 0, // 17: towerops.agent.SnmpResult.job_type:type_name -> towerops.agent.JobType + 28, // 18: towerops.agent.SnmpResult.oid_values:type_name -> towerops.agent.SnmpResult.OidValuesEntry + 29, // 19: towerops.agent.MikrotikCommand.args:type_name -> towerops.agent.MikrotikCommand.ArgsEntry + 26, // 20: towerops.agent.MikrotikResult.sentences:type_name -> towerops.agent.MikrotikSentence + 30, // 21: towerops.agent.MikrotikSentence.attributes:type_name -> towerops.agent.MikrotikSentence.AttributesEntry + 22, // [22:22] is the sub-list for method output_type + 22, // [22:22] is the sub-list for method input_type + 22, // [22:22] is the sub-list for extension type_name + 22, // [22:22] is the sub-list for extension extendee + 0, // [0:22] is the sub-list for field type_name +} + +func init() { file_proto_agent_proto_init() } +func file_proto_agent_proto_init() { + if File_proto_agent_proto != nil { + return + } + file_proto_agent_proto_msgTypes[6].OneofWrappers = []any{ + (*Metric_SensorReading)(nil), + (*Metric_InterfaceStat)(nil), + (*Metric_NeighborDiscovery)(nil), + (*Metric_MonitoringCheck)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_agent_proto_rawDesc), len(file_proto_agent_proto_rawDesc)), + NumEnums: 2, + NumMessages: 29, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proto_agent_proto_goTypes, + DependencyIndexes: file_proto_agent_proto_depIdxs, + EnumInfos: file_proto_agent_proto_enumTypes, + MessageInfos: file_proto_agent_proto_msgTypes, + }.Build() + File_proto_agent_proto = out.File + file_proto_agent_proto_goTypes = nil + file_proto_agent_proto_depIdxs = nil +} diff --git a/ping.go b/ping.go new file mode 100644 index 0000000..e2fb60a --- /dev/null +++ b/ping.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + "net" + "os/exec" + "strconv" + "strings" + "time" +) + +// pingDevice pings an IP address and returns the response time in milliseconds. +func pingDevice(ip string, timeoutMs int) (float64, error) { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return 0, fmt.Errorf("invalid IP address: %s", ip) + } + + pingCmd := "ping" + if parsedIP.To4() == nil { + pingCmd = "ping6" + } + + timeoutSecs := max(1, timeoutMs/1000) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs+1000)*time.Millisecond) + defer cancel() + + cmd := exec.CommandContext(ctx, pingCmd, "-c", "1", "-W", strconv.Itoa(timeoutSecs), ip) + output, err := cmd.CombinedOutput() + if err != nil { + return 0, fmt.Errorf("ping failed: %s", strings.TrimSpace(string(output))) + } + + return parsePingTime(string(output)) +} + +// parsePingTime extracts the response time from ping output. +func parsePingTime(output string) (float64, error) { + for _, line := range strings.Split(output, "\n") { + idx := strings.Index(line, "time=") + if idx < 0 { + continue + } + timeStr := line[idx+5:] + end := strings.Index(timeStr, " ms") + if end < 0 { + end = strings.IndexByte(timeStr, ' ') + } + if end < 0 { + end = len(timeStr) + } + return strconv.ParseFloat(timeStr[:end], 64) + } + return 0, fmt.Errorf("no time= field in ping output") +} diff --git a/ping_test.go b/ping_test.go new file mode 100644 index 0000000..590e3cc --- /dev/null +++ b/ping_test.go @@ -0,0 +1,56 @@ +package main + +import "testing" + +func TestParsePingTime(t *testing.T) { + tests := []struct { + name string + output string + want float64 + wantErr bool + }{ + { + name: "standard linux", + output: "64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=12.3 ms", + want: 12.3, + }, + { + name: "localhost", + output: "64 bytes from localhost: icmp_seq=1 ttl=64 time=0.123 ms", + want: 0.123, + }, + { + name: "multiline", + output: "PING 8.8.8.8 (8.8.8.8): 56 data bytes\n64 bytes from 8.8.8.8: icmp_seq=0 ttl=118 time=15.7 ms\n--- 8.8.8.8 ping statistics ---", + want: 15.7, + }, + { + name: "no time field", + output: "Request timeout for icmp_seq 0", + wantErr: true, + }, + { + name: "empty", + output: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePingTime(tt.output) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got %v", got) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/proto/agent.proto b/proto/agent.proto index 9c3d4ce..348ac3a 100644 --- a/proto/agent.proto +++ b/proto/agent.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package towerops.agent; +option go_package = "github.com/towerops-app/towerops-agent/pb"; + // Configuration received from the API message AgentConfig { string version = 1; diff --git a/snmp.go b/snmp.go new file mode 100644 index 0000000..033f151 --- /dev/null +++ b/snmp.go @@ -0,0 +1,269 @@ +package main + +import ( + "fmt" + "log/slog" + "time" + + "github.com/gosnmp/gosnmp" + "github.com/towerops-app/towerops-agent/pb" +) + +// executeSnmpJob runs SNMP GET/WALK queries for a job and sends results. +func executeSnmpJob(job *pb.AgentJob, resultCh chan<- *pb.SnmpResult) { + dev := job.SnmpDevice + if dev == nil { + slog.Error("job missing snmp device", "job_id", job.JobId) + return + } + + conn, err := newSnmpConn(dev) + if err != nil { + slog.Error("snmp connect", "job_id", job.JobId, "device", dev.Ip, "error", err) + return + } + defer conn.Conn.Close() + + oidValues := make(map[string]string) + + for _, q := range job.Queries { + switch q.QueryType { + case pb.QueryType_GET: + for _, oid := range q.Oids { + result, err := conn.Get([]string{oid}) + if err != nil { + slog.Warn("snmp get failed", "device", dev.Ip, "oid", oid, "error", err) + continue + } + for _, v := range result.Variables { + if v.Type == gosnmp.NoSuchObject || v.Type == gosnmp.NoSuchInstance || v.Type == gosnmp.EndOfMibView { + continue + } + oidValues[v.Name] = snmpValueToString(v) + } + } + case pb.QueryType_WALK: + for _, baseOID := range q.Oids { + results, err := conn.BulkWalkAll(baseOID) + if err != nil { + slog.Warn("snmp walk failed", "device", dev.Ip, "oid", baseOID, "error", err) + continue + } + for _, v := range results { + if v.Type == gosnmp.NoSuchObject || v.Type == gosnmp.NoSuchInstance || v.Type == gosnmp.EndOfMibView { + continue + } + oidValues[v.Name] = snmpValueToString(v) + } + } + } + } + + result := &pb.SnmpResult{ + DeviceId: job.DeviceId, + JobType: job.JobType, + JobId: job.JobId, + OidValues: oidValues, + Timestamp: time.Now().Unix(), + } + + slog.Info("snmp job complete", "job_id", job.JobId, "oids", len(oidValues)) + + select { + case resultCh <- result: + default: + slog.Warn("result channel full", "job_id", job.JobId) + } +} + +// executeCredentialTest tests SNMP credentials by reading sysDescr.0. +func executeCredentialTest(job *pb.AgentJob, resultCh chan<- *pb.CredentialTestResult) { + dev := job.SnmpDevice + if dev == nil { + slog.Error("job missing snmp device", "job_id", job.JobId) + return + } + + conn, err := newSnmpConn(dev) + timestamp := time.Now().Unix() + + if err != nil { + resultCh <- &pb.CredentialTestResult{ + TestId: job.JobId, + Success: false, + ErrorMessage: fmt.Sprintf("connection failed: %v", err), + Timestamp: timestamp, + } + return + } + defer conn.Conn.Close() + + result, err := conn.Get([]string{"1.3.6.1.2.1.1.1.0"}) + if err != nil { + resultCh <- &pb.CredentialTestResult{ + TestId: job.JobId, + Success: false, + ErrorMessage: fmt.Sprintf("SNMP test failed: %v", err), + Timestamp: timestamp, + } + return + } + + sysDescr := "" + if len(result.Variables) > 0 { + sysDescr = snmpValueToString(result.Variables[0]) + } + + resultCh <- &pb.CredentialTestResult{ + TestId: job.JobId, + Success: true, + SystemDescription: sysDescr, + Timestamp: timestamp, + } +} + +// newSnmpConn creates a gosnmp.GoSNMP connection from protobuf device config. +func newSnmpConn(dev *pb.SnmpDevice) (*gosnmp.GoSNMP, error) { + conn := &gosnmp.GoSNMP{ + Target: dev.Ip, + Port: uint16(dev.Port), + Timeout: 10 * time.Second, + Retries: 2, + } + + // Transport + if dev.Transport == "tcp" { + conn.Transport = "tcp" + } + + // Version + auth + switch dev.Version { + case "1", "v1": + conn.Version = gosnmp.Version1 + conn.Community = dev.Community + case "3", "v3": + conn.Version = gosnmp.Version3 + conn.SecurityModel = gosnmp.UserSecurityModel + usmParams := &gosnmp.UsmSecurityParameters{ + UserName: dev.V3Username, + } + + switch dev.V3SecurityLevel { + case "authPriv": + conn.MsgFlags = gosnmp.AuthPriv + usmParams.AuthenticationPassphrase = dev.V3AuthPassword + usmParams.PrivacyPassphrase = dev.V3PrivPassword + usmParams.AuthenticationProtocol = mapAuthProtocol(dev.V3AuthProtocol) + usmParams.PrivacyProtocol = mapPrivProtocol(dev.V3PrivProtocol) + case "authNoPriv": + conn.MsgFlags = gosnmp.AuthNoPriv + usmParams.AuthenticationPassphrase = dev.V3AuthPassword + usmParams.AuthenticationProtocol = mapAuthProtocol(dev.V3AuthProtocol) + default: // noAuthNoPriv + conn.MsgFlags = gosnmp.NoAuthNoPriv + } + + conn.SecurityParameters = usmParams + default: // "2c", "v2c", "2", "" + conn.Version = gosnmp.Version2c + conn.Community = dev.Community + } + + if err := conn.Connect(); err != nil { + return nil, fmt.Errorf("snmp connect %s:%d: %w", dev.Ip, dev.Port, err) + } + + return conn, nil +} + +func mapAuthProtocol(p string) gosnmp.SnmpV3AuthProtocol { + switch p { + case "MD5": + return gosnmp.MD5 + case "SHA", "SHA-1": + return gosnmp.SHA + case "SHA-224": + return gosnmp.SHA224 + case "SHA-256": + return gosnmp.SHA256 + case "SHA-384": + return gosnmp.SHA384 + case "SHA-512": + return gosnmp.SHA512 + default: + return gosnmp.SHA + } +} + +func mapPrivProtocol(p string) gosnmp.SnmpV3PrivProtocol { + switch p { + case "DES": + return gosnmp.DES + case "AES", "AES-128": + return gosnmp.AES + case "AES-192": + return gosnmp.AES192 + case "AES-256": + return gosnmp.AES256 + case "AES-192-C": + return gosnmp.AES192C + case "AES-256-C": + return gosnmp.AES256C + default: + return gosnmp.AES + } +} + +// snmpValueToString converts a gosnmp PDU value to a string. +func snmpValueToString(pdu gosnmp.SnmpPDU) string { + switch pdu.Type { + case gosnmp.Integer: + return fmt.Sprintf("%d", gosnmp.ToBigInt(pdu.Value).Int64()) + case gosnmp.OctetString: + b := pdu.Value.([]byte) + // Try UTF-8 first + for _, c := range b { + if c < 0x20 && c != '\n' && c != '\r' && c != '\t' { + // Non-printable - return hex + return formatHex(b) + } + } + return string(b) + case gosnmp.ObjectIdentifier: + return pdu.Value.(string) + case gosnmp.Counter32: + return fmt.Sprintf("%d", pdu.Value.(uint)) + case gosnmp.Counter64: + return fmt.Sprintf("%d", pdu.Value.(uint64)) + case gosnmp.Gauge32: + return fmt.Sprintf("%d", pdu.Value.(uint)) + case gosnmp.TimeTicks: + return fmt.Sprintf("%d", pdu.Value.(uint32)) + case gosnmp.IPAddress: + return pdu.Value.(string) + case gosnmp.Null, gosnmp.NoSuchObject, gosnmp.NoSuchInstance, gosnmp.EndOfMibView: + return "null" + case gosnmp.Opaque: + return formatHex(pdu.Value.([]byte)) + default: + return fmt.Sprintf("%v", pdu.Value) + } +} + +func formatHex(b []byte) string { + if len(b) == 0 { + return "" + } + parts := make([]string, len(b)) + for i, v := range b { + parts[i] = fmt.Sprintf("%02x", v) + } + result := "" + for i, p := range parts { + if i > 0 { + result += ":" + } + result += p + } + return result +} diff --git a/snmp_test.go b/snmp_test.go new file mode 100644 index 0000000..ba2985b --- /dev/null +++ b/snmp_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "testing" + + "github.com/gosnmp/gosnmp" +) + +func TestSnmpValueToString(t *testing.T) { + tests := []struct { + name string + pdu gosnmp.SnmpPDU + want string + }{ + { + name: "integer", + pdu: gosnmp.SnmpPDU{Type: gosnmp.Integer, Value: 42}, + want: "42", + }, + { + name: "string", + pdu: gosnmp.SnmpPDU{Type: gosnmp.OctetString, Value: []byte("Linux router")}, + want: "Linux router", + }, + { + name: "hex bytes", + pdu: gosnmp.SnmpPDU{Type: gosnmp.OctetString, Value: []byte{0x00, 0x1a, 0x2b}}, + want: "00:1a:2b", + }, + { + name: "oid", + pdu: gosnmp.SnmpPDU{Type: gosnmp.ObjectIdentifier, Value: "1.3.6.1.2.1.1.1.0"}, + want: "1.3.6.1.2.1.1.1.0", + }, + { + name: "counter32", + pdu: gosnmp.SnmpPDU{Type: gosnmp.Counter32, Value: uint(12345)}, + want: "12345", + }, + { + name: "counter64", + pdu: gosnmp.SnmpPDU{Type: gosnmp.Counter64, Value: uint64(9876543210)}, + want: "9876543210", + }, + { + name: "gauge32", + pdu: gosnmp.SnmpPDU{Type: gosnmp.Gauge32, Value: uint(999)}, + want: "999", + }, + { + name: "timeticks", + pdu: gosnmp.SnmpPDU{Type: gosnmp.TimeTicks, Value: uint32(12345678)}, + want: "12345678", + }, + { + name: "ip address", + pdu: gosnmp.SnmpPDU{Type: gosnmp.IPAddress, Value: "192.168.1.1"}, + want: "192.168.1.1", + }, + { + name: "null", + pdu: gosnmp.SnmpPDU{Type: gosnmp.Null, Value: nil}, + want: "null", + }, + { + name: "no such object", + pdu: gosnmp.SnmpPDU{Type: gosnmp.NoSuchObject, Value: nil}, + want: "null", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := snmpValueToString(tt.pdu) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestMapAuthProtocol(t *testing.T) { + tests := []struct { + input string + want gosnmp.SnmpV3AuthProtocol + }{ + {"MD5", gosnmp.MD5}, + {"SHA", gosnmp.SHA}, + {"SHA-256", gosnmp.SHA256}, + {"SHA-512", gosnmp.SHA512}, + {"unknown", gosnmp.SHA}, + } + for _, tt := range tests { + got := mapAuthProtocol(tt.input) + if got != tt.want { + t.Errorf("mapAuthProtocol(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestMapPrivProtocol(t *testing.T) { + tests := []struct { + input string + want gosnmp.SnmpV3PrivProtocol + }{ + {"DES", gosnmp.DES}, + {"AES", gosnmp.AES}, + {"AES-256", gosnmp.AES256}, + {"unknown", gosnmp.AES}, + } + for _, tt := range tests { + got := mapPrivProtocol(tt.input) + if got != tt.want { + t.Errorf("mapPrivProtocol(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestFormatHex(t *testing.T) { + tests := []struct { + input []byte + want string + }{ + {nil, ""}, + {[]byte{}, ""}, + {[]byte{0xAB}, "ab"}, + {[]byte{0x00, 0xFF, 0x1A}, "00:ff:1a"}, + } + for _, tt := range tests { + got := formatHex(tt.input) + if got != tt.want { + t.Errorf("formatHex(%v) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index a46c8ba..0000000 --- a/src/config.rs +++ /dev/null @@ -1,58 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Configuration received from the API -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AgentConfig { - pub version: String, - pub poll_interval_seconds: u64, - pub equipment: Vec, -} - -/// Configuration for a single piece of equipment -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct EquipmentConfig { - pub id: String, - pub name: String, - pub ip_address: String, - pub snmp: SnmpConfig, - pub poll_interval_seconds: u64, - pub sensors: Vec, - pub interfaces: Vec, -} - -/// SNMP configuration -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SnmpConfig { - pub enabled: bool, - pub version: String, - pub community: String, - pub port: u16, -} - -/// Sensor configuration -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SensorConfig { - pub id: String, - #[serde(rename = "type")] - pub sensor_type: String, - pub oid: String, - pub divisor: Option, - pub unit: Option, - pub metadata: Option, -} - -/// Interface configuration -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct InterfaceConfig { - pub id: String, - pub if_index: i32, - pub if_name: String, -} - -/// Heartbeat metadata sent to the API -#[derive(Debug, Serialize)] -pub struct HeartbeatMetadata { - pub version: String, - pub hostname: String, - pub uptime_seconds: u64, -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 9aeff5b..0000000 --- a/src/main.rs +++ /dev/null @@ -1,608 +0,0 @@ -mod mikrotik; -mod ping; -mod proto; -pub mod secret; -mod snmp; -mod ssh; -mod version; -mod websocket_client; - -use clap::Parser; -use std::env; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::watch; -use tokio::time::sleep; -use tracing_subscriber::EnvFilter; -use websocket_client::AgentClient; - -fn init_logger() { - // Use LOG_LEVEL env var (fall back to RUST_LOG for backwards compatibility) - let filter = env::var("LOG_LEVEL") - .or_else(|_| env::var("RUST_LOG")) - .unwrap_or_else(|_| "info".to_string()); - - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::new(&filter)) - .with_target(false) - .init(); -} - -/// Convert HTTP(S) URL to WebSocket URL -fn convert_to_websocket_url(url: &str) -> String { - if url.starts_with("http://") { - url.replace("http://", "ws://") - } else if url.starts_with("https://") { - url.replace("https://", "wss://") - } else if url.starts_with("ws://") || url.starts_with("wss://") { - url.to_string() - } else { - // Default to wss:// for bare domains - format!("wss://{}", url) - } -} - -#[derive(Parser)] -#[command(name = "towerops-agent")] -#[command(about = "Towerops remote SNMP polling agent", long_about = None)] -struct Args { - /// API URL (e.g., wss://towerops.net or https://towerops.net) - #[arg( - long, - env = "TOWEROPS_API_URL", - required_unless_present = "mikrotik_test" - )] - api_url: Option, - - /// Agent authentication token - #[arg( - long, - env = "TOWEROPS_AGENT_TOKEN", - required_unless_present = "mikrotik_test" - )] - token: Option, - - /// UDP port for SNMP trap listener - #[arg(long, env = "TRAP_PORT", default_value_t = snmp::DEFAULT_TRAP_PORT)] - trap_port: u16, - - /// Enable SNMP trap listener - #[arg(long, env = "TRAP_ENABLED", default_value_t = false)] - trap_enabled: bool, - - /// Run MikroTik API test instead of normal agent operation - #[arg(long)] - mikrotik_test: bool, - - /// MikroTik device IP address (for --mikrotik-test) - #[arg(long, required_if_eq("mikrotik_test", "true"))] - mikrotik_ip: Option, - - /// MikroTik username (for --mikrotik-test) - #[arg(long, default_value = "admin")] - mikrotik_user: String, - - /// MikroTik password (for --mikrotik-test) - #[arg(long, default_value = "")] - mikrotik_pass: String, - - /// MikroTik API port (for --mikrotik-test) - #[arg(long, default_value_t = 8729)] - mikrotik_port: u16, - - /// Use plain TCP instead of SSL (port 8728) - WARNING: credentials sent in plaintext - #[arg(long, default_value_t = false)] - mikrotik_plain: bool, - - /// Run SNMPv3 test instead of normal agent operation - #[arg(long)] - snmpv3_test: bool, - - /// Device IP address (for --snmpv3-test) - #[arg(long, required_if_eq("snmpv3_test", "true"))] - snmpv3_ip: Option, - - /// SNMPv3 username (for --snmpv3-test) - #[arg(long, default_value = "")] - snmpv3_user: String, - - /// SNMPv3 auth password (for --snmpv3-test) - #[arg(long, default_value = "")] - snmpv3_auth_pass: String, - - /// SNMPv3 priv password (for --snmpv3-test) - #[arg(long, default_value = "")] - snmpv3_priv_pass: String, -} - -fn install_crash_handler() { - unsafe { - // Install a signal handler for SIGSEGV/SIGBUS/SIGABRT so we get - // diagnostic output instead of a silent exit code 139. - extern "C" fn crash_handler(sig: libc::c_int) { - let name = match sig { - libc::SIGSEGV => "SIGSEGV (segmentation fault)", - libc::SIGBUS => "SIGBUS (bus error)", - libc::SIGABRT => "SIGABRT (abort)", - _ => "unknown signal", - }; - let msg = format!( - "\n*** FATAL: {} (signal {})\n\ - *** This is likely a bug in C FFI code (libnetsnmp).\n\ - *** SNMP process isolation should prevent most crashes from reaching here.\n\ - *** If this persists, check TOWEROPS_SNMP_ISOLATION env var.\n\ - *** Set RUST_BACKTRACE=1 for more info.\n", - name, sig - ); - unsafe { - libc::write(libc::STDERR_FILENO, msg.as_ptr() as _, msg.len()); - // Re-raise with default handler to get the correct exit code - libc::signal(sig, libc::SIG_DFL); - libc::raise(sig); - } - } - - libc::signal( - libc::SIGSEGV, - crash_handler as *const () as libc::sighandler_t, - ); - libc::signal( - libc::SIGBUS, - crash_handler as *const () as libc::sighandler_t, - ); - libc::signal( - libc::SIGABRT, - crash_handler as *const () as libc::sighandler_t, - ); - } -} - -fn main() { - install_crash_handler(); - - // Install ring as the default TLS crypto provider. Required because both - // ring and aws-lc-rs features are enabled transitively, so rustls can't - // auto-detect which one to use. - rustls::crypto::ring::default_provider() - .install_default() - .expect("Failed to install rustls CryptoProvider"); - - // Build Tokio runtime with larger stack size for SNMPv3 crypto operations - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(8 * 1024 * 1024) // 8MB stack (default is 2MB) - .build() - .expect("Failed to build Tokio runtime"); - - runtime.block_on(async_main()) -} - -async fn async_main() { - let args = Args::parse(); - - // Initialize logging - init_logger(); - - // Handle MikroTik test mode - if args.mikrotik_test { - run_mikrotik_test(&args).await; - return; - } - - // Handle SNMPv3 test mode - if args.snmpv3_test { - run_snmpv3_test(&args).await; - return; - } - - tracing::info!("Towerops agent starting"); - tracing::info!("SNMP isolation mode: {:?}", snmp::isolation_mode()); - - // Check for newer Docker image version - version::check_for_updates(); - - // Start SNMP trap listener if enabled - if args.trap_enabled { - let trap_port = args.trap_port; - tokio::spawn(async move { - let (trap_tx, mut trap_rx) = tokio::sync::mpsc::channel::(100); - let trap_listener = snmp::TrapListener::new(trap_port); - - // Spawn the listener - tokio::spawn(async move { - trap_listener.run(trap_tx).await; - }); - - // Log received traps - while let Some(trap) = trap_rx.recv().await { - tracing::info!("{}", trap); - } - }); - } - - // Convert HTTP(S) URL to WebSocket URL - let ws_url = convert_to_websocket_url(args.api_url.as_ref().unwrap()); - - tracing::info!("WebSocket URL: {}", ws_url); - - // Shared connection state - // Starts as false (not connected), updated when WebSocket connects/disconnects - let connected = Arc::new(AtomicBool::new(false)); - - // Create shutdown signal channel - let (shutdown_tx, shutdown_rx) = watch::channel(false); - - // Spawn signal handler for graceful shutdown - tokio::spawn(async move { - wait_for_shutdown_signal().await; - tracing::info!("Shutdown signal received, initiating graceful shutdown..."); - let _ = shutdown_tx.send(true); - }); - - // Retry loop with exponential backoff - let mut retry_delay = Duration::from_secs(1); - let max_retry_delay = Duration::from_secs(60); - let mut attempt = 0; - - let token = secret::SecretString::new(args.token.as_ref().unwrap().clone()); - - loop { - // Check if shutdown was requested - if *shutdown_rx.borrow() { - tracing::info!("Shutdown requested, exiting main loop"); - break; - } - - attempt += 1; - - if attempt > 1 { - tracing::info!( - "Retry attempt {} - waiting {} seconds before reconnecting", - attempt, - retry_delay.as_secs() - ); - sleep(retry_delay).await; - - // Exponential backoff: double the delay, capped at max - retry_delay = std::cmp::min(retry_delay * 2, max_retry_delay); - } - - // Connect to Towerops server via WebSocket - let mut client = match AgentClient::connect(&ws_url, &token).await { - Ok(client) => { - tracing::info!("Successfully connected to server"); - // Mark as connected for health check - connected.store(true, Ordering::Relaxed); - // Reset retry delay on successful connection - retry_delay = Duration::from_secs(1); - attempt = 0; - client - } - Err(e) => { - tracing::error!("Failed to connect to server: {}", e); - // Mark as disconnected for health check - connected.store(false, Ordering::Relaxed); - continue; - } - }; - - // Run the agent event loop with shutdown signal - match client.run(shutdown_rx.clone()).await { - Ok(()) => { - // Clean shutdown requested - if *shutdown_rx.borrow() { - tracing::info!("Agent shutdown complete"); - break; - } - } - Err(e) => { - tracing::error!("Agent disconnected: {}", e); - } - } - - // Mark as disconnected for health check - connected.store(false, Ordering::Relaxed); - // Loop will retry with backoff (unless shutdown was requested) - } - - tracing::info!("Towerops agent stopped"); -} - -/// Run SNMPv3 test -async fn run_snmpv3_test(args: &Args) { - use snmp::V3Config; - - let ip = args.snmpv3_ip.as_ref().expect("--snmpv3-ip required"); - let username = &args.snmpv3_user; - let auth_pass = &args.snmpv3_auth_pass; - let priv_pass = &args.snmpv3_priv_pass; - - println!("Testing SNMPv3 device at {}...", ip); - println!(" Username: {}", username); - println!( - " Auth Password: {}", - if auth_pass.is_empty() { - "(empty)" - } else { - "(set)" - } - ); - println!( - " Priv Password: {}", - if priv_pass.is_empty() { - "(empty)" - } else { - "(set)" - } - ); - - let v3_config = V3Config { - username: username.clone(), - auth_password: if !auth_pass.is_empty() { - Some(zeroize::Zeroizing::new(auth_pass.clone())) - } else { - None - }, - priv_password: if !priv_pass.is_empty() { - Some(zeroize::Zeroizing::new(priv_pass.clone())) - } else { - None - }, - auth_protocol: Some("SHA".to_string()), - priv_protocol: Some("AES".to_string()), - security_level: "authPriv".to_string(), - }; - - let snmp_client = snmp::SnmpClient::new(); - - println!("\nTest 1: Get sysDescr.0 (1.3.6.1.2.1.1.1.0)"); - match snmp_client - .get( - ip, - "", - "3", - 161, - "1.3.6.1.2.1.1.1.0", - Some(v3_config.clone()), - ) - .await - { - Ok(value) => println!(" Result: {:?}", value), - Err(e) => println!(" Error: {}", e), - } - - println!("\nTest 2: Get sysUpTime.0 (1.3.6.1.2.1.1.3.0)"); - match snmp_client - .get( - ip, - "", - "3", - 161, - "1.3.6.1.2.1.1.3.0", - Some(v3_config.clone()), - ) - .await - { - Ok(value) => println!(" Result: {:?}", value), - Err(e) => println!(" Error: {}", e), - } - - println!("\nTest 3: Walk interfaces (1.3.6.1.2.1.2.2.1)"); - match snmp_client - .walk( - ip, - "", - "3", - 161, - "1.3.6.1.2.1.2.2.1", - Some(v3_config.clone()), - ) - .await - { - Ok(values) => println!(" Found {} values", values.len()), - Err(e) => println!(" Error: {}", e), - } - - println!("\nTest complete."); -} - -/// Run MikroTik API test -async fn run_mikrotik_test(args: &Args) { - use mikrotik::MikrotikClient; - use secret::SecretString; - - let ip = args.mikrotik_ip.as_ref().expect("--mikrotik-ip required"); - let port = args.mikrotik_port; - let username = &args.mikrotik_user; - let password = SecretString::new(&args.mikrotik_pass); - - println!("Connecting to MikroTik device at {}:{}...", ip, port); - println!(" Username: {}", username); - println!( - " Password: {}", - if password.expose().is_empty() { - "(empty)" - } else { - "(set)" - } - ); - - // Quick TCP connectivity check first - print!(" Testing TCP connectivity... "); - match tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::net::TcpStream::connect(format!("{}:{}", ip, port)), - ) - .await - { - Ok(Ok(_)) => println!("OK"), - Ok(Err(e)) => { - println!("FAILED"); - eprintln!("\nTCP connection failed: {}", e); - eprintln!("Make sure the API-SSL service is enabled on the router:"); - eprintln!(" /ip service set api-ssl disabled=no"); - std::process::exit(1); - } - Err(_) => { - println!("TIMEOUT"); - eprintln!("\nTCP connection timed out after 5 seconds."); - eprintln!("Check network connectivity and firewall rules."); - std::process::exit(1); - } - } - - let use_plain = args.mikrotik_plain; - if use_plain { - print!(" Connecting (plain TCP) and authenticating... "); - } else { - print!(" Establishing TLS and authenticating... "); - } - - let connect_result = if use_plain { - MikrotikClient::connect_plain(ip, port, username, &password).await - } else { - MikrotikClient::connect(ip, port, username, &password).await - }; - - let mut client = match connect_result { - Ok(client) => { - println!("OK"); - client - } - Err(e) => { - println!("FAILED"); - eprintln!("\nError: {}", e); - eprintln!("\nTroubleshooting tips:"); - if use_plain { - eprintln!(" 1. Verify the API service (non-SSL) is enabled:"); - eprintln!(" /ip service set api disabled=no"); - } else { - eprintln!(" 1. Verify the API-SSL service is enabled:"); - eprintln!(" /ip service set api-ssl disabled=no"); - } - eprintln!(" 2. Verify the username/password are correct"); - eprintln!(" 3. Check if the user has API access permission:"); - eprintln!(" /user print"); - std::process::exit(1); - } - }; - - println!("\nRunning /system/identity/print..."); - match client.execute("/system/identity/print", &[]).await { - Ok(response) => { - if let Some(err) = response.error { - eprintln!("Command error: {}", err); - } else if let Some(sentence) = response.sentences.first() { - if let Some(name) = sentence.attributes.get("name") { - println!("Device identity: {}", name); - } else { - println!("Response: {:?}", sentence.attributes); - } - } else { - println!("No response data received"); - } - } - Err(e) => { - eprintln!("Command failed: {}", e); - } - } - - println!("\nRunning /system/resource/print..."); - match client.execute("/system/resource/print", &[]).await { - Ok(response) => { - if let Some(err) = response.error { - eprintln!("Command error: {}", err); - } else if let Some(sentence) = response.sentences.first() { - println!("System resources:"); - for (key, value) in &sentence.attributes { - println!(" {}: {}", key, value); - } - } else { - println!("No response data received"); - } - } - Err(e) => { - eprintln!("Command failed: {}", e); - } - } - - let _ = client.close().await; - println!("\nTest complete."); -} - -/// Wait for SIGTERM or SIGINT shutdown signal. -async fn wait_for_shutdown_signal() { - #[cfg(unix)] - { - use tokio::signal::unix::{signal, SignalKind}; - - let mut sigterm = - signal(SignalKind::terminate()).expect("Failed to register SIGTERM handler"); - let mut sigint = - signal(SignalKind::interrupt()).expect("Failed to register SIGINT handler"); - - tokio::select! { - _ = sigterm.recv() => { - tracing::info!("Received SIGTERM"); - } - _ = sigint.recv() => { - tracing::info!("Received SIGINT"); - } - } - } - - #[cfg(not(unix))] - { - // On non-Unix platforms, just wait for Ctrl+C - tokio::signal::ctrl_c() - .await - .expect("Failed to register Ctrl+C handler"); - tracing::info!("Received Ctrl+C"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_convert_http_to_websocket() { - assert_eq!( - convert_to_websocket_url("http://localhost:4000"), - "ws://localhost:4000" - ); - } - - #[test] - fn test_convert_https_to_websocket() { - assert_eq!( - convert_to_websocket_url("https://towerops.net"), - "wss://towerops.net" - ); - } - - #[test] - fn test_websocket_url_unchanged() { - assert_eq!( - convert_to_websocket_url("ws://localhost:4000"), - "ws://localhost:4000" - ); - assert_eq!( - convert_to_websocket_url("wss://towerops.net"), - "wss://towerops.net" - ); - } - - #[test] - fn test_bare_domain_gets_wss() { - assert_eq!( - convert_to_websocket_url("towerops.net"), - "wss://towerops.net" - ); - assert_eq!( - convert_to_websocket_url("localhost:4000"), - "wss://localhost:4000" - ); - } -} diff --git a/src/mikrotik/client.rs b/src/mikrotik/client.rs deleted file mode 100644 index 14ca515..0000000 --- a/src/mikrotik/client.rs +++ /dev/null @@ -1,646 +0,0 @@ -use super::types::{CommandResponse, MikrotikError, MikrotikResult, SecretString, Sentence}; -use std::collections::HashMap; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; -use tokio::net::TcpStream; -use tokio::time::{timeout, Duration}; -use tokio_rustls::client::TlsStream; -use tokio_rustls::rustls::ClientConfig; -use tokio_rustls::TlsConnector; - -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); -const READ_TIMEOUT: Duration = Duration::from_secs(30); - -/// Stream type that can be either TLS or plain TCP -enum MikrotikStream { - Tls(Box>), - Plain(TcpStream), -} - -impl AsyncRead for MikrotikStream { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - match self.get_mut() { - MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf), - MikrotikStream::Plain(s) => Pin::new(s).poll_read(cx, buf), - } - } -} - -impl AsyncWrite for MikrotikStream { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - match self.get_mut() { - MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf), - MikrotikStream::Plain(s) => Pin::new(s).poll_write(cx, buf), - } - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.get_mut() { - MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx), - MikrotikStream::Plain(s) => Pin::new(s).poll_flush(cx), - } - } - - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.get_mut() { - MikrotikStream::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx), - MikrotikStream::Plain(s) => Pin::new(s).poll_shutdown(cx), - } - } -} - -/// MikroTik RouterOS API client (supports both SSL and plain connections) -pub struct MikrotikClient { - stream: MikrotikStream, -} - -impl MikrotikClient { - /// Connect to a MikroTik device over SSL (port 8729) and authenticate - pub async fn connect( - ip: &str, - port: u16, - username: &str, - password: &SecretString, - ) -> MikrotikResult { - // Create TLS config that accepts any certificate (RouterOS uses self-signed) - let config = ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerifier)) - .with_no_client_auth(); - - let connector = TlsConnector::from(Arc::new(config)); - - // Connect TCP - let addr = format!("{}:{}", ip, port); - let tcp_stream = match timeout(CONNECTION_TIMEOUT, TcpStream::connect(&addr)).await { - Ok(Ok(stream)) => stream, - Ok(Err(e)) => { - return Err(MikrotikError::ConnectionFailed(format!( - "TCP connect to {} failed: {}", - addr, e - ))) - } - Err(_) => return Err(MikrotikError::Timeout), - }; - - // Upgrade to TLS - handle both IP addresses and hostnames - let domain = if let Ok(ip_addr) = ip.parse::() { - tokio_rustls::rustls::pki_types::ServerName::IpAddress( - tokio_rustls::rustls::pki_types::IpAddr::from(ip_addr), - ) - } else { - tokio_rustls::rustls::pki_types::ServerName::try_from(ip.to_string()).unwrap_or_else( - |_| { - tokio_rustls::rustls::pki_types::ServerName::try_from("mikrotik".to_string()) - .unwrap() - }, - ) - }; - let tls_stream = - match timeout(CONNECTION_TIMEOUT, connector.connect(domain, tcp_stream)).await { - Ok(Ok(stream)) => stream, - Ok(Err(e)) => { - return Err(MikrotikError::TlsError(format!( - "TLS handshake failed: {}", - e - ))) - } - Err(_) => return Err(MikrotikError::Timeout), - }; - - let mut client = Self { - stream: MikrotikStream::Tls(Box::new(tls_stream)), - }; - - // Authenticate - client.authenticate(username, password).await?; - - Ok(client) - } - - /// Connect to a MikroTik device over plain TCP (port 8728) and authenticate - /// WARNING: Credentials are sent in plaintext - use only for testing or on trusted networks - pub async fn connect_plain( - ip: &str, - port: u16, - username: &str, - password: &SecretString, - ) -> MikrotikResult { - // Connect TCP - let addr = format!("{}:{}", ip, port); - let tcp_stream = match timeout(CONNECTION_TIMEOUT, TcpStream::connect(&addr)).await { - Ok(Ok(stream)) => stream, - Ok(Err(e)) => { - return Err(MikrotikError::ConnectionFailed(format!( - "TCP connect to {} failed: {}", - addr, e - ))) - } - Err(_) => return Err(MikrotikError::Timeout), - }; - - let mut client = Self { - stream: MikrotikStream::Plain(tcp_stream), - }; - - // Authenticate - client.authenticate(username, password).await?; - - Ok(client) - } - - /// Authenticate with the RouterOS device - async fn authenticate( - &mut self, - username: &str, - password: &SecretString, - ) -> MikrotikResult<()> { - let response = self - .execute( - "/login", - &[("name", username), ("password", password.expose())], - ) - .await?; - - if let Some(err) = response.error { - return Err(MikrotikError::AuthenticationFailed(err)); - } - - Ok(()) - } - - /// Execute a command and return the response - pub async fn execute( - &mut self, - command: &str, - args: &[(&str, &str)], - ) -> MikrotikResult { - // Build and send the command - let mut words = vec![command.to_string()]; - for (key, value) in args { - // Query parameters use ? prefix, attributes use = prefix - if key.starts_with('?') || key.starts_with('.') { - // Query or special attribute - use as-is with = separator - words.push(format!("{}={}", key, value)); - } else { - // Regular attribute - prepend with = - words.push(format!("={}={}", key, value)); - } - } - - self.send_sentence(&words).await?; - - // Read response sentences until we get !done or !trap - self.read_response().await - } - - /// Send a sentence (list of words) to the device - async fn send_sentence(&mut self, words: &[String]) -> MikrotikResult<()> { - let mut buf = Vec::new(); - - for word in words { - encode_word(&mut buf, word); - } - // Empty word to terminate sentence - encode_word(&mut buf, ""); - - self.stream - .write_all(&buf) - .await - .map_err(|e| MikrotikError::ConnectionFailed(format!("Write failed: {}", e)))?; - - self.stream - .flush() - .await - .map_err(|e| MikrotikError::ConnectionFailed(format!("Flush failed: {}", e)))?; - - Ok(()) - } - - /// Read response sentences from the device - async fn read_response(&mut self) -> MikrotikResult { - let mut response = CommandResponse::default(); - - loop { - let sentence = self.read_sentence().await?; - - if sentence.is_empty() { - continue; - } - - let first_word = &sentence[0]; - - match first_word.as_str() { - "!done" => { - // Parse any attributes in the done sentence - let attrs = parse_attributes(&sentence[1..]); - if !attrs.is_empty() { - response.sentences.push(Sentence { - attributes: attrs, - tag: None, - }); - } - break; - } - "!trap" => { - // Error response - let attrs = parse_attributes(&sentence[1..]); - let error_msg = attrs - .get("message") - .cloned() - .unwrap_or_else(|| "Unknown error".to_string()); - response.error = Some(error_msg); - // Continue reading until !done - } - "!re" => { - // Result sentence - let attrs = parse_attributes(&sentence[1..]); - response.sentences.push(Sentence { - attributes: attrs, - tag: None, - }); - } - "!fatal" => { - let attrs = parse_attributes(&sentence[1..]); - let error_msg = attrs - .get("message") - .cloned() - .unwrap_or_else(|| "Fatal error".to_string()); - return Err(MikrotikError::CommandFailed(error_msg)); - } - _ => { - // Unknown sentence type, ignore - } - } - } - - Ok(response) - } - - /// Read a single sentence (list of words until empty word) - async fn read_sentence(&mut self) -> MikrotikResult> { - let mut words = Vec::new(); - - loop { - let word = match timeout(READ_TIMEOUT, self.read_word()).await { - Ok(Ok(w)) => w, - Ok(Err(e)) => return Err(e), - Err(_) => return Err(MikrotikError::Timeout), - }; - - if word.is_empty() { - break; - } - - words.push(word); - } - - Ok(words) - } - - /// Read a single word from the stream - async fn read_word(&mut self) -> MikrotikResult { - let len = self.read_length().await?; - - if len == 0 { - return Ok(String::new()); - } - - let mut buf = vec![0u8; len]; - self.stream - .read_exact(&mut buf) - .await - .map_err(|e| MikrotikError::ConnectionFailed(format!("Read word failed: {}", e)))?; - - String::from_utf8(buf) - .map_err(|e| MikrotikError::ProtocolError(format!("Invalid UTF-8: {}", e))) - } - - /// Read the length prefix of a word - async fn read_length(&mut self) -> MikrotikResult { - let mut first = [0u8; 1]; - self.stream - .read_exact(&mut first) - .await - .map_err(|e| MikrotikError::ConnectionFailed(format!("Read length failed: {}", e)))?; - - let first_byte = first[0]; - - // RouterOS API length encoding: - // 0x00-0x7F: 1 byte, value is the length - // 0x80-0xBF: 2 bytes, length = ((b1 & 0x3F) << 8) | b2 - // 0xC0-0xDF: 3 bytes, length = ((b1 & 0x1F) << 16) | (b2 << 8) | b3 - // 0xE0-0xEF: 4 bytes, length = ((b1 & 0x0F) << 24) | (b2 << 16) | (b3 << 8) | b4 - // 0xF0: 5 bytes, length = (b2 << 24) | (b3 << 16) | (b4 << 8) | b5 - - if first_byte < 0x80 { - Ok(first_byte as usize) - } else if first_byte < 0xC0 { - let mut buf = [0u8; 1]; - self.stream.read_exact(&mut buf).await.map_err(|e| { - MikrotikError::ConnectionFailed(format!("Read length failed: {}", e)) - })?; - Ok((((first_byte & 0x3F) as usize) << 8) | (buf[0] as usize)) - } else if first_byte < 0xE0 { - let mut buf = [0u8; 2]; - self.stream.read_exact(&mut buf).await.map_err(|e| { - MikrotikError::ConnectionFailed(format!("Read length failed: {}", e)) - })?; - Ok((((first_byte & 0x1F) as usize) << 16) - | ((buf[0] as usize) << 8) - | (buf[1] as usize)) - } else if first_byte < 0xF0 { - let mut buf = [0u8; 3]; - self.stream.read_exact(&mut buf).await.map_err(|e| { - MikrotikError::ConnectionFailed(format!("Read length failed: {}", e)) - })?; - Ok((((first_byte & 0x0F) as usize) << 24) - | ((buf[0] as usize) << 16) - | ((buf[1] as usize) << 8) - | (buf[2] as usize)) - } else { - let mut buf = [0u8; 4]; - self.stream.read_exact(&mut buf).await.map_err(|e| { - MikrotikError::ConnectionFailed(format!("Read length failed: {}", e)) - })?; - Ok(((buf[0] as usize) << 24) - | ((buf[1] as usize) << 16) - | ((buf[2] as usize) << 8) - | (buf[3] as usize)) - } - } - - /// Close the connection - pub async fn close(&mut self) -> MikrotikResult<()> { - // Send quit command - let _ = self.execute("/quit", &[]).await; - Ok(()) - } -} - -/// Encode a word with its length prefix -fn encode_word(buf: &mut Vec, word: &str) { - let len = word.len(); - encode_length(buf, len); - buf.extend_from_slice(word.as_bytes()); -} - -/// Encode a length using RouterOS API encoding -fn encode_length(buf: &mut Vec, len: usize) { - if len < 0x80 { - buf.push(len as u8); - } else if len < 0x4000 { - buf.push(((len >> 8) as u8) | 0x80); - buf.push((len & 0xFF) as u8); - } else if len < 0x200000 { - buf.push(((len >> 16) as u8) | 0xC0); - buf.push(((len >> 8) & 0xFF) as u8); - buf.push((len & 0xFF) as u8); - } else if len < 0x10000000 { - buf.push(((len >> 24) as u8) | 0xE0); - buf.push(((len >> 16) & 0xFF) as u8); - buf.push(((len >> 8) & 0xFF) as u8); - buf.push((len & 0xFF) as u8); - } else { - buf.push(0xF0); - buf.push(((len >> 24) & 0xFF) as u8); - buf.push(((len >> 16) & 0xFF) as u8); - buf.push(((len >> 8) & 0xFF) as u8); - buf.push((len & 0xFF) as u8); - } -} - -/// Parse attributes from response words (=key=value format) -fn parse_attributes(words: &[String]) -> HashMap { - let mut attrs = HashMap::new(); - - for word in words { - if let Some(kv) = word.strip_prefix('=') { - if let Some((key, value)) = kv.split_once('=') { - attrs.insert(key.to_string(), value.to_string()); - } - } - } - - attrs -} - -/// Custom certificate verifier that accepts any certificate -/// RouterOS devices use self-signed certificates -#[derive(Debug)] -struct NoVerifier; - -impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoVerifier { - fn verify_server_cert( - &self, - _end_entity: &tokio_rustls::rustls::pki_types::CertificateDer<'_>, - _intermediates: &[tokio_rustls::rustls::pki_types::CertificateDer<'_>], - _server_name: &tokio_rustls::rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: tokio_rustls::rustls::pki_types::UnixTime, - ) -> Result - { - Ok(tokio_rustls::rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>, - _dss: &tokio_rustls::rustls::DigitallySignedStruct, - ) -> Result< - tokio_rustls::rustls::client::danger::HandshakeSignatureValid, - tokio_rustls::rustls::Error, - > { - Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>, - _dss: &tokio_rustls::rustls::DigitallySignedStruct, - ) -> Result< - tokio_rustls::rustls::client::danger::HandshakeSignatureValid, - tokio_rustls::rustls::Error, - > { - Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256, - tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA384, - tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA512, - tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256, - tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA384, - tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA512, - tokio_rustls::rustls::SignatureScheme::ED25519, - ] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Tests for encode_length - the RouterOS API length encoding - - #[test] - fn test_encode_length_single_byte() { - // Lengths 0-127 use single byte - let mut buf = Vec::new(); - encode_length(&mut buf, 0); - assert_eq!(buf, vec![0x00]); - - let mut buf = Vec::new(); - encode_length(&mut buf, 1); - assert_eq!(buf, vec![0x01]); - - let mut buf = Vec::new(); - encode_length(&mut buf, 127); - assert_eq!(buf, vec![0x7F]); - } - - #[test] - fn test_encode_length_two_bytes() { - // Lengths 128-16383 use two bytes (0x80-0xBF prefix) - let mut buf = Vec::new(); - encode_length(&mut buf, 128); - assert_eq!(buf, vec![0x80, 0x80]); - - let mut buf = Vec::new(); - encode_length(&mut buf, 255); - assert_eq!(buf, vec![0x80, 0xFF]); - - let mut buf = Vec::new(); - encode_length(&mut buf, 256); - assert_eq!(buf, vec![0x81, 0x00]); - - let mut buf = Vec::new(); - encode_length(&mut buf, 16383); - assert_eq!(buf, vec![0xBF, 0xFF]); - } - - #[test] - fn test_encode_length_three_bytes() { - // Lengths 16384-2097151 use three bytes (0xC0-0xDF prefix) - let mut buf = Vec::new(); - encode_length(&mut buf, 16384); - assert_eq!(buf, vec![0xC0, 0x40, 0x00]); - - let mut buf = Vec::new(); - encode_length(&mut buf, 2097151); - assert_eq!(buf, vec![0xDF, 0xFF, 0xFF]); - } - - #[test] - fn test_encode_length_four_bytes() { - // Lengths 2097152-268435455 use four bytes (0xE0-0xEF prefix) - let mut buf = Vec::new(); - encode_length(&mut buf, 2097152); - assert_eq!(buf, vec![0xE0, 0x20, 0x00, 0x00]); - } - - #[test] - fn test_encode_length_five_bytes() { - // Lengths >= 268435456 use five bytes (0xF0 prefix) - let mut buf = Vec::new(); - encode_length(&mut buf, 268435456); - assert_eq!(buf, vec![0xF0, 0x10, 0x00, 0x00, 0x00]); - } - - // Tests for encode_word - - #[test] - fn test_encode_word_empty() { - let mut buf = Vec::new(); - encode_word(&mut buf, ""); - assert_eq!(buf, vec![0x00]); // Just length byte of 0 - } - - #[test] - fn test_encode_word_simple() { - let mut buf = Vec::new(); - encode_word(&mut buf, "/login"); - assert_eq!(buf, vec![0x06, b'/', b'l', b'o', b'g', b'i', b'n']); - } - - #[test] - fn test_encode_word_with_argument() { - let mut buf = Vec::new(); - encode_word(&mut buf, "=name=admin"); - assert_eq!( - buf, - vec![0x0B, b'=', b'n', b'a', b'm', b'e', b'=', b'a', b'd', b'm', b'i', b'n'] - ); - } - - // Tests for parse_attributes - - #[test] - fn test_parse_attributes_empty() { - let words: Vec = vec![]; - let attrs = parse_attributes(&words); - assert!(attrs.is_empty()); - } - - #[test] - fn test_parse_attributes_single() { - let words = vec!["=name=MyRouter".to_string()]; - let attrs = parse_attributes(&words); - assert_eq!(attrs.get("name"), Some(&"MyRouter".to_string())); - } - - #[test] - fn test_parse_attributes_multiple() { - let words = vec![ - "=name=MyRouter".to_string(), - "=model=RB450Gx4".to_string(), - "=version=7.10".to_string(), - ]; - let attrs = parse_attributes(&words); - assert_eq!(attrs.get("name"), Some(&"MyRouter".to_string())); - assert_eq!(attrs.get("model"), Some(&"RB450Gx4".to_string())); - assert_eq!(attrs.get("version"), Some(&"7.10".to_string())); - } - - #[test] - fn test_parse_attributes_with_equals_in_value() { - // Value contains equals sign - split_once preserves the rest - let words = vec!["=comment=a=b=c".to_string()]; - let attrs = parse_attributes(&words); - assert_eq!(attrs.get("comment"), Some(&"a=b=c".to_string())); - } - - #[test] - fn test_parse_attributes_ignores_non_attribute() { - let words = vec![ - "!re".to_string(), // Not an attribute - "=name=test".to_string(), - ]; - let attrs = parse_attributes(&words); - assert_eq!(attrs.len(), 1); - assert_eq!(attrs.get("name"), Some(&"test".to_string())); - } - - #[test] - fn test_parse_attributes_empty_value() { - let words = vec!["=disabled=".to_string()]; - let attrs = parse_attributes(&words); - assert_eq!(attrs.get("disabled"), Some(&"".to_string())); - } -} diff --git a/src/mikrotik/mod.rs b/src/mikrotik/mod.rs deleted file mode 100644 index ab91e60..0000000 --- a/src/mikrotik/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod client; -mod types; - -pub use client::MikrotikClient; -#[allow(unused_imports)] // These will be used when integrating with websocket_client -pub use types::{CommandResponse, MikrotikError, MikrotikResult, SecretString, Sentence}; diff --git a/src/mikrotik/types.rs b/src/mikrotik/types.rs deleted file mode 100644 index 38b406d..0000000 --- a/src/mikrotik/types.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::collections::HashMap; - -pub use crate::secret::SecretString; - -/// Error types for MikroTik operations -#[derive(Debug)] -pub enum MikrotikError { - ConnectionFailed(String), - AuthenticationFailed(String), - CommandFailed(String), - Timeout, - TlsError(String), - ProtocolError(String), -} - -impl std::fmt::Display for MikrotikError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg), - Self::AuthenticationFailed(msg) => write!(f, "Authentication failed: {}", msg), - Self::CommandFailed(msg) => write!(f, "Command failed: {}", msg), - Self::Timeout => write!(f, "Operation timed out"), - Self::TlsError(msg) => write!(f, "TLS error: {}", msg), - Self::ProtocolError(msg) => write!(f, "Protocol error: {}", msg), - } - } -} - -impl std::error::Error for MikrotikError {} - -pub type MikrotikResult = Result; - -/// A sentence from a RouterOS API response (key-value pairs) -#[derive(Debug, Clone, Default)] -pub struct Sentence { - pub attributes: HashMap, - #[allow(dead_code)] // Reserved for future use with tagged API commands - pub tag: Option, -} - -/// Response from a RouterOS API command -#[derive(Debug, Clone, Default)] -pub struct CommandResponse { - pub sentences: Vec, - pub error: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_mikrotik_error_display_connection_failed() { - let err = MikrotikError::ConnectionFailed("refused".to_string()); - assert_eq!(format!("{}", err), "Connection failed: refused"); - } - - #[test] - fn test_mikrotik_error_display_auth_failed() { - let err = MikrotikError::AuthenticationFailed("bad password".to_string()); - assert_eq!(format!("{}", err), "Authentication failed: bad password"); - } - - #[test] - fn test_mikrotik_error_display_command_failed() { - let err = MikrotikError::CommandFailed("no such command".to_string()); - assert_eq!(format!("{}", err), "Command failed: no such command"); - } - - #[test] - fn test_mikrotik_error_display_timeout() { - let err = MikrotikError::Timeout; - assert_eq!(format!("{}", err), "Operation timed out"); - } - - #[test] - fn test_mikrotik_error_display_tls_error() { - let err = MikrotikError::TlsError("certificate invalid".to_string()); - assert_eq!(format!("{}", err), "TLS error: certificate invalid"); - } - - #[test] - fn test_mikrotik_error_display_protocol_error() { - let err = MikrotikError::ProtocolError("unexpected response".to_string()); - assert_eq!(format!("{}", err), "Protocol error: unexpected response"); - } - - #[test] - fn test_mikrotik_error_is_error_trait() { - let err: &dyn std::error::Error = &MikrotikError::Timeout; - assert_eq!(format!("{}", err), "Operation timed out"); - } - - #[test] - fn test_sentence_default() { - let sentence = Sentence::default(); - assert!(sentence.attributes.is_empty()); - assert!(sentence.tag.is_none()); - } - - #[test] - fn test_command_response_default() { - let response = CommandResponse::default(); - assert!(response.sentences.is_empty()); - assert!(response.error.is_none()); - } -} diff --git a/src/ping.rs b/src/ping.rs deleted file mode 100644 index f24fd2b..0000000 --- a/src/ping.rs +++ /dev/null @@ -1,113 +0,0 @@ -use anyhow::{Context, Result}; -use std::net::IpAddr; -use std::time::Duration; -use tokio::process::Command; -use tokio::time::timeout; - -/// Ping a device using command-line ping and return response time in milliseconds. -/// -/// Uses the system ping command (from iputils package) which has setuid root -/// and doesn't require CAP_NET_RAW capability. -/// -/// Returns Ok(response_time_ms) on success, Err on failure. -pub async fn ping_device(ip_address: &str, timeout_ms: u64) -> Result { - let ip: IpAddr = ip_address - .parse() - .context(format!("Invalid IP address: {}", ip_address))?; - - // Determine ping command based on IP version - let ping_cmd = match ip { - IpAddr::V4(_) => "ping", - IpAddr::V6(_) => "ping6", - }; - - // Convert timeout to seconds (ping uses seconds, min 1) - let timeout_secs = std::cmp::max(1, timeout_ms / 1000); - - // Execute ping command: -c 1 (count=1), -W timeout (wait time) - // Output format: time=X.XX ms - let output = timeout( - Duration::from_millis(timeout_ms + 1000), // Add 1s buffer to tokio timeout - Command::new(ping_cmd) - .arg("-c") - .arg("1") - .arg("-W") - .arg(timeout_secs.to_string()) - .arg(ip_address) - .output(), - ) - .await - .context("Ping command timed out")? - .context("Failed to execute ping command")?; - - // Check if ping succeeded (exit code 0) - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Ping failed: {}", stderr.trim())); - } - - // Parse response time from stdout - // Example output: "64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=12.3 ms" - let stdout = String::from_utf8_lossy(&output.stdout); - let response_time = - parse_ping_time(&stdout).context("Failed to parse ping response time from output")?; - - Ok(response_time) -} - -/// Parse the response time from ping output. -/// -/// Looks for "time=X.XX ms" or "time=X.XX" pattern in the output. -fn parse_ping_time(output: &str) -> Result { - for line in output.lines() { - if let Some(time_start) = line.find("time=") { - let time_str = &line[time_start + 5..]; // Skip "time=" - - // Extract number before " ms" or end of string - let time_end = time_str - .find(" ms") - .or_else(|| time_str.find(' ')) - .unwrap_or(time_str.len()); - - let time_value = &time_str[..time_end]; - - return time_value - .parse::() - .context(format!("Invalid time value: {}", time_value)); - } - } - - Err(anyhow::anyhow!("No time= field found in ping output")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_ping_time() { - let output = "64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=12.3 ms"; - assert_eq!(parse_ping_time(output).unwrap(), 12.3); - - let output = "64 bytes from localhost: icmp_seq=1 ttl=64 time=0.123 ms"; - assert_eq!(parse_ping_time(output).unwrap(), 0.123); - } - - #[tokio::test] - #[ignore] // Requires network access - async fn test_ping_localhost() { - let result = ping_device("127.0.0.1", 5000).await; - assert!(result.is_ok()); - let response_time = result.unwrap(); - assert!(response_time > 0.0); - assert!(response_time < 100.0); // Localhost should be fast - } - - #[tokio::test] - #[ignore] // Requires network access - async fn test_ping_timeout() { - // Try to ping a non-routable address with short timeout - let result = ping_device("192.0.2.1", 1000).await; - assert!(result.is_err()); - } -} diff --git a/src/proto.rs b/src/proto.rs deleted file mode 100644 index dbbdda3..0000000 --- a/src/proto.rs +++ /dev/null @@ -1,5 +0,0 @@ -// Generated protobuf code -#[allow(dead_code)] -pub mod agent { - include!(concat!(env!("OUT_DIR"), "/towerops.agent.rs")); -} diff --git a/src/secret.rs b/src/secret.rs deleted file mode 100644 index 90f36a3..0000000 --- a/src/secret.rs +++ /dev/null @@ -1,87 +0,0 @@ -use zeroize::Zeroize; - -/// A wrapper for sensitive strings (passwords, tokens) that prevents accidental logging. -/// - Debug and Display show "[REDACTED]" instead of the actual value -/// - The inner value is zeroized on drop using volatile writes (cannot be optimized away) -#[derive(Clone)] -pub struct SecretString(String); - -impl SecretString { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } - - /// Access the secret value. Use sparingly and never log the result. - pub fn expose(&self) -> &str { - &self.0 - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl std::fmt::Debug for SecretString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "[REDACTED]") - } -} - -impl std::fmt::Display for SecretString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "[REDACTED]") - } -} - -impl Drop for SecretString { - fn drop(&mut self) { - self.0.zeroize(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_secret_string_expose() { - let secret = SecretString::new("my_password"); - assert_eq!(secret.expose(), "my_password"); - } - - #[test] - fn test_secret_string_debug_is_redacted() { - let secret = SecretString::new("my_password"); - let debug_output = format!("{:?}", secret); - assert_eq!(debug_output, "[REDACTED]"); - assert!(!debug_output.contains("my_password")); - } - - #[test] - fn test_secret_string_display_is_redacted() { - let secret = SecretString::new("my_password"); - let display_output = format!("{}", secret); - assert_eq!(display_output, "[REDACTED]"); - assert!(!display_output.contains("my_password")); - } - - #[test] - fn test_secret_string_clone() { - let secret = SecretString::new("my_password"); - let cloned = secret.clone(); - assert_eq!(cloned.expose(), "my_password"); - } - - #[test] - fn test_secret_string_empty() { - let secret = SecretString::new(""); - assert!(secret.is_empty()); - assert!(secret.expose().is_empty()); - } - - #[test] - fn test_secret_string_is_empty() { - let secret = SecretString::new("not_empty"); - assert!(!secret.is_empty()); - } -} diff --git a/src/snmp/client.rs b/src/snmp/client.rs deleted file mode 100644 index 85bcf4f..0000000 --- a/src/snmp/client.rs +++ /dev/null @@ -1,1305 +0,0 @@ -use super::types::{SnmpError, SnmpResult, SnmpValue}; -use netsnmp_sys::*; -use std::ffi::{c_char, CStr, CString}; -use std::ptr; -use std::sync::OnceLock; -use zeroize::{Zeroize, Zeroizing}; - -type SecretString = Zeroizing; - -/// Controls whether SNMP operations run in forked child processes (crash isolation) -/// or directly in the current process. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IsolationMode { - /// Each SNMP operation forks a child process. A crash in libnetsnmp - /// only kills the child; the parent agent survives. - Fork, - /// SNMP operations run directly (legacy behavior). Use for debugging. - Direct, -} - -/// Returns the configured isolation mode. Reads `TOWEROPS_SNMP_ISOLATION` -/// once; defaults to `Fork`. -pub fn isolation_mode() -> IsolationMode { - static MODE: OnceLock = OnceLock::new(); - *MODE.get_or_init( - || match std::env::var("TOWEROPS_SNMP_ISOLATION").as_deref() { - Ok("direct") => IsolationMode::Direct, - _ => IsolationMode::Fork, - }, - ) -} - -// FFI structs matching C definitions in snmp_helper.h - -#[repr(C)] -struct SnmpIsolatedGetResult { - status: i32, - value_type: i32, - child_signal: i32, - error_buf: [c_char; 512], - value_buf: [u8; 1024], -} - -#[repr(C)] -struct SnmpIsolatedWalkHeader { - status: i32, - num_results: u32, - child_signal: i32, - error_buf: [c_char; 512], -} - -#[cfg(not(test))] -const SNMP_TIMEOUT_US: i64 = 10_000_000; // 10 seconds -#[cfg(not(test))] -const SNMP_RETRIES: i32 = 2; - -// Use short timeouts in tests - just enough to prove the operation fails -#[cfg(test)] -const SNMP_TIMEOUT_US: i64 = 200_000; // 200ms -#[cfg(test)] -const SNMP_RETRIES: i32 = 0; - -// C structs and functions -#[repr(C)] -#[derive(Clone)] -struct SnmpWalkResult { - oid: [u8; 256], - value: [u8; 1024], - value_len: usize, - value_type: i32, -} - -#[repr(C)] -struct SnmpV3ConfigC { - username: *const c_char, - auth_password: *const c_char, - priv_password: *const c_char, - auth_protocol: *const c_char, - priv_protocol: *const c_char, - security_level: *const c_char, -} - -extern "C" { - fn snmp_init_library() -> i32; - fn snmp_open_session( - ip_address: *const c_char, - port: u16, - community: *const c_char, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: *const SnmpV3ConfigC, - error_buf: *mut c_char, - error_buf_len: usize, - ) -> *mut std::ffi::c_void; - fn snmp_close_session(sess_handle: *mut std::ffi::c_void); - fn snmp_get( - sess_handle: *mut std::ffi::c_void, - oid_str: *const c_char, - value_buf: *mut std::ffi::c_void, - value_buf_len: usize, - value_type: *mut i32, - error_buf: *mut c_char, - error_buf_len: usize, - ) -> i32; - fn snmp_walk( - sess_handle: *mut std::ffi::c_void, - oid_str: *const c_char, - results: *mut SnmpWalkResult, - max_results: usize, - num_results: *mut usize, - error_buf: *mut c_char, - error_buf_len: usize, - ) -> i32; - fn snmp_get_isolated( - ip_address: *const c_char, - port: u16, - community: *const c_char, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: *const SnmpV3ConfigC, - oid_str: *const c_char, - result: *mut SnmpIsolatedGetResult, - ); - fn snmp_walk_isolated( - ip_address: *const c_char, - port: u16, - community: *const c_char, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: *const SnmpV3ConfigC, - oid_str: *const c_char, - header: *mut SnmpIsolatedWalkHeader, - results: *mut SnmpWalkResult, - max_results: usize, - ); -} - -#[cfg(test)] -extern "C" { - fn snmp_test_crash_in_child(child_signal: *mut i32) -> i32; -} - -/// SNMPv3 configuration -#[derive(Clone)] -pub struct V3Config { - pub username: String, - pub auth_password: Option, - pub priv_password: Option, - pub auth_protocol: Option, - pub priv_protocol: Option, - pub security_level: String, -} - -impl std::fmt::Debug for V3Config { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("V3Config") - .field("username", &self.username) - .field( - "auth_password", - &self.auth_password.as_ref().map(|_| "[REDACTED]"), - ) - .field( - "priv_password", - &self.priv_password.as_ref().map(|_| "[REDACTED]"), - ) - .field("auth_protocol", &self.auth_protocol) - .field("priv_protocol", &self.priv_protocol) - .field("security_level", &self.security_level) - .finish() - } -} - -/// SNMP client for polling devices using libnetsnmp -#[derive(Debug, Clone, Copy)] -pub struct SnmpClient; - -impl SnmpClient { - pub fn new() -> Self { - // In fork mode, skip init - each child initializes its own copy. - // In direct mode, initialize once in the parent process. - if isolation_mode() == IsolationMode::Direct { - unsafe { - snmp_init_library(); - } - } - Self - } - - /// Parse SNMP version string to integer. - fn parse_version(version: &str) -> SnmpResult { - match version { - "1" | "v1" => Ok(1), - "2c" | "v2c" | "2" => Ok(2), - "3" | "v3" => Ok(3), - _ => Err(SnmpError::RequestFailed(format!( - "Unsupported SNMP version: {}", - version - ))), - } - } - - /// Perform an SNMP GET operation - pub async fn get( - &self, - ip_address: &str, - community: &str, - version: &str, - port: u16, - oid: &str, - v3_config: Option, - ) -> SnmpResult { - let ip_address = ip_address.to_string(); - let community = community.to_string(); - let version = version.to_string(); - let oid = oid.to_string(); - - tokio::task::spawn_blocking(move || { - if isolation_mode() == IsolationMode::Fork { - let version_num = Self::parse_version(&version)?; - get_isolated( - &ip_address, - port, - &community, - version_num, - SNMP_TIMEOUT_US, - SNMP_RETRIES, - v3_config.as_ref(), - &oid, - ) - } else { - let session = SnmpSession::new(&ip_address, port, &community, &version, v3_config)?; - session.get(&oid) - } - }) - .await - .map_err(|e| SnmpError::RequestFailed(format!("Task join error: {}", e)))? - } - - /// Perform an SNMP WALK operation - pub async fn walk( - &self, - ip_address: &str, - community: &str, - version: &str, - port: u16, - oid: &str, - v3_config: Option, - ) -> SnmpResult> { - let ip_address = ip_address.to_string(); - let community = community.to_string(); - let version = version.to_string(); - let oid = oid.to_string(); - - tokio::task::spawn_blocking(move || { - if isolation_mode() == IsolationMode::Fork { - let version_num = Self::parse_version(&version)?; - walk_isolated( - &ip_address, - port, - &community, - version_num, - SNMP_TIMEOUT_US, - SNMP_RETRIES, - v3_config.as_ref(), - &oid, - ) - } else { - let session = SnmpSession::new(&ip_address, port, &community, &version, v3_config)?; - session.walk(&oid) - } - }) - .await - .map_err(|e| SnmpError::RequestFailed(format!("Task join error: {}", e)))? - } -} - -impl Default for SnmpClient { - fn default() -> Self { - Self::new() - } -} - -/// RAII wrapper for SNMP session -struct SnmpSession { - sess_handle: *mut std::ffi::c_void, -} - -impl SnmpSession { - fn new( - ip_address: &str, - port: u16, - community: &str, - version: &str, - v3_config: Option, - ) -> SnmpResult { - tracing::debug!( - "Creating SNMP session: ip={}, port={}, version={}", - ip_address, - port, - version - ); - - // Parse SNMP version - let version_num = match version { - "1" | "v1" => 1, - "2c" | "v2c" | "2" => 2, - "3" | "v3" => 3, - _ => { - return Err(SnmpError::RequestFailed(format!( - "Unsupported SNMP version: {}", - version - ))) - } - }; - - unsafe { - let ip_cstr = CString::new(ip_address) - .map_err(|_| SnmpError::RequestFailed("Invalid IP address".into()))?; - let comm_cstr = CString::new(community) - .map_err(|_| SnmpError::RequestFailed("Invalid community string".into()))?; - - // Prepare SNMPv3 config if needed - let (v3_c_config, _v3_strings) = if let Some(ref v3) = v3_config { - let username_cstr = CString::new(v3.username.as_str()) - .map_err(|_| SnmpError::RequestFailed("Invalid username".into()))?; - let auth_pass_cstr = v3 - .auth_password - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid auth password".into()))?; - let priv_pass_cstr = v3 - .priv_password - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid priv password".into()))?; - let auth_proto_cstr = v3 - .auth_protocol - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid auth protocol".into()))?; - let priv_proto_cstr = v3 - .priv_protocol - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid priv protocol".into()))?; - let sec_level_cstr = CString::new(v3.security_level.as_str()) - .map_err(|_| SnmpError::RequestFailed("Invalid security level".into()))?; - - let config = SnmpV3ConfigC { - username: username_cstr.as_ptr(), - auth_password: auth_pass_cstr - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - priv_password: priv_pass_cstr - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - auth_protocol: auth_proto_cstr - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - priv_protocol: priv_proto_cstr - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - security_level: sec_level_cstr.as_ptr(), - }; - - ( - Some(config), - Some(( - username_cstr, - auth_pass_cstr, - priv_pass_cstr, - auth_proto_cstr, - priv_proto_cstr, - sec_level_cstr, - )), - ) - } else { - (None, None) - }; - - let mut error_buf = [0 as c_char; 512]; - let sess_handle = snmp_open_session( - ip_cstr.as_ptr(), - port, - comm_cstr.as_ptr(), - version_num, - SNMP_TIMEOUT_US, - SNMP_RETRIES, - v3_c_config - .as_ref() - .map(|c| c as *const _) - .unwrap_or(ptr::null()), - error_buf.as_mut_ptr(), - error_buf.len(), - ); - - // Zeroize sensitive data - drop(comm_cstr); - drop(_v3_strings); // Drops all v3 CStrings - if !community.is_empty() { - let mut community_copy = community.to_string(); - community_copy.zeroize(); - } - - if sess_handle.is_null() { - let err_msg = CStr::from_ptr(error_buf.as_ptr()) - .to_string_lossy() - .to_string(); - tracing::error!("SNMP session open failed: {}", err_msg); - - return Err( - if err_msg.contains("Unknown host") || err_msg.contains("Connection refused") { - SnmpError::NetworkUnreachable - } else { - SnmpError::RequestFailed(err_msg) - }, - ); - } - - tracing::debug!("SNMP session opened successfully: {:?}", sess_handle); - Ok(Self { sess_handle }) - } - } - - fn get(&self, oid: &str) -> SnmpResult { - unsafe { - let oid_cstr = CString::new(oid) - .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", oid)))?; - - let mut value_buf = [0u8; 1024]; - let mut value_type: i32 = 0; - let mut error_buf = [0 as c_char; 512]; - - let result = snmp_get( - self.sess_handle, - oid_cstr.as_ptr(), - value_buf.as_mut_ptr() as *mut _, - value_buf.len(), - &mut value_type, - error_buf.as_mut_ptr(), - error_buf.len(), - ); - - if result < 0 { - let err_msg = CStr::from_ptr(error_buf.as_ptr()) - .to_string_lossy() - .to_string(); - - if err_msg.contains("timeout") { - return Err(SnmpError::Timeout); - } else if err_msg.contains("Failed to parse OID") { - return Err(SnmpError::InvalidOid(err_msg)); - } else { - return Err(SnmpError::RequestFailed(err_msg)); - } - } - - // Parse value based on type - let value_len = result as usize; - match value_type as u8 { - ASN_OCTET_STR => { - // Try to convert to UTF-8 string first - match String::from_utf8(value_buf[..value_len].to_vec()) { - Ok(s) => Ok(SnmpValue::String(s)), - Err(_) => Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())), - } - } - ASN_OPAQUE => Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())), - ASN_IPADDRESS => { - // IP addresses are 4 bytes - convert to dotted notation - if value_len == 4 { - Ok(SnmpValue::IpAddress(format!( - "{}.{}.{}.{}", - value_buf[0], value_buf[1], value_buf[2], value_buf[3] - ))) - } else { - Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())) - } - } - ASN_OBJECT_ID => { - // Object IDs are returned as strings in dotted notation from C - match String::from_utf8(value_buf[..value_len].to_vec()) { - Ok(s) => Ok(SnmpValue::Oid(s)), - Err(_) => Ok(SnmpValue::OctetString(value_buf[..value_len].to_vec())), - } - } - ASN_INTEGER | ASN_COUNTER | ASN_GAUGE | ASN_TIMETICKS | ASN_UINTEGER => { - if value_len >= std::mem::size_of::() { - // Use unaligned read to avoid alignment issues from C - let value = (value_buf.as_ptr() as *const i64).read_unaligned(); - Ok(SnmpValue::Integer(value)) - } else { - Err(SnmpError::RequestFailed("Invalid integer size".into())) - } - } - ASN_COUNTER64 => { - if value_len >= 8 { - let high = u32::from_ne_bytes([ - value_buf[0], - value_buf[1], - value_buf[2], - value_buf[3], - ]); - let low = u32::from_ne_bytes([ - value_buf[4], - value_buf[5], - value_buf[6], - value_buf[7], - ]); - Ok(SnmpValue::Counter64((high as u64) << 32 | low as u64)) - } else { - Err(SnmpError::RequestFailed("Invalid counter64 size".into())) - } - } - _ => Err(SnmpError::RequestFailed(format!( - "Unsupported type: {}", - value_type - ))), - } - } - } - - fn walk(&self, start_oid: &str) -> SnmpResult> { - unsafe { - let oid_cstr = CString::new(start_oid) - .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", start_oid)))?; - - // Allocate buffer for results (max 10000 entries) - const MAX_RESULTS: usize = 10000; - let mut results_buf: Vec = vec![ - SnmpWalkResult { - oid: [0; 256], - value: [0; 1024], - value_len: 0, - value_type: 0, - }; - MAX_RESULTS - ]; - - let mut num_results: usize = 0; - let mut error_buf = [0 as c_char; 512]; - - let result = snmp_walk( - self.sess_handle, - oid_cstr.as_ptr(), - results_buf.as_mut_ptr(), - MAX_RESULTS, - &mut num_results, - error_buf.as_mut_ptr(), - error_buf.len(), - ); - - if result < 0 { - let err_msg = CStr::from_ptr(error_buf.as_ptr()) - .to_string_lossy() - .to_string(); - if err_msg.contains("Failed to parse OID") { - return Err(SnmpError::InvalidOid(err_msg)); - } - return Err(SnmpError::RequestFailed(err_msg)); - } - - // Convert C results to Rust - let mut parsed_results = Vec::with_capacity(num_results); - for res in results_buf.iter().take(num_results) { - // Parse OID string - let oid_str = CStr::from_ptr(res.oid.as_ptr() as *const c_char) - .to_string_lossy() - .to_string(); - - // Parse value - if res.value_len > 0 { - let value = match res.value_type as u8 { - ASN_OCTET_STR => { - // Try UTF-8 conversion first - match String::from_utf8(res.value[..res.value_len].to_vec()) { - Ok(s) => SnmpValue::String(s), - Err(_) => { - SnmpValue::OctetString(res.value[..res.value_len].to_vec()) - } - } - } - ASN_OPAQUE => SnmpValue::OctetString(res.value[..res.value_len].to_vec()), - ASN_IPADDRESS => { - if res.value_len == 4 { - SnmpValue::IpAddress(format!( - "{}.{}.{}.{}", - res.value[0], res.value[1], res.value[2], res.value[3] - )) - } else { - SnmpValue::OctetString(res.value[..res.value_len].to_vec()) - } - } - ASN_OBJECT_ID => { - match String::from_utf8(res.value[..res.value_len].to_vec()) { - Ok(s) => SnmpValue::Oid(s), - Err(_) => { - SnmpValue::OctetString(res.value[..res.value_len].to_vec()) - } - } - } - ASN_INTEGER | ASN_COUNTER | ASN_GAUGE | ASN_TIMETICKS | ASN_UINTEGER => { - if res.value_len >= std::mem::size_of::() { - // Use unaligned read to avoid alignment issues from C - let val = (res.value.as_ptr() as *const i64).read_unaligned(); - SnmpValue::Integer(val) - } else { - continue; // Skip invalid values - } - } - ASN_COUNTER64 => { - if res.value_len >= 8 { - let high = u32::from_ne_bytes([ - res.value[0], - res.value[1], - res.value[2], - res.value[3], - ]); - let low = u32::from_ne_bytes([ - res.value[4], - res.value[5], - res.value[6], - res.value[7], - ]); - SnmpValue::Counter64((high as u64) << 32 | low as u64) - } else { - continue; - } - } - _ => continue, // Skip unsupported types - }; - - parsed_results.push((oid_str, value)); - } - } - - Ok(parsed_results) - } - } -} - -impl Drop for SnmpSession { - fn drop(&mut self) { - unsafe { - if !self.sess_handle.is_null() { - snmp_close_session(self.sess_handle); - } - } - } -} - -/// Parse a raw SNMP value buffer + type into an SnmpValue. -/// Shared by both direct and isolated code paths. -fn parse_snmp_value(value_buf: &[u8], value_len: usize, value_type: i32) -> SnmpResult { - let buf = &value_buf[..value_len]; - match value_type as u8 { - ASN_OCTET_STR => match String::from_utf8(buf.to_vec()) { - Ok(s) => Ok(SnmpValue::String(s)), - Err(_) => Ok(SnmpValue::OctetString(buf.to_vec())), - }, - ASN_OPAQUE => Ok(SnmpValue::OctetString(buf.to_vec())), - ASN_IPADDRESS => { - if value_len == 4 { - Ok(SnmpValue::IpAddress(format!( - "{}.{}.{}.{}", - buf[0], buf[1], buf[2], buf[3] - ))) - } else { - Ok(SnmpValue::OctetString(buf.to_vec())) - } - } - ASN_OBJECT_ID => match String::from_utf8(buf.to_vec()) { - Ok(s) => Ok(SnmpValue::Oid(s)), - Err(_) => Ok(SnmpValue::OctetString(buf.to_vec())), - }, - ASN_INTEGER | ASN_COUNTER | ASN_GAUGE | ASN_TIMETICKS | ASN_UINTEGER => { - if value_len >= std::mem::size_of::() { - let value = unsafe { (buf.as_ptr() as *const i64).read_unaligned() }; - Ok(SnmpValue::Integer(value)) - } else { - Err(SnmpError::RequestFailed("Invalid integer size".into())) - } - } - ASN_COUNTER64 => { - if value_len >= 8 { - let high = u32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]); - let low = u32::from_ne_bytes([buf[4], buf[5], buf[6], buf[7]]); - Ok(SnmpValue::Counter64((high as u64) << 32 | low as u64)) - } else { - Err(SnmpError::RequestFailed("Invalid counter64 size".into())) - } - } - ASN_NULL => Ok(SnmpValue::Null), - _ => Err(SnmpError::RequestFailed(format!( - "Unsupported type: {}", - value_type - ))), - } -} - -/// Parse a walk result entry into (oid_string, SnmpValue). -fn parse_walk_result(res: &SnmpWalkResult) -> Option<(String, SnmpValue)> { - if res.value_len == 0 { - return None; - } - let oid_str = unsafe { - CStr::from_ptr(res.oid.as_ptr() as *const c_char) - .to_string_lossy() - .to_string() - }; - parse_snmp_value(&res.value, res.value_len, res.value_type) - .ok() - .map(|v| (oid_str, v)) -} - -/// Helper to build SNMPv3 C config and keep CStrings alive. -struct V3CStrings { - _username: CString, - _auth_pass: Option, - _priv_pass: Option, - _auth_proto: Option, - _priv_proto: Option, - _sec_level: CString, - config: SnmpV3ConfigC, -} - -impl V3CStrings { - fn new(v3: &V3Config) -> SnmpResult { - let username = CString::new(v3.username.as_str()) - .map_err(|_| SnmpError::RequestFailed("Invalid username".into()))?; - let auth_pass = v3 - .auth_password - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid auth password".into()))?; - let priv_pass = v3 - .priv_password - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid priv password".into()))?; - let auth_proto = v3 - .auth_protocol - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid auth protocol".into()))?; - let priv_proto = v3 - .priv_protocol - .as_ref() - .map(|p| CString::new(p.as_str())) - .transpose() - .map_err(|_| SnmpError::RequestFailed("Invalid priv protocol".into()))?; - let sec_level = CString::new(v3.security_level.as_str()) - .map_err(|_| SnmpError::RequestFailed("Invalid security level".into()))?; - - let config = SnmpV3ConfigC { - username: username.as_ptr(), - auth_password: auth_pass - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - priv_password: priv_pass - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - auth_protocol: auth_proto - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - priv_protocol: priv_proto - .as_ref() - .map(|c| c.as_ptr()) - .unwrap_or(ptr::null()), - security_level: sec_level.as_ptr(), - }; - - Ok(Self { - _username: username, - _auth_pass: auth_pass, - _priv_pass: priv_pass, - _auth_proto: auth_proto, - _priv_proto: priv_proto, - _sec_level: sec_level, - config, - }) - } -} - -/// Perform an SNMP GET in a forked child process for crash isolation. -#[allow(clippy::too_many_arguments)] -fn get_isolated( - ip_address: &str, - port: u16, - community: &str, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: Option<&V3Config>, - oid: &str, -) -> SnmpResult { - let ip_cstr = CString::new(ip_address) - .map_err(|_| SnmpError::RequestFailed("Invalid IP address".into()))?; - let comm_cstr = CString::new(community) - .map_err(|_| SnmpError::RequestFailed("Invalid community string".into()))?; - let oid_cstr = - CString::new(oid).map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", oid)))?; - - let v3_strings = v3_config.map(V3CStrings::new).transpose()?; - let v3_ptr = v3_strings - .as_ref() - .map(|s| &s.config as *const _) - .unwrap_or(ptr::null()); - - let mut result = SnmpIsolatedGetResult { - status: -1, - value_type: 0, - child_signal: 0, - error_buf: [0; 512], - value_buf: [0; 1024], - }; - - unsafe { - snmp_get_isolated( - ip_cstr.as_ptr(), - port, - comm_cstr.as_ptr(), - version, - timeout_us, - retries, - v3_ptr, - oid_cstr.as_ptr(), - &mut result, - ); - } - - match result.status { - -2 => Err(SnmpError::CrashRecovered { - signal: result.child_signal, - message: unsafe { - CStr::from_ptr(result.error_buf.as_ptr()) - .to_string_lossy() - .to_string() - }, - }), - -1 => { - let err_msg = unsafe { - CStr::from_ptr(result.error_buf.as_ptr()) - .to_string_lossy() - .to_string() - }; - if err_msg.contains("timeout") || err_msg.contains("Timeout") { - Err(SnmpError::Timeout) - } else if err_msg.contains("Failed to parse OID") { - Err(SnmpError::InvalidOid(err_msg)) - } else if err_msg.contains("Unknown host") || err_msg.contains("Connection refused") { - Err(SnmpError::NetworkUnreachable) - } else { - Err(SnmpError::RequestFailed(err_msg)) - } - } - value_len => parse_snmp_value(&result.value_buf, value_len as usize, result.value_type), - } -} - -/// Perform an SNMP WALK in a forked child process for crash isolation. -#[allow(clippy::too_many_arguments)] -fn walk_isolated( - ip_address: &str, - port: u16, - community: &str, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: Option<&V3Config>, - start_oid: &str, -) -> SnmpResult> { - let ip_cstr = CString::new(ip_address) - .map_err(|_| SnmpError::RequestFailed("Invalid IP address".into()))?; - let comm_cstr = CString::new(community) - .map_err(|_| SnmpError::RequestFailed("Invalid community string".into()))?; - let oid_cstr = CString::new(start_oid) - .map_err(|_| SnmpError::InvalidOid(format!("Invalid OID: {}", start_oid)))?; - - let v3_strings = v3_config.map(V3CStrings::new).transpose()?; - let v3_ptr = v3_strings - .as_ref() - .map(|s| &s.config as *const _) - .unwrap_or(ptr::null()); - - const MAX_RESULTS: usize = 10000; - let mut header = SnmpIsolatedWalkHeader { - status: -1, - num_results: 0, - child_signal: 0, - error_buf: [0; 512], - }; - let mut results_buf: Vec = vec![ - SnmpWalkResult { - oid: [0; 256], - value: [0; 1024], - value_len: 0, - value_type: 0, - }; - MAX_RESULTS - ]; - - unsafe { - snmp_walk_isolated( - ip_cstr.as_ptr(), - port, - comm_cstr.as_ptr(), - version, - timeout_us, - retries, - v3_ptr, - oid_cstr.as_ptr(), - &mut header, - results_buf.as_mut_ptr(), - MAX_RESULTS, - ); - } - - match header.status { - -2 => Err(SnmpError::CrashRecovered { - signal: header.child_signal, - message: unsafe { - CStr::from_ptr(header.error_buf.as_ptr()) - .to_string_lossy() - .to_string() - }, - }), - -1 => { - let err_msg = unsafe { - CStr::from_ptr(header.error_buf.as_ptr()) - .to_string_lossy() - .to_string() - }; - if err_msg.contains("Failed to parse OID") { - Err(SnmpError::InvalidOid(err_msg)) - } else { - Err(SnmpError::RequestFailed(err_msg)) - } - } - _ => { - let parsed: Vec<(String, SnmpValue)> = results_buf - .iter() - .take(header.num_results as usize) - .filter_map(parse_walk_result) - .collect(); - Ok(parsed) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_snmp_client_creation() { - let client = SnmpClient::new(); - // Should not panic - init_snmp should be called once - let client2 = SnmpClient::new(); - // Verify both clients are valid (zero-sized struct) - assert_eq!( - std::mem::size_of_val(&client), - std::mem::size_of_val(&client2) - ); - } - - #[tokio::test] - async fn test_get_invalid_host() { - let client = SnmpClient::new(); - let result = client - .get( - "invalid.host.that.does.not.exist", - "public", - "2c", - 161, - "1.3.6.1.2.1.1.1.0", - None, - ) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - SnmpError::NetworkUnreachable | SnmpError::RequestFailed(_) => {} - e => panic!("Expected NetworkUnreachable or RequestFailed, got: {:?}", e), - } - } - - #[tokio::test] - async fn test_get_invalid_oid() { - let client = SnmpClient::new(); - let result = client - .get("127.0.0.1", "public", "2c", 161, "not-a-valid-oid", None) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - SnmpError::InvalidOid(_) => {} - e => panic!("Expected InvalidOid, got: {:?}", e), - } - } - - #[tokio::test] - async fn test_walk_invalid_oid() { - let client = SnmpClient::new(); - let result = client - .walk("127.0.0.1", "public", "2c", 161, "not-valid", None) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - SnmpError::InvalidOid(_) => {} - e => panic!("Expected InvalidOid, got: {:?}", e), - } - } - - #[tokio::test] - async fn test_v3_config_clone() { - let config = V3Config { - username: "testuser".to_string(), - auth_password: Some(Zeroizing::new("authpass".to_string())), - priv_password: Some(Zeroizing::new("privpass".to_string())), - auth_protocol: Some("SHA".to_string()), - priv_protocol: Some("AES".to_string()), - security_level: "authPriv".to_string(), - }; - - let cloned = config.clone(); - assert_eq!(config.username, cloned.username); - assert_eq!(config.auth_protocol, cloned.auth_protocol); - assert_eq!(config.priv_protocol, cloned.priv_protocol); - assert_eq!(config.security_level, cloned.security_level); - } - - #[tokio::test] - async fn test_v3_config_debug_redacts_passwords() { - let config = V3Config { - username: "testuser".to_string(), - auth_password: Some(Zeroizing::new("authpass".to_string())), - priv_password: Some(Zeroizing::new("privpass".to_string())), - auth_protocol: Some("SHA".to_string()), - priv_protocol: Some("AES".to_string()), - security_level: "authPriv".to_string(), - }; - - let debug_str = format!("{:?}", config); - assert!(!debug_str.contains("authpass")); - assert!(!debug_str.contains("privpass")); - assert!(debug_str.contains("[REDACTED]")); - assert!(debug_str.contains("testuser")); - } - - #[tokio::test] - async fn test_unsupported_version() { - let client = SnmpClient::new(); - let result = client - .get("127.0.0.1", "public", "99", 161, "1.3.6.1.2.1.1.1.0", None) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - SnmpError::RequestFailed(msg) => { - assert!(msg.contains("Unsupported SNMP version")); - } - e => panic!("Expected RequestFailed with version error, got: {:?}", e), - } - } - - #[tokio::test] - async fn test_sequential_requests() { - // Test that multiple sequential requests work without issues - // Note: Concurrent requests via tokio::spawn can cause segfaults - // because libnetsnmp may not be fully thread-safe. - // Our implementation uses spawn_blocking which should be safe for - // sequential async operations. - let client = SnmpClient::new(); - - for _ in 0..2 { - let result = client - .get("192.0.2.1", "public", "2c", 161, "1.3.6.1.2.1.1.1.0", None) - .await; - - // Should fail (no agent at 192.0.2.1) but not panic - assert!(result.is_err()); - } - } - - #[tokio::test] - async fn test_get_and_walk_different_clients() { - // Test that get and walk can be used with different client instances - let client1 = SnmpClient::new(); - let client2 = SnmpClient::new(); - - let get_result = client1 - .get("192.0.2.1", "public", "2c", 161, "1.3.6.1.2.1.1.1.0", None) - .await; - - let walk_result = client2 - .walk("192.0.2.1", "public", "2c", 161, "1.3.6.1.2.1.1", None) - .await; - - // Get should fail (unreachable host), walk may fail or return empty - assert!(get_result.is_err()); - match walk_result { - Err(_) => {} // Expected on most systems - Ok(results) => assert!( - results.is_empty(), - "Walk to unreachable host should return no results" - ), - } - } - - #[test] - fn test_c_helpers_init() { - // Test that C library initializes without crashing - unsafe { - let result = snmp_init_library(); - assert_eq!(result, 0, "C library initialization should succeed"); - } - } - - #[test] - fn test_c_helpers_session_open_invalid_host() { - // Test session opening with invalid host - unsafe { - snmp_init_library(); - - let ip = CString::new("invalid.host.example").unwrap(); - let community = CString::new("public").unwrap(); - let mut error_buf = [0i8; 256]; - - let sess = snmp_open_session( - ip.as_ptr(), - 161, - community.as_ptr(), - 2, // SNMPv2c - 10_000_000, - 2, - ptr::null(), // No v3 config for v2c - error_buf.as_mut_ptr(), - error_buf.len(), - ); - - // Should fail with invalid host - assert!(sess.is_null(), "Session should fail with invalid host"); - - // Should have an error message - let err_msg = CStr::from_ptr(error_buf.as_ptr()) - .to_string_lossy() - .to_string(); - assert!( - !err_msg.is_empty(), - "Should have error message, got: {}", - err_msg - ); - } - } - - #[test] - fn test_struct_layout_get_result() { - // Verify SnmpIsolatedGetResult matches C layout - // C struct: int status (4) + int value_type (4) + int child_signal (4) - // + char error_buf[512] + uint8_t value_buf[1024] - // With padding: 4+4+4 = 12, then 512 + 1024 = 1548 total - assert_eq!( - std::mem::size_of::(), - 4 + 4 + 4 + 512 + 1024 - ); - } - - #[test] - fn test_struct_layout_walk_header() { - // C struct: int status (4) + uint32_t num_results (4) - // + int child_signal (4) + char error_buf[512] - assert_eq!( - std::mem::size_of::(), - 4 + 4 + 4 + 512 - ); - } - - #[test] - fn test_fork_crash_recovery() { - // Verify that a SIGSEGV in a forked child does NOT kill our process - let mut child_signal: i32 = 0; - let ret = unsafe { snmp_test_crash_in_child(&mut child_signal) }; - assert_eq!(ret, 0, "snmp_test_crash_in_child should succeed"); - assert_eq!( - child_signal, - libc::SIGSEGV, - "child should have died from SIGSEGV" - ); - } - - #[test] - fn test_isolation_mode_default() { - // Default isolation mode should be Fork (unless TOWEROPS_SNMP_ISOLATION=direct) - let mode = isolation_mode(); - // We can't guarantee env var state in tests, just verify it returns a valid value - assert!( - matches!(mode, IsolationMode::Fork | IsolationMode::Direct), - "isolation_mode() should return Fork or Direct" - ); - } - - #[test] - fn test_isolated_get_unreachable_host() { - // An isolated GET to an unreachable host should return an error, not crash - let result = get_isolated( - "192.0.2.1", - 161, - "public", - 2, - SNMP_TIMEOUT_US, - SNMP_RETRIES, - None, - "1.3.6.1.2.1.1.1.0", - ); - assert!(result.is_err()); - } - - #[test] - fn test_isolated_get_invalid_oid() { - let result = get_isolated( - "127.0.0.1", - 161, - "public", - 2, - SNMP_TIMEOUT_US, - SNMP_RETRIES, - None, - "not-a-valid-oid", - ); - assert!(result.is_err()); - match result.unwrap_err() { - SnmpError::InvalidOid(_) => {} - e => panic!("Expected InvalidOid, got: {:?}", e), - } - } - - #[test] - fn test_isolated_walk_unreachable_host() { - let result = walk_isolated( - "192.0.2.1", - 161, - "public", - 2, - SNMP_TIMEOUT_US, - SNMP_RETRIES, - None, - "1.3.6.1.2.1.1", - ); - // Should either error or return empty, not crash - match result { - Err(_) => {} - Ok(results) => assert!(results.is_empty()), - } - } - - #[test] - fn test_c_helpers_session_lifecycle() { - // Test session open/close lifecycle (doesn't require actual SNMP device) - unsafe { - snmp_init_library(); - - let ip = CString::new("192.0.2.1").unwrap(); // TEST-NET-1 (should be unreachable) - let community = CString::new("public").unwrap(); - let mut error_buf = [0i8; 256]; - - let sess = snmp_open_session( - ip.as_ptr(), - 161, - community.as_ptr(), - 2, // SNMPv2c - 10_000_000, - 2, - ptr::null(), // No v3 config for v2c - error_buf.as_mut_ptr(), - error_buf.len(), - ); - - // Session creation should succeed even if host is unreachable - // (connection happens on first request) - if !sess.is_null() { - // Clean close should not crash - snmp_close_session(sess); - } - } - } -} diff --git a/src/snmp/client_v2.rs b/src/snmp/client_v2.rs deleted file mode 100644 index 28ab108..0000000 --- a/src/snmp/client_v2.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Temporary new implementation to test compilation - will replace client.rs - -use super::types::{SnmpError, SnmpResult, SnmpValue}; -use netsnmp_sys::*; -use std::ffi::{CStr, CString}; -use std::ptr; - -const SNMP_TIMEOUT_SECS: i64 = 10; - -/// Test if we can compile a simple SNMP GET using netsnmp-sys -pub fn test_snmp_get(ip: &str, community: &str) -> SnmpResult { - unsafe { - // Initialize library - init_snmp(b"test\0".as_ptr() as *const i8); - - // Create session - let mut sess: Struct_netsnmp_session = std::mem::zeroed(); - snmp_sess_init(&mut sess as *mut _); - - // Set peername - let peer = CString::new(format!("{}:161", ip)).unwrap(); - sess.peername = peer.as_ptr() as *mut _; - - // Set version and community - sess.version = SNMP_VERSION_2c as i32; - let comm = CString::new(community).unwrap(); - sess.community = comm.as_ptr() as *mut _; - sess.community_len = community.len(); - - // Set timeout - sess.timeout = SNMP_TIMEOUT_SECS * 1_000_000; // microseconds - - // Open session - let sess_ptr = snmp_open(&mut sess as *mut _); - if sess_ptr.is_null() { - return Err(SnmpError::NetworkUnreachable); - } - - // Parse OID for sysDescr.0 - let mut oid_buf = [0u32; MAX_OID_LEN]; - let mut oid_len = MAX_OID_LEN; - let oid_str = CString::new("1.3.6.1.2.1.1.1.0").unwrap(); - - if read_objid(oid_str.as_ptr(), oid_buf.as_mut_ptr(), &mut oid_len) == 0 { - snmp_close(sess_ptr); - return Err(SnmpError::InvalidOid("Failed to parse OID".into())); - } - - // Create PDU - let pdu = snmp_pdu_create(SNMP_MSG_GET as i32); - if pdu.is_null() { - snmp_close(sess_ptr); - return Err(SnmpError::RequestFailed("Failed to create PDU".into())); - } - - // Add OID to PDU - snmp_add_null_var(pdu, oid_buf.as_ptr(), oid_len); - - // Send request - let mut response: *mut Struct_netsnmp_pdu = ptr::null_mut(); - let status = snmp_synch_response(sess_ptr, pdu, &mut response as *mut _); - - let result = if status == STAT_SUCCESS as i32 && !response.is_null() { - let vars = (*response).variables; - if !vars.is_null() { - // Get the type - let var_type = (*vars)._type; - - // Try to extract string value - if var_type == ASN_OCTET_STR as u8 { - let val_len = (*vars).val_len; - // Access union - need mutable pointer - let vars_mut = vars as *mut _; - let string_ptr = *(*vars_mut).val.string(); - let slice = std::slice::from_raw_parts(string_ptr, val_len); - Ok(String::from_utf8_lossy(slice).to_string()) - } else { - Err(SnmpError::RequestFailed(format!("Unexpected type: {}", var_type))) - } - } else { - Err(SnmpError::RequestFailed("No variables in response".into())) - } - } else { - Err(if status == STAT_TIMEOUT as i32 { - SnmpError::Timeout - } else { - SnmpError::RequestFailed("SNMP request failed".into())) - } - }; - - // Cleanup - if !response.is_null() { - snmp_free_pdu(response); - } - snmp_close(sess_ptr); - - result - } -} diff --git a/src/snmp/device_poller.rs b/src/snmp/device_poller.rs deleted file mode 100644 index 5d1ccfa..0000000 --- a/src/snmp/device_poller.rs +++ /dev/null @@ -1,334 +0,0 @@ -use super::client::SnmpClient; -use super::types::{SnmpError, SnmpResult, SnmpValue}; -use super::V3Config; -use crate::secret::SecretString; -use tokio::sync::{mpsc, oneshot}; - -/// Request to perform an SNMP operation -#[derive(Debug)] -pub enum SnmpRequest { - Get { - oid: String, - response_tx: oneshot::Sender>, - }, - Walk { - base_oid: String, - response_tx: oneshot::Sender>>, - }, - Shutdown, -} - -/// Configuration for a device poller -#[derive(Clone)] -pub struct DeviceConfig { - pub ip: String, - pub port: u16, - pub version: String, - pub community: SecretString, - pub v3_config: Option, - pub transport: String, -} - -impl std::fmt::Debug for DeviceConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DeviceConfig") - .field("ip", &self.ip) - .field("port", &self.port) - .field("version", &self.version) - .field("transport", &self.transport) - .field("community", &"[REDACTED]") - .field("v3_config", &self.v3_config) - .finish() - } -} - -/// Per-device polling thread that uses C FFI to libnetsnmp -pub struct DevicePoller { - pub device_id: String, - config: DeviceConfig, - request_tx: mpsc::UnboundedSender, -} - -impl DevicePoller { - /// Spawn a new device poller thread - pub fn spawn(device_id: String, config: DeviceConfig) -> Self { - let (request_tx, request_rx) = mpsc::unbounded_channel(); - - let device_id_clone = device_id.clone(); - let config_clone = config.clone(); - - // Spawn the polling thread with 8MB stack for SNMPv3 crypto operations - tracing::debug!( - "Spawning device poller thread for {} at {}:{}", - device_id, - config.ip, - config.port - ); - std::thread::Builder::new() - .name(format!("poller-{}", device_id)) - .stack_size(8 * 1024 * 1024) // 8MB stack (default is 2MB) - .spawn(move || { - tracing::debug!("Device poller thread starting for {}", device_id_clone); - if let Err(e) = run_poller_thread(device_id_clone.clone(), config_clone, request_rx) - { - tracing::error!("Device poller thread failed for {}: {}", device_id_clone, e); - } - tracing::debug!("Device poller thread exited for {}", device_id_clone); - }) - .expect("Failed to spawn device poller thread"); - - tracing::debug!( - "Successfully spawned device poller thread for {}", - device_id - ); - - Self { - device_id, - config, - request_tx, - } - } - - /// Send a GET request to the poller thread - pub async fn get(&self, oid: String) -> SnmpResult { - let (response_tx, response_rx) = oneshot::channel(); - - self.request_tx - .send(SnmpRequest::Get { oid, response_tx }) - .map_err(|_| SnmpError::RequestFailed("Poller thread died".into()))?; - - response_rx - .await - .map_err(|_| SnmpError::RequestFailed("Poller thread didn't respond".into()))? - } - - /// Send a WALK request to the poller thread - pub async fn walk(&self, base_oid: String) -> SnmpResult> { - let (response_tx, response_rx) = oneshot::channel(); - - self.request_tx - .send(SnmpRequest::Walk { - base_oid, - response_tx, - }) - .map_err(|_| SnmpError::RequestFailed("Poller thread died".into()))?; - - response_rx - .await - .map_err(|_| SnmpError::RequestFailed("Poller thread didn't respond".into()))? - } - - /// Shutdown the poller thread - pub fn shutdown(&self) { - let _ = self.request_tx.send(SnmpRequest::Shutdown); - } - - /// Get the device config - pub fn config(&self) -> &DeviceConfig { - &self.config - } - - /// Log the status of this poller (for debugging) - pub fn log_status(&self) { - tracing::debug!( - "Poller status: device_id={}, ip={}:{}", - self.device_id, - self.config.ip, - self.config.port - ); - } -} - -/// Run the poller thread using C FFI to libnetsnmp -fn run_poller_thread( - device_id: String, - config: DeviceConfig, - mut request_rx: mpsc::UnboundedReceiver, -) -> Result<(), String> { - tracing::debug!( - "Device poller thread started for {} at {}:{}", - device_id, - config.ip, - config.port - ); - - // Create a tokio runtime for this thread (SnmpClient uses async) - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("Failed to create tokio runtime: {}", e))?; - - // Create SNMP client (stateless, uses C FFI) - let client = SnmpClient::new(); - - // Process requests until shutdown - while let Some(request) = request_rx.blocking_recv() { - let is_shutdown = matches!(request, SnmpRequest::Shutdown); - - // Log what request we're processing - match &request { - SnmpRequest::Get { oid, .. } => { - tracing::debug!("Poller thread {} processing GET {}", device_id, oid); - } - SnmpRequest::Walk { base_oid, .. } => { - tracing::debug!("Poller thread {} processing WALK {}", device_id, base_oid); - } - SnmpRequest::Shutdown => { - tracing::debug!("Poller thread {} received shutdown signal", device_id); - } - } - - let panic_result = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match request { - SnmpRequest::Get { oid, response_tx } => { - tracing::debug!("Poller thread {} executing GET", device_id); - let result = perform_get(&runtime, &client, &config, &oid); - tracing::debug!( - "Poller thread {} GET result: {:?}", - device_id, - result.is_ok() - ); - let _ = response_tx.send(result); - } - SnmpRequest::Walk { - base_oid, - response_tx, - } => { - tracing::debug!("Poller thread {} executing WALK", device_id); - let result = perform_walk(&runtime, &client, &config, &base_oid); - tracing::debug!( - "Poller thread {} WALK result: {:?}", - device_id, - result.as_ref().map(|v| v.len()) - ); - let _ = response_tx.send(result); - } - SnmpRequest::Shutdown => { - tracing::debug!("Device poller thread shutting down for {}", device_id); - } - })); - - if let Err(panic_err) = panic_result { - let panic_msg = if let Some(s) = panic_err.downcast_ref::<&str>() { - s.to_string() - } else if let Some(s) = panic_err.downcast_ref::() { - s.clone() - } else { - "Unknown panic".to_string() - }; - tracing::error!( - "Panic in device poller thread for {}: {}", - device_id, - panic_msg - ); - // Don't break - keep the thread alive for future requests - } else { - tracing::debug!("Poller thread {} completed request successfully", device_id); - } - - if is_shutdown { - tracing::debug!("Poller thread {} exiting due to shutdown", device_id); - break; - } - } - - tracing::debug!("Device poller thread stopped for {}", device_id); - Ok(()) -} - -/// Perform SNMP GET using C FFI -fn perform_get( - runtime: &tokio::runtime::Runtime, - client: &SnmpClient, - config: &DeviceConfig, - oid: &str, -) -> SnmpResult { - let result = runtime.block_on(async { - client - .get( - &config.ip, - config.community.expose(), - &config.version, - config.port, - oid, - config.v3_config.clone(), - ) - .await - }); - - if let Err(SnmpError::CrashRecovered { - signal, - ref message, - }) = result - { - tracing::error!( - "SNMP GET crash recovered for {}:{} OID {} (signal {}): {}", - config.ip, - config.port, - oid, - signal, - message - ); - } - - result -} - -/// Perform SNMP WALK using C FFI -fn perform_walk( - runtime: &tokio::runtime::Runtime, - client: &SnmpClient, - config: &DeviceConfig, - base_oid: &str, -) -> SnmpResult> { - let result = runtime.block_on(async { - client - .walk( - &config.ip, - config.community.expose(), - &config.version, - config.port, - base_oid, - config.v3_config.clone(), - ) - .await - }); - - if let Err(SnmpError::CrashRecovered { - signal, - ref message, - }) = result - { - tracing::error!( - "SNMP WALK crash recovered for {}:{} OID {} (signal {}): {}", - config.ip, - config.port, - base_oid, - signal, - message - ); - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_device_config_debug() { - let config = DeviceConfig { - ip: "192.168.1.1".to_string(), - port: 161, - version: "2c".to_string(), - community: SecretString::new("public"), - v3_config: None, - transport: "udp".to_string(), - }; - - let debug_str = format!("{:?}", config); - assert!(debug_str.contains("[REDACTED]")); - assert!(!debug_str.contains("public")); - } -} diff --git a/src/snmp/mod.rs b/src/snmp/mod.rs deleted file mode 100644 index 0a7d510..0000000 --- a/src/snmp/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod client; -mod device_poller; -mod poller_registry; -pub mod trap; -mod types; - -pub use client::{isolation_mode, SnmpClient, V3Config}; -pub use device_poller::DeviceConfig; -pub use poller_registry::PollerRegistry; -pub use trap::{SnmpTrap, TrapListener, DEFAULT_TRAP_PORT}; -pub use types::SnmpValue; diff --git a/src/snmp/poller_registry.rs b/src/snmp/poller_registry.rs deleted file mode 100644 index a9c7047..0000000 --- a/src/snmp/poller_registry.rs +++ /dev/null @@ -1,154 +0,0 @@ -use super::device_poller::{DeviceConfig, DevicePoller}; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -/// Registry of active device pollers -#[derive(Clone)] -pub struct PollerRegistry { - pollers: Arc>>>, -} - -impl PollerRegistry { - /// Create a new poller registry - pub fn new() -> Self { - Self { - pollers: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Get or create a device poller - pub fn get_or_create(&self, device_id: String, config: DeviceConfig) -> Arc { - // Try read lock first (fast path if poller exists) - { - let pollers = self.pollers.read().unwrap(); - if let Some(poller) = pollers.get(&device_id) { - poller.log_status(); - return Arc::clone(poller); - } - } - - // Need to create new poller (write lock) - let mut pollers = self.pollers.write().unwrap(); - - // Double-check in case another thread created it while we waited for write lock - if let Some(poller) = pollers.get(&device_id) { - poller.log_status(); - return Arc::clone(poller); - } - - // Create new poller - let poller = Arc::new(DevicePoller::spawn(device_id.clone(), config)); - pollers.insert(device_id, Arc::clone(&poller)); - - // Release write lock before logging - drop(pollers); - - tracing::debug!("Created new device poller (total: {})", self.count()); - poller.log_status(); - - poller - } - - /// Remove a device poller (shutdown thread) - /// Called when a device is deleted or no longer needs polling - /// Returns the device IP if the poller was found - pub fn remove(&self, device_id: &str) -> Option { - let mut pollers = self.pollers.write().unwrap(); - if let Some(poller) = pollers.remove(device_id) { - let ip = poller.config().ip.clone(); - poller.shutdown(); - tracing::debug!( - "Removed device poller for {} (remaining: {})", - device_id, - pollers.len() - ); - Some(ip) - } else { - None - } - } - - /// Get a list of active device IDs - pub fn list_devices(&self) -> Vec { - let pollers = self.pollers.read().unwrap(); - pollers.keys().cloned().collect() - } - - /// Get count of active pollers - pub fn count(&self) -> usize { - let pollers = self.pollers.read().unwrap(); - pollers.len() - } - - /// Shutdown all pollers - pub fn shutdown_all(&self) { - let device_list = self.list_devices(); - if !device_list.is_empty() { - tracing::info!("Shutting down {} device pollers", device_list.len()); - } - - let mut pollers = self.pollers.write().unwrap(); - for (device_id, poller) in pollers.drain() { - poller.shutdown(); - tracing::debug!("Shutdown device poller for {}", device_id); - } - } -} - -impl Default for PollerRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::secret::SecretString; - - #[test] - fn test_registry_remove() { - let registry = PollerRegistry::new(); - - // Create a test device config - let config = DeviceConfig { - ip: "127.0.0.1".to_string(), - port: 161, - version: "2c".to_string(), - community: SecretString::new("public"), - v3_config: None, - transport: "udp".to_string(), - }; - - // Create a poller - let poller = registry.get_or_create("test-device".to_string(), config); - assert_eq!(registry.count(), 1); - assert_eq!(poller.device_id, "test-device"); - - // Remove the poller - let removed_ip = registry.remove("test-device"); - assert_eq!(registry.count(), 0); - assert_eq!(removed_ip, Some("127.0.0.1".to_string())); - } - - #[test] - fn test_device_poller_accessors() { - let config = DeviceConfig { - ip: "192.168.1.1".to_string(), - port: 161, - version: "2c".to_string(), - community: SecretString::new("public"), - v3_config: None, - transport: "udp".to_string(), - }; - - let poller = DevicePoller::spawn("test-device".to_string(), config.clone()); - - // Test accessors - assert_eq!(poller.device_id, "test-device"); - assert_eq!(poller.config().ip, "192.168.1.1"); - assert_eq!(poller.config().port, 161); - - poller.shutdown(); - } -} diff --git a/src/snmp/trap.rs b/src/snmp/trap.rs deleted file mode 100644 index 9bf39d3..0000000 --- a/src/snmp/trap.rs +++ /dev/null @@ -1,1485 +0,0 @@ -//! SNMP Trap Listener -//! -//! Listens for SNMP v1 and v2c traps on a UDP socket and logs them. -//! Implements minimal BER/ASN.1 parsing for trap PDUs. - -use std::fmt; -use std::net::SocketAddr; -use tokio::net::UdpSocket; -use tokio::sync::mpsc; - -/// Default SNMP trap port -pub const DEFAULT_TRAP_PORT: u16 = 162; - -/// Maximum UDP packet size for SNMP traps -const MAX_PACKET_SIZE: usize = 65535; - -/// SNMP version -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SnmpVersion { - V1, - V2c, -} - -impl fmt::Display for SnmpVersion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SnmpVersion::V1 => write!(f, "v1"), - SnmpVersion::V2c => write!(f, "v2c"), - } - } -} - -/// SNMPv1 generic trap types -#[derive(Debug, Clone, Copy)] -pub enum GenericTrap { - ColdStart, - WarmStart, - LinkDown, - LinkUp, - AuthenticationFailure, - EgpNeighborLoss, - EnterpriseSpecific, -} - -impl GenericTrap { - fn from_u8(value: u8) -> Option { - match value { - 0 => Some(GenericTrap::ColdStart), - 1 => Some(GenericTrap::WarmStart), - 2 => Some(GenericTrap::LinkDown), - 3 => Some(GenericTrap::LinkUp), - 4 => Some(GenericTrap::AuthenticationFailure), - 5 => Some(GenericTrap::EgpNeighborLoss), - 6 => Some(GenericTrap::EnterpriseSpecific), - _ => None, - } - } -} - -impl fmt::Display for GenericTrap { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - GenericTrap::ColdStart => write!(f, "coldStart"), - GenericTrap::WarmStart => write!(f, "warmStart"), - GenericTrap::LinkDown => write!(f, "linkDown"), - GenericTrap::LinkUp => write!(f, "linkUp"), - GenericTrap::AuthenticationFailure => write!(f, "authenticationFailure"), - GenericTrap::EgpNeighborLoss => write!(f, "egpNeighborLoss"), - GenericTrap::EnterpriseSpecific => write!(f, "enterpriseSpecific"), - } - } -} - -/// Parsed SNMP trap -#[derive(Debug, Clone)] -pub struct SnmpTrap { - pub source_addr: SocketAddr, - pub version: SnmpVersion, - #[allow(dead_code)] // Parsed but not currently logged; useful for future filtering - pub community: String, - pub trap_oid: String, - pub generic_trap: Option, - pub specific_trap: Option, - pub uptime: u32, - pub varbinds: Vec<(String, String)>, -} - -impl fmt::Display for SnmpTrap { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SNMP trap from {} [{}]", self.source_addr, self.version)?; - - match self.version { - SnmpVersion::V1 => { - write!(f, " enterprise={}", self.trap_oid)?; - if let Some(generic) = &self.generic_trap { - write!(f, " generic={}", generic)?; - } - if let Some(specific) = self.specific_trap { - write!(f, " specific={}", specific)?; - } - } - SnmpVersion::V2c => { - write!(f, " oid={}", self.trap_oid)?; - } - } - - write!(f, " uptime={}", self.uptime)?; - - if !self.varbinds.is_empty() { - write!(f, " varbinds=[")?; - for (i, (oid, value)) in self.varbinds.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}={}", oid, value)?; - } - write!(f, "]")?; - } - - Ok(()) - } -} - -/// SNMP trap listener -pub struct TrapListener { - port: u16, -} - -impl TrapListener { - pub fn new(port: u16) -> Self { - Self { port } - } - - /// Run the trap listener, sending parsed traps through the channel - pub async fn run(self, trap_tx: mpsc::Sender) { - let bind_addr = format!("0.0.0.0:{}", self.port); - - let socket = match UdpSocket::bind(&bind_addr).await { - Ok(s) => { - tracing::info!("SNMP trap listener started on UDP port {}", self.port); - s - } - Err(e) => { - tracing::error!("Failed to bind trap listener to {}: {}", bind_addr, e); - return; - } - }; - - let mut buf = vec![0u8; MAX_PACKET_SIZE]; - - loop { - match socket.recv_from(&mut buf).await { - Ok((len, src_addr)) => { - let packet = &buf[..len]; - - match parse_trap(packet, src_addr) { - Ok(trap) => { - if trap_tx.send(trap).await.is_err() { - tracing::warn!("Trap channel closed, stopping listener"); - break; - } - } - Err(e) => { - tracing::warn!("Failed to parse SNMP trap from {}: {}", src_addr, e); - } - } - } - Err(e) => { - tracing::warn!("Error receiving trap packet: {}", e); - } - } - } - } -} - -// ============================================================================ -// BER/ASN.1 Parsing -// ============================================================================ - -/// BER tag types -mod ber_tags { - pub const INTEGER: u8 = 0x02; - pub const OCTET_STRING: u8 = 0x04; - pub const NULL: u8 = 0x05; - pub const OBJECT_IDENTIFIER: u8 = 0x06; - pub const SEQUENCE: u8 = 0x30; - pub const IP_ADDRESS: u8 = 0x40; - pub const COUNTER32: u8 = 0x41; - pub const GAUGE32: u8 = 0x42; - pub const TIMETICKS: u8 = 0x43; - pub const COUNTER64: u8 = 0x46; - pub const TRAP_PDU_V1: u8 = 0xA4; - pub const TRAP_PDU_V2: u8 = 0xA7; -} - -/// Parse error -#[derive(Debug)] -struct ParseError(String); - -impl fmt::Display for ParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl std::error::Error for ParseError {} - -type ParseResult = Result; - -/// Parse a BER TLV (Tag-Length-Value) and return (tag, value_bytes, remaining_bytes) -fn parse_tlv(data: &[u8]) -> ParseResult<(u8, &[u8], &[u8])> { - if data.is_empty() { - return Err(ParseError("Empty data".to_string())); - } - - let tag = data[0]; - let (length, header_len) = parse_length(&data[1..])?; - - let total_header = 1 + header_len; - if data.len() < total_header + length { - return Err(ParseError(format!( - "Data too short: need {} bytes, have {}", - total_header + length, - data.len() - ))); - } - - let value = &data[total_header..total_header + length]; - let remaining = &data[total_header + length..]; - - Ok((tag, value, remaining)) -} - -/// Parse BER length field, returning (length, bytes_consumed) -fn parse_length(data: &[u8]) -> ParseResult<(usize, usize)> { - if data.is_empty() { - return Err(ParseError("Empty length field".to_string())); - } - - let first = data[0]; - - if first < 0x80 { - // Short form: length in single byte - Ok((first as usize, 1)) - } else if first == 0x80 { - Err(ParseError("Indefinite length not supported".to_string())) - } else { - // Long form: first byte indicates number of length bytes - let num_bytes = (first & 0x7F) as usize; - if num_bytes > 4 || data.len() < 1 + num_bytes { - return Err(ParseError("Invalid length encoding".to_string())); - } - - let mut length: usize = 0; - for byte in &data[1..1 + num_bytes] { - length = (length << 8) | (*byte as usize); - } - - Ok((length, 1 + num_bytes)) - } -} - -/// Parse an INTEGER value -fn parse_integer(data: &[u8]) -> ParseResult { - if data.is_empty() { - return Ok(0); - } - - let mut value: i64 = if data[0] & 0x80 != 0 { -1 } else { 0 }; - - for &byte in data { - value = (value << 8) | (byte as i64); - } - - Ok(value) -} - -/// Parse an unsigned INTEGER value -fn parse_unsigned(data: &[u8]) -> ParseResult { - let mut value: u64 = 0; - for &byte in data { - value = (value << 8) | (byte as u64); - } - Ok(value) -} - -/// Parse an OBJECT IDENTIFIER -fn parse_oid(data: &[u8]) -> ParseResult { - if data.is_empty() { - return Ok(String::new()); - } - - let mut oid_parts = Vec::new(); - - // First byte encodes first two components: X*40 + Y - let first = data[0] as u32; - oid_parts.push(first / 40); - oid_parts.push(first % 40); - - // Remaining bytes use variable-length encoding - let mut value: u32 = 0; - for &byte in &data[1..] { - value = (value << 7) | ((byte & 0x7F) as u32); - if byte & 0x80 == 0 { - oid_parts.push(value); - value = 0; - } - } - - Ok(oid_parts - .iter() - .map(|n| n.to_string()) - .collect::>() - .join(".")) -} - -/// Parse an OCTET STRING as a UTF-8 string (lossy) -fn parse_octet_string(data: &[u8]) -> String { - // Try UTF-8 first, fall back to hex if it contains non-printable chars - let s = String::from_utf8_lossy(data); - if s.chars() - .all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace()) - { - s.into_owned() - } else { - // Hex encode non-printable data - data.iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join("") - } -} - -/// Parse IP address (4 bytes) -fn parse_ip_address(data: &[u8]) -> ParseResult { - if data.len() != 4 { - return Err(ParseError(format!( - "Invalid IP address length: {}", - data.len() - ))); - } - Ok(format!("{}.{}.{}.{}", data[0], data[1], data[2], data[3])) -} - -/// Parse a varbind value to string representation -fn parse_value_to_string(tag: u8, data: &[u8]) -> String { - match tag { - ber_tags::INTEGER => parse_integer(data) - .map(|v| v.to_string()) - .unwrap_or_else(|_| "?".to_string()), - ber_tags::OCTET_STRING => parse_octet_string(data), - ber_tags::OBJECT_IDENTIFIER => parse_oid(data).unwrap_or_else(|_| "?".to_string()), - ber_tags::NULL => "null".to_string(), - ber_tags::IP_ADDRESS => parse_ip_address(data).unwrap_or_else(|_| "?".to_string()), - ber_tags::COUNTER32 | ber_tags::GAUGE32 | ber_tags::TIMETICKS => parse_unsigned(data) - .map(|v| v.to_string()) - .unwrap_or_else(|_| "?".to_string()), - ber_tags::COUNTER64 => parse_unsigned(data) - .map(|v| v.to_string()) - .unwrap_or_else(|_| "?".to_string()), - _ => format!("[tag=0x{:02x}]", tag), - } -} - -/// Parse varbind list -fn parse_varbinds(data: &[u8]) -> ParseResult> { - let mut varbinds = Vec::new(); - let mut remaining = data; - - while !remaining.is_empty() { - // Each varbind is a SEQUENCE of (OID, value) - let (tag, varbind_data, rest) = parse_tlv(remaining)?; - if tag != ber_tags::SEQUENCE { - return Err(ParseError(format!("Expected SEQUENCE, got 0x{:02x}", tag))); - } - remaining = rest; - - // Parse OID - let (oid_tag, oid_data, value_rest) = parse_tlv(varbind_data)?; - if oid_tag != ber_tags::OBJECT_IDENTIFIER { - return Err(ParseError(format!("Expected OID, got 0x{:02x}", oid_tag))); - } - let oid = parse_oid(oid_data)?; - - // Parse value - let (value_tag, value_data, _) = parse_tlv(value_rest)?; - let value = parse_value_to_string(value_tag, value_data); - - varbinds.push((oid, value)); - } - - Ok(varbinds) -} - -/// Parse an SNMP trap packet -fn parse_trap(data: &[u8], source_addr: SocketAddr) -> ParseResult { - // SNMP message: SEQUENCE { version INTEGER, community OCTET STRING, PDU } - let (tag, message_data, _) = parse_tlv(data)?; - if tag != ber_tags::SEQUENCE { - return Err(ParseError(format!("Expected SEQUENCE, got 0x{:02x}", tag))); - } - - // Parse version - let (tag, version_data, rest) = parse_tlv(message_data)?; - if tag != ber_tags::INTEGER { - return Err(ParseError(format!( - "Expected INTEGER for version, got 0x{:02x}", - tag - ))); - } - let version_num = parse_integer(version_data)?; - let version = match version_num { - 0 => SnmpVersion::V1, - 1 => SnmpVersion::V2c, - _ => { - return Err(ParseError(format!( - "Unsupported SNMP version: {}", - version_num - ))) - } - }; - - // Parse community string - let (tag, community_data, rest) = parse_tlv(rest)?; - if tag != ber_tags::OCTET_STRING { - return Err(ParseError(format!( - "Expected OCTET STRING for community, got 0x{:02x}", - tag - ))); - } - let community = String::from_utf8_lossy(community_data).into_owned(); - - // Parse PDU based on version - let (pdu_tag, pdu_data, _) = parse_tlv(rest)?; - - match version { - SnmpVersion::V1 => { - if pdu_tag != ber_tags::TRAP_PDU_V1 { - return Err(ParseError(format!( - "Expected Trap-PDU (0xA4), got 0x{:02x}", - pdu_tag - ))); - } - parse_v1_trap(pdu_data, source_addr, community) - } - SnmpVersion::V2c => { - if pdu_tag != ber_tags::TRAP_PDU_V2 { - return Err(ParseError(format!( - "Expected SNMPv2-Trap-PDU (0xA7), got 0x{:02x}", - pdu_tag - ))); - } - parse_v2c_trap(pdu_data, source_addr, community) - } - } -} - -/// Parse SNMPv1 Trap-PDU -fn parse_v1_trap(data: &[u8], source_addr: SocketAddr, community: String) -> ParseResult { - // Trap-PDU: enterprise OID, agent-addr, generic-trap, specific-trap, time-stamp, varbinds - - // Enterprise OID - let (tag, oid_data, rest) = parse_tlv(data)?; - if tag != ber_tags::OBJECT_IDENTIFIER { - return Err(ParseError(format!( - "Expected OID for enterprise, got 0x{:02x}", - tag - ))); - } - let enterprise_oid = parse_oid(oid_data)?; - - // Agent address (NetworkAddress - IpAddress) - let (tag, _, rest) = parse_tlv(rest)?; - if tag != ber_tags::IP_ADDRESS { - return Err(ParseError(format!( - "Expected IpAddress for agent-addr, got 0x{:02x}", - tag - ))); - } - // We don't use agent-addr, skip it - - // Generic trap - let (tag, generic_data, rest) = parse_tlv(rest)?; - if tag != ber_tags::INTEGER { - return Err(ParseError(format!( - "Expected INTEGER for generic-trap, got 0x{:02x}", - tag - ))); - } - let generic_num = parse_integer(generic_data)? as u8; - let generic_trap = GenericTrap::from_u8(generic_num); - - // Specific trap - let (tag, specific_data, rest) = parse_tlv(rest)?; - if tag != ber_tags::INTEGER { - return Err(ParseError(format!( - "Expected INTEGER for specific-trap, got 0x{:02x}", - tag - ))); - } - let specific_trap = parse_unsigned(specific_data)? as u32; - - // Timestamp - let (tag, timestamp_data, rest) = parse_tlv(rest)?; - if tag != ber_tags::TIMETICKS { - return Err(ParseError(format!( - "Expected TIMETICKS for time-stamp, got 0x{:02x}", - tag - ))); - } - let uptime = parse_unsigned(timestamp_data)? as u32; - - // Varbind list - let (tag, varbind_data, _) = parse_tlv(rest)?; - if tag != ber_tags::SEQUENCE { - return Err(ParseError(format!( - "Expected SEQUENCE for varbinds, got 0x{:02x}", - tag - ))); - } - let varbinds = parse_varbinds(varbind_data)?; - - Ok(SnmpTrap { - source_addr, - version: SnmpVersion::V1, - community, - trap_oid: enterprise_oid, - generic_trap, - specific_trap: Some(specific_trap), - uptime, - varbinds, - }) -} - -/// Parse SNMPv2c Trap-PDU -fn parse_v2c_trap( - data: &[u8], - source_addr: SocketAddr, - community: String, -) -> ParseResult { - // SNMPv2-Trap-PDU: request-id, error-status, error-index, varbinds - // The trap OID is in the second varbind (snmpTrapOID.0) - - // Request ID - let (tag, _, rest) = parse_tlv(data)?; - if tag != ber_tags::INTEGER { - return Err(ParseError(format!( - "Expected INTEGER for request-id, got 0x{:02x}", - tag - ))); - } - - // Error status - let (tag, _, rest) = parse_tlv(rest)?; - if tag != ber_tags::INTEGER { - return Err(ParseError(format!( - "Expected INTEGER for error-status, got 0x{:02x}", - tag - ))); - } - - // Error index - let (tag, _, rest) = parse_tlv(rest)?; - if tag != ber_tags::INTEGER { - return Err(ParseError(format!( - "Expected INTEGER for error-index, got 0x{:02x}", - tag - ))); - } - - // Varbind list - let (tag, varbind_data, _) = parse_tlv(rest)?; - if tag != ber_tags::SEQUENCE { - return Err(ParseError(format!( - "Expected SEQUENCE for varbinds, got 0x{:02x}", - tag - ))); - } - let varbinds = parse_varbinds(varbind_data)?; - - // Extract sysUpTime from first varbind (1.3.6.1.2.1.1.3.0) - let uptime = varbinds - .first() - .filter(|(oid, _)| oid == "1.3.6.1.2.1.1.3.0") - .and_then(|(_, value)| value.parse::().ok()) - .unwrap_or(0); - - // Extract snmpTrapOID from second varbind (1.3.6.1.6.3.1.1.4.1.0) - let trap_oid = varbinds - .get(1) - .filter(|(oid, _)| oid == "1.3.6.1.6.3.1.1.4.1.0") - .map(|(_, value)| value.clone()) - .unwrap_or_else(|| "unknown".to_string()); - - // Remaining varbinds (skip first two which are sysUpTime and snmpTrapOID) - let remaining_varbinds: Vec<_> = varbinds.into_iter().skip(2).collect(); - - Ok(SnmpTrap { - source_addr, - version: SnmpVersion::V2c, - community, - trap_oid, - generic_trap: None, - specific_trap: None, - uptime, - varbinds: remaining_varbinds, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_length_short() { - assert_eq!(parse_length(&[0x05]).unwrap(), (5, 1)); - assert_eq!(parse_length(&[0x7F]).unwrap(), (127, 1)); - } - - #[test] - fn test_parse_length_long() { - // Two-byte length: 0x81 0x80 = 128 - assert_eq!(parse_length(&[0x81, 0x80]).unwrap(), (128, 2)); - // Three-byte length: 0x82 0x01 0x00 = 256 - assert_eq!(parse_length(&[0x82, 0x01, 0x00]).unwrap(), (256, 3)); - } - - #[test] - fn test_parse_integer() { - assert_eq!(parse_integer(&[0x00]).unwrap(), 0); - assert_eq!(parse_integer(&[0x01]).unwrap(), 1); - assert_eq!(parse_integer(&[0x7F]).unwrap(), 127); - assert_eq!(parse_integer(&[0x00, 0x80]).unwrap(), 128); - assert_eq!(parse_integer(&[0xFF]).unwrap(), -1); - assert_eq!(parse_integer(&[0x80]).unwrap(), -128); - } - - #[test] - fn test_parse_oid() { - // 1.3.6.1.2.1.1.1.0 = 0x2B 0x06 0x01 0x02 0x01 0x01 0x01 0x00 - let oid_bytes = [0x2B, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00]; - assert_eq!(parse_oid(&oid_bytes).unwrap(), "1.3.6.1.2.1.1.1.0"); - } - - #[test] - fn test_parse_oid_large_component() { - // OID with component > 127 (uses multi-byte encoding) - // 1.3.6.1.4.1.9.9.41 where 9.9.41 tests various sizes - let oid_bytes = [0x2B, 0x06, 0x01, 0x04, 0x01, 0x09, 0x09, 0x29]; - assert_eq!(parse_oid(&oid_bytes).unwrap(), "1.3.6.1.4.1.9.9.41"); - } - - #[test] - fn test_generic_trap_display() { - assert_eq!(format!("{}", GenericTrap::ColdStart), "coldStart"); - assert_eq!(format!("{}", GenericTrap::LinkUp), "linkUp"); - assert_eq!( - format!("{}", GenericTrap::EnterpriseSpecific), - "enterpriseSpecific" - ); - } - - #[test] - fn test_snmp_version_display() { - assert_eq!(format!("{}", SnmpVersion::V1), "v1"); - assert_eq!(format!("{}", SnmpVersion::V2c), "v2c"); - } - - #[test] - fn test_snmp_trap_display_v1() { - let trap = SnmpTrap { - source_addr: "192.168.1.1:161".parse().unwrap(), - version: SnmpVersion::V1, - community: "public".to_string(), - trap_oid: "1.3.6.1.4.1.9.9.41".to_string(), - generic_trap: Some(GenericTrap::EnterpriseSpecific), - specific_trap: Some(1), - uptime: 12345, - varbinds: vec![("1.3.6.1.2.1.2.2.1.1".to_string(), "2".to_string())], - }; - - let display = format!("{}", trap); - assert!(display.contains("192.168.1.1:161")); - assert!(display.contains("[v1]")); - assert!(display.contains("enterprise=1.3.6.1.4.1.9.9.41")); - assert!(display.contains("generic=enterpriseSpecific")); - assert!(display.contains("specific=1")); - assert!(display.contains("uptime=12345")); - } - - #[test] - fn test_snmp_trap_display_v2c() { - let trap = SnmpTrap { - source_addr: "192.168.1.1:161".parse().unwrap(), - version: SnmpVersion::V2c, - community: "public".to_string(), - trap_oid: "1.3.6.1.6.3.1.1.5.4".to_string(), - generic_trap: None, - specific_trap: None, - uptime: 12345, - varbinds: vec![("ifIndex.2".to_string(), "2".to_string())], - }; - - let display = format!("{}", trap); - assert!(display.contains("192.168.1.1:161")); - assert!(display.contains("[v2c]")); - assert!(display.contains("oid=1.3.6.1.6.3.1.1.5.4")); - assert!(display.contains("uptime=12345")); - } - - #[test] - fn test_parse_octet_string_printable() { - let data = b"Hello World"; - assert_eq!(parse_octet_string(data), "Hello World"); - } - - #[test] - fn test_parse_octet_string_binary() { - let data = [0x00, 0x01, 0x02, 0xFF]; - assert_eq!(parse_octet_string(&data), "000102ff"); - } - - #[test] - fn test_parse_ip_address() { - assert_eq!(parse_ip_address(&[192, 168, 1, 1]).unwrap(), "192.168.1.1"); - } - - #[test] - fn test_parse_ip_address_invalid_length() { - assert!(parse_ip_address(&[192, 168, 1]).is_err()); - } - - #[test] - fn test_generic_trap_from_u8_all_variants() { - assert!(matches!( - GenericTrap::from_u8(0), - Some(GenericTrap::ColdStart) - )); - assert!(matches!( - GenericTrap::from_u8(1), - Some(GenericTrap::WarmStart) - )); - assert!(matches!( - GenericTrap::from_u8(2), - Some(GenericTrap::LinkDown) - )); - assert!(matches!(GenericTrap::from_u8(3), Some(GenericTrap::LinkUp))); - assert!(matches!( - GenericTrap::from_u8(4), - Some(GenericTrap::AuthenticationFailure) - )); - assert!(matches!( - GenericTrap::from_u8(5), - Some(GenericTrap::EgpNeighborLoss) - )); - assert!(matches!( - GenericTrap::from_u8(6), - Some(GenericTrap::EnterpriseSpecific) - )); - assert!(GenericTrap::from_u8(7).is_none()); - assert!(GenericTrap::from_u8(255).is_none()); - } - - #[test] - fn test_generic_trap_display_all_variants() { - assert_eq!(format!("{}", GenericTrap::ColdStart), "coldStart"); - assert_eq!(format!("{}", GenericTrap::WarmStart), "warmStart"); - assert_eq!(format!("{}", GenericTrap::LinkDown), "linkDown"); - assert_eq!(format!("{}", GenericTrap::LinkUp), "linkUp"); - assert_eq!( - format!("{}", GenericTrap::AuthenticationFailure), - "authenticationFailure" - ); - assert_eq!( - format!("{}", GenericTrap::EgpNeighborLoss), - "egpNeighborLoss" - ); - assert_eq!( - format!("{}", GenericTrap::EnterpriseSpecific), - "enterpriseSpecific" - ); - } - - #[test] - fn test_parse_tlv_simple() { - // INTEGER 5: tag=0x02, length=0x01, value=0x05 - let data = [0x02, 0x01, 0x05]; - let (tag, value, remaining) = parse_tlv(&data).unwrap(); - assert_eq!(tag, 0x02); - assert_eq!(value, &[0x05]); - assert!(remaining.is_empty()); - } - - #[test] - fn test_parse_tlv_with_remaining() { - // INTEGER 5 followed by more data - let data = [0x02, 0x01, 0x05, 0x04, 0x02, 0x41, 0x42]; - let (tag, value, remaining) = parse_tlv(&data).unwrap(); - assert_eq!(tag, 0x02); - assert_eq!(value, &[0x05]); - assert_eq!(remaining, &[0x04, 0x02, 0x41, 0x42]); - } - - #[test] - fn test_parse_tlv_empty() { - let result = parse_tlv(&[]); - assert!(result.is_err()); - } - - #[test] - fn test_parse_tlv_too_short() { - // Says length is 5 but only has 2 bytes - let data = [0x02, 0x05, 0x01, 0x02]; - let result = parse_tlv(&data); - assert!(result.is_err()); - } - - #[test] - fn test_parse_length_empty() { - let result = parse_length(&[]); - assert!(result.is_err()); - } - - #[test] - fn test_parse_length_indefinite() { - // Indefinite length (0x80) not supported - let result = parse_length(&[0x80]); - assert!(result.is_err()); - } - - #[test] - fn test_parse_length_too_long() { - // Length field says 5 bytes but not enough data - let result = parse_length(&[0x85, 0x01]); - assert!(result.is_err()); - } - - #[test] - fn test_parse_integer_empty() { - assert_eq!(parse_integer(&[]).unwrap(), 0); - } - - #[test] - fn test_parse_integer_multibyte() { - // 256 = 0x0100 - assert_eq!(parse_integer(&[0x01, 0x00]).unwrap(), 256); - // -256 = 0xFF00 - assert_eq!(parse_integer(&[0xFF, 0x00]).unwrap(), -256); - } - - #[test] - fn test_parse_unsigned() { - assert_eq!(parse_unsigned(&[]).unwrap(), 0); - assert_eq!(parse_unsigned(&[0x01]).unwrap(), 1); - assert_eq!(parse_unsigned(&[0xFF]).unwrap(), 255); - assert_eq!(parse_unsigned(&[0x01, 0x00]).unwrap(), 256); - } - - #[test] - fn test_parse_oid_empty() { - assert_eq!(parse_oid(&[]).unwrap(), ""); - } - - #[test] - fn test_parse_value_to_string_integer() { - let result = parse_value_to_string(ber_tags::INTEGER, &[0x2A]); - assert_eq!(result, "42"); - } - - #[test] - fn test_parse_value_to_string_octet_string() { - let result = parse_value_to_string(ber_tags::OCTET_STRING, b"test"); - assert_eq!(result, "test"); - } - - #[test] - fn test_parse_value_to_string_object_identifier() { - let oid_bytes = [0x2B, 0x06, 0x01, 0x02, 0x01]; - let result = parse_value_to_string(ber_tags::OBJECT_IDENTIFIER, &oid_bytes); - assert_eq!(result, "1.3.6.1.2.1"); - } - - #[test] - fn test_parse_value_to_string_null() { - let result = parse_value_to_string(ber_tags::NULL, &[]); - assert_eq!(result, "null"); - } - - #[test] - fn test_parse_value_to_string_ip_address() { - let result = parse_value_to_string(ber_tags::IP_ADDRESS, &[10, 0, 0, 1]); - assert_eq!(result, "10.0.0.1"); - } - - #[test] - fn test_parse_value_to_string_counter32() { - let result = parse_value_to_string(ber_tags::COUNTER32, &[0x00, 0x01]); - assert_eq!(result, "1"); - } - - #[test] - fn test_parse_value_to_string_gauge32() { - let result = parse_value_to_string(ber_tags::GAUGE32, &[0x64]); - assert_eq!(result, "100"); - } - - #[test] - fn test_parse_value_to_string_timeticks() { - let result = parse_value_to_string(ber_tags::TIMETICKS, &[0x00, 0x01]); - assert_eq!(result, "1"); - } - - #[test] - fn test_parse_value_to_string_counter64() { - let result = parse_value_to_string(ber_tags::COUNTER64, &[0x01, 0x00]); - assert_eq!(result, "256"); - } - - #[test] - fn test_parse_value_to_string_unknown() { - let result = parse_value_to_string(0xFF, &[0x01]); - assert!(result.contains("tag=0xff")); - } - - #[test] - fn test_parse_varbinds_empty() { - let result = parse_varbinds(&[]).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn test_snmp_trap_display_v1_no_varbinds() { - let trap = SnmpTrap { - source_addr: "192.168.1.1:161".parse().unwrap(), - version: SnmpVersion::V1, - community: "public".to_string(), - trap_oid: "1.3.6.1.4.1.9.9.41".to_string(), - generic_trap: Some(GenericTrap::ColdStart), - specific_trap: None, - uptime: 0, - varbinds: vec![], - }; - - let display = format!("{}", trap); - assert!(display.contains("[v1]")); - assert!(display.contains("generic=coldStart")); - assert!(!display.contains("varbinds=")); - } - - #[test] - fn test_snmp_trap_display_v1_no_generic_trap() { - let trap = SnmpTrap { - source_addr: "192.168.1.1:161".parse().unwrap(), - version: SnmpVersion::V1, - community: "public".to_string(), - trap_oid: "1.3.6.1.4.1.9".to_string(), - generic_trap: None, - specific_trap: None, - uptime: 100, - varbinds: vec![], - }; - - let display = format!("{}", trap); - assert!(display.contains("[v1]")); - assert!(!display.contains("generic=")); - assert!(!display.contains("specific=")); - } - - #[test] - fn test_snmp_trap_display_multiple_varbinds() { - let trap = SnmpTrap { - source_addr: "10.0.0.1:162".parse().unwrap(), - version: SnmpVersion::V2c, - community: "private".to_string(), - trap_oid: "1.3.6.1.6.3.1.1.5.3".to_string(), - generic_trap: None, - specific_trap: None, - uptime: 5000, - varbinds: vec![ - ("1.3.6.1.2.1.2.2.1.1".to_string(), "1".to_string()), - ("1.3.6.1.2.1.2.2.1.2".to_string(), "eth0".to_string()), - ], - }; - - let display = format!("{}", trap); - assert!(display.contains("10.0.0.1:162")); - assert!(display.contains("[v2c]")); - assert!(display.contains("varbinds=[")); - assert!(display.contains(", ")); - } - - #[test] - fn test_trap_listener_new() { - let listener = TrapListener::new(1620); - assert_eq!(listener.port, 1620); - } - - #[test] - fn test_parse_error_display() { - let err = ParseError("test error".to_string()); - assert_eq!(format!("{}", err), "test error"); - } - - #[test] - fn test_snmp_version_eq() { - assert_eq!(SnmpVersion::V1, SnmpVersion::V1); - assert_eq!(SnmpVersion::V2c, SnmpVersion::V2c); - assert_ne!(SnmpVersion::V1, SnmpVersion::V2c); - } - - #[test] - fn test_snmp_trap_clone() { - let trap = SnmpTrap { - source_addr: "192.168.1.1:161".parse().unwrap(), - version: SnmpVersion::V1, - community: "public".to_string(), - trap_oid: "1.3.6.1.4.1.9".to_string(), - generic_trap: Some(GenericTrap::LinkUp), - specific_trap: Some(42), - uptime: 12345, - varbinds: vec![("oid".to_string(), "value".to_string())], - }; - - let cloned = trap.clone(); - assert_eq!(trap.source_addr, cloned.source_addr); - assert_eq!(trap.version, cloned.version); - assert_eq!(trap.community, cloned.community); - } - - #[test] - fn test_generic_trap_copy() { - let trap = GenericTrap::WarmStart; - let copied = trap; - assert!(matches!(copied, GenericTrap::WarmStart)); - } - - #[test] - fn test_snmp_version_copy() { - let version = SnmpVersion::V2c; - let copied = version; - assert_eq!(copied, SnmpVersion::V2c); - } - - // Test full SNMPv1 trap parsing with a minimal but valid trap packet - #[test] - fn test_parse_trap_v1() { - // Build a valid SNMPv1 trap packet - // Inner Trap-PDU contents: - // OID 1.3 (enterprise): 06 01 2B - // IpAddress 10.0.0.1: 40 04 0A 00 00 01 - // INTEGER 0 (generic): 02 01 00 - // INTEGER 0 (specific): 02 01 00 - // TimeTicks 0: 43 01 00 - // SEQUENCE {} (varbinds): 30 00 - // Total Trap-PDU content = 3 + 6 + 3 + 3 + 3 + 2 = 20 bytes - let trap_pdu: Vec = vec![ - 0x06, 0x01, 0x2B, // OID 1.3 - 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 - 0x02, 0x01, 0x00, // INTEGER 0 (generic-trap) - 0x02, 0x01, 0x00, // INTEGER 0 (specific-trap) - 0x43, 0x01, 0x00, // TimeTicks 0 - 0x30, 0x00, // SEQUENCE {} (varbinds) - ]; - - // Full message: - // SEQUENCE { version, community, Trap-PDU } - // version: 02 01 00 (3 bytes) - // community "pub": 04 03 70 75 62 (5 bytes) - // Trap-PDU [4]: A4 - let mut trap_bytes: Vec = vec![ - 0x30, - 0x00, // SEQUENCE with placeholder length - 0x02, - 0x01, - 0x00, // INTEGER 0 (version SNMPv1) - 0x04, - 0x03, - 0x70, - 0x75, - 0x62, // OCTET STRING "pub" - 0xA4, - trap_pdu.len() as u8, // Trap-PDU tag with length - ]; - trap_bytes.extend_from_slice(&trap_pdu); - // Fix outer SEQUENCE length - trap_bytes[1] = (trap_bytes.len() - 2) as u8; - - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_ok(), "Expected Ok, got {:?}", result); - - let trap = result.unwrap(); - assert_eq!(trap.version, SnmpVersion::V1); - assert_eq!(trap.community, "pub"); - assert!(trap.generic_trap.is_some()); - } - - // Test full SNMPv2c trap parsing - #[test] - fn test_parse_trap_v2c() { - // Build a valid SNMPv2c trap packet - // Inner SNMPv2-Trap-PDU contents: - // INTEGER 1 (request-id): 02 01 01 - // INTEGER 0 (error-status): 02 01 00 - // INTEGER 0 (error-index): 02 01 00 - // SEQUENCE {} (varbinds): 30 00 - // Total PDU content = 3 + 3 + 3 + 2 = 11 bytes - let trap_pdu: Vec = vec![ - 0x02, 0x01, 0x01, // INTEGER 1 (request-id) - 0x02, 0x01, 0x00, // INTEGER 0 (error-status) - 0x02, 0x01, 0x00, // INTEGER 0 (error-index) - 0x30, 0x00, // SEQUENCE {} (varbinds) - ]; - - // Full message: - // SEQUENCE { version, community, SNMPv2-Trap-PDU } - let mut trap_bytes: Vec = vec![ - 0x30, - 0x00, // SEQUENCE with placeholder length - 0x02, - 0x01, - 0x01, // INTEGER 1 (version SNMPv2c) - 0x04, - 0x03, - 0x70, - 0x75, - 0x62, // OCTET STRING "pub" - 0xA7, - trap_pdu.len() as u8, // SNMPv2-Trap-PDU tag with length - ]; - trap_bytes.extend_from_slice(&trap_pdu); - // Fix outer SEQUENCE length - trap_bytes[1] = (trap_bytes.len() - 2) as u8; - - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_ok(), "Expected Ok, got {:?}", result); - - let trap = result.unwrap(); - assert_eq!(trap.version, SnmpVersion::V2c); - assert_eq!(trap.community, "pub"); - } - - #[test] - fn test_parse_trap_invalid_outer_sequence() { - // Not a SEQUENCE - let trap_bytes: Vec = vec![0x02, 0x01, 0x00]; - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_trap_unsupported_version() { - // SEQUENCE with version 2 (not v1=0 or v2c=1) - let trap_bytes: Vec = vec![ - 0x30, 0x0A, // INTEGER 2 (unsupported) - 0x02, 0x01, 0x02, // OCTET STRING "pub" - 0x04, 0x03, 0x70, 0x75, 0x62, // Minimal PDU - 0xA7, 0x00, - ]; - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_trap_invalid_version_tag() { - // Version is not an INTEGER - let trap_bytes: Vec = vec![ - 0x30, 0x08, // OCTET STRING instead of INTEGER for version - 0x04, 0x01, 0x00, // OCTET STRING "pub" - 0x04, 0x03, 0x70, 0x75, 0x62, - ]; - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_trap_invalid_community_tag() { - // Community is not an OCTET STRING - let trap_bytes: Vec = vec![ - 0x30, 0x08, // INTEGER 0 (version) - 0x02, 0x01, 0x00, // INTEGER instead of OCTET STRING for community - 0x02, 0x01, 0x00, // PDU - 0xA4, 0x00, - ]; - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_trap_v1_invalid_pdu_tag() { - // V1 with wrong PDU tag (should be 0xA4) - let trap_bytes: Vec = vec![ - 0x30, 0x0B, // INTEGER 0 (version v1) - 0x02, 0x01, 0x00, // OCTET STRING "pub" - 0x04, 0x03, 0x70, 0x75, 0x62, // Wrong PDU tag (0xA7 is v2c) - 0xA7, 0x00, - ]; - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_trap_v2c_invalid_pdu_tag() { - // V2c with wrong PDU tag (should be 0xA7) - let trap_bytes: Vec = vec![ - 0x30, 0x0B, // INTEGER 1 (version v2c) - 0x02, 0x01, 0x01, // OCTET STRING "pub" - 0x04, 0x03, 0x70, 0x75, 0x62, // Wrong PDU tag (0xA4 is v1) - 0xA4, 0x00, - ]; - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&trap_bytes, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_varbinds_invalid_varbind_tag() { - // Varbind is not a SEQUENCE - let data: Vec = vec![ - // INTEGER instead of SEQUENCE - 0x02, 0x01, 0x00, - ]; - let result = parse_varbinds(&data); - assert!(result.is_err()); - } - - #[test] - fn test_parse_varbinds_invalid_oid_tag() { - // Valid SEQUENCE but OID is wrong type - let data: Vec = vec![ - // SEQUENCE - 0x30, 0x06, // INTEGER instead of OID - 0x02, 0x01, 0x00, // NULL value - 0x05, 0x00, - ]; - let result = parse_varbinds(&data); - assert!(result.is_err()); - } - - #[test] - fn test_parse_varbinds_valid() { - // Valid varbind: SEQUENCE { OID 1.3, INTEGER 42 } - let data: Vec = vec![ - // SEQUENCE - 0x30, 0x06, // OID 1.3 - 0x06, 0x01, 0x2B, // INTEGER 42 - 0x02, 0x01, 0x2A, - ]; - let result = parse_varbinds(&data).unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].0, "1.3"); - assert_eq!(result[0].1, "42"); - } - - #[test] - fn test_parse_value_to_string_ip_address_invalid() { - // IP address with wrong length - let result = parse_value_to_string(ber_tags::IP_ADDRESS, &[192, 168, 1]); - assert_eq!(result, "?"); - } - - #[test] - fn test_parse_value_to_string_oid_empty() { - let result = parse_value_to_string(ber_tags::OBJECT_IDENTIFIER, &[]); - assert_eq!(result, ""); - } - - #[test] - fn test_parse_length_four_bytes() { - // Four-byte length: 0x84 followed by 4 bytes - let data = [0x84, 0x00, 0x00, 0x01, 0x00]; - let result = parse_length(&data); - assert!(result.is_ok()); - let (length, consumed) = result.unwrap(); - assert_eq!(length, 256); - assert_eq!(consumed, 5); - } - - #[test] - fn test_parse_integer_negative_multibyte() { - // -1 as two bytes: 0xFF 0xFF - assert_eq!(parse_integer(&[0xFF, 0xFF]).unwrap(), -1); - // -128 as single byte - assert_eq!(parse_integer(&[0x80]).unwrap(), -128); - // -129 as two bytes: 0xFF 0x7F - assert_eq!(parse_integer(&[0xFF, 0x7F]).unwrap(), -129); - } - - #[test] - fn test_parse_unsigned_large() { - // Large counter value - let data = [0x01, 0x00, 0x00, 0x00]; - let result = parse_unsigned(&data).unwrap(); - assert_eq!(result, 16777216); - } - - // Helper to build a minimal v1 trap with custom PDU content - fn build_v1_trap_packet(pdu_content: &[u8]) -> Vec { - let mut packet = vec![ - 0x30, - 0x00, // SEQUENCE placeholder - 0x02, - 0x01, - 0x00, // INTEGER 0 (version v1) - 0x04, - 0x03, - 0x70, - 0x75, - 0x62, // OCTET STRING "pub" - 0xA4, - pdu_content.len() as u8, // Trap-PDU - ]; - packet.extend_from_slice(pdu_content); - packet[1] = (packet.len() - 2) as u8; - packet - } - - // Helper to build a minimal v2c trap with custom PDU content - fn build_v2c_trap_packet(pdu_content: &[u8]) -> Vec { - let mut packet = vec![ - 0x30, - 0x00, // SEQUENCE placeholder - 0x02, - 0x01, - 0x01, // INTEGER 1 (version v2c) - 0x04, - 0x03, - 0x70, - 0x75, - 0x62, // OCTET STRING "pub" - 0xA7, - pdu_content.len() as u8, // SNMPv2-Trap-PDU - ]; - packet.extend_from_slice(pdu_content); - packet[1] = (packet.len() - 2) as u8; - packet - } - - #[test] - fn test_parse_v1_trap_invalid_enterprise_tag() { - // Enterprise should be OID, not INTEGER - let pdu = vec![ - 0x02, 0x01, 0x00, // INTEGER instead of OID - ]; - let packet = build_v1_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v1_trap_invalid_agent_addr_tag() { - // Agent addr should be IP_ADDRESS - let pdu = vec![ - 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) - 0x02, 0x01, 0x00, // INTEGER instead of IpAddress - ]; - let packet = build_v1_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v1_trap_invalid_generic_trap_tag() { - // Generic trap should be INTEGER - let pdu = vec![ - 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) - 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 - 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER - ]; - let packet = build_v1_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v1_trap_invalid_specific_trap_tag() { - // Specific trap should be INTEGER - let pdu = vec![ - 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) - 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 - 0x02, 0x01, 0x00, // INTEGER 0 (generic) - 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER - ]; - let packet = build_v1_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v1_trap_invalid_timestamp_tag() { - // Timestamp should be TIMETICKS - let pdu = vec![ - 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) - 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 - 0x02, 0x01, 0x00, // INTEGER 0 (generic) - 0x02, 0x01, 0x00, // INTEGER 0 (specific) - 0x02, 0x01, 0x00, // INTEGER instead of TIMETICKS - ]; - let packet = build_v1_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v1_trap_invalid_varbinds_tag() { - // Varbinds should be SEQUENCE - let pdu = vec![ - 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) - 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 - 0x02, 0x01, 0x00, // INTEGER 0 (generic) - 0x02, 0x01, 0x00, // INTEGER 0 (specific) - 0x43, 0x01, 0x00, // TimeTicks 0 - 0x02, 0x01, 0x00, // INTEGER instead of SEQUENCE - ]; - let packet = build_v1_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v2c_trap_invalid_request_id_tag() { - // Request-id should be INTEGER - let pdu = vec![ - 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER - ]; - let packet = build_v2c_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v2c_trap_invalid_error_status_tag() { - // Error-status should be INTEGER - let pdu = vec![ - 0x02, 0x01, 0x01, // INTEGER 1 (request-id) - 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER - ]; - let packet = build_v2c_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v2c_trap_invalid_error_index_tag() { - // Error-index should be INTEGER - let pdu = vec![ - 0x02, 0x01, 0x01, // INTEGER 1 (request-id) - 0x02, 0x01, 0x00, // INTEGER 0 (error-status) - 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER - ]; - let packet = build_v2c_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } - - #[test] - fn test_parse_v2c_trap_invalid_varbinds_tag() { - // Varbinds should be SEQUENCE - let pdu = vec![ - 0x02, 0x01, 0x01, // INTEGER 1 (request-id) - 0x02, 0x01, 0x00, // INTEGER 0 (error-status) - 0x02, 0x01, 0x00, // INTEGER 0 (error-index) - 0x02, 0x01, 0x00, // INTEGER instead of SEQUENCE - ]; - let packet = build_v2c_trap_packet(&pdu); - let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); - let result = parse_trap(&packet, source_addr); - assert!(result.is_err()); - } -} diff --git a/src/snmp/types.rs b/src/snmp/types.rs deleted file mode 100644 index 339dd88..0000000 --- a/src/snmp/types.rs +++ /dev/null @@ -1,124 +0,0 @@ -#[derive(Debug)] -pub enum SnmpError { - RequestFailed(String), - InvalidOid(String), - Timeout, - NetworkUnreachable, - CrashRecovered { signal: i32, message: String }, -} - -impl std::fmt::Display for SnmpError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::RequestFailed(msg) => write!(f, "SNMP request failed: {}", msg), - Self::InvalidOid(oid) => write!(f, "Invalid OID: {}", oid), - Self::Timeout => write!(f, "Timeout"), - Self::NetworkUnreachable => write!(f, "Network unreachable"), - Self::CrashRecovered { signal, message } => { - write!(f, "SNMP crash recovered (signal {}): {}", signal, message) - } - } - } -} - -impl std::error::Error for SnmpError {} - -pub type SnmpResult = Result; - -/// SNMP value returned from a GET operation -#[allow(dead_code)] // Some variants' data not yet accessed directly -#[derive(Debug, Clone)] -pub enum SnmpValue { - Integer(i64), - String(String), - OctetString(Vec), - Oid(String), - Counter32(u32), - Counter64(u64), - Gauge32(u32), - TimeTicks(u32), - IpAddress(String), - Null, - Unsupported(String), -} - -impl SnmpValue { - #[allow(dead_code)] - pub fn as_i64(&self) -> Option { - match self { - SnmpValue::Integer(v) => Some(*v), - SnmpValue::Counter32(v) => Some(*v as i64), - SnmpValue::Counter64(v) => Some(*v as i64), - SnmpValue::Gauge32(v) => Some(*v as i64), - SnmpValue::TimeTicks(v) => Some(*v as i64), - _ => None, - } - } - - #[allow(dead_code)] - pub fn as_f64(&self) -> Option { - self.as_i64().map(|v| v as f64) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_snmp_error_display() { - assert_eq!( - format!("{}", SnmpError::RequestFailed("test error".to_string())), - "SNMP request failed: test error" - ); - assert_eq!( - format!("{}", SnmpError::InvalidOid("1.2.3".to_string())), - "Invalid OID: 1.2.3" - ); - assert_eq!(format!("{}", SnmpError::Timeout), "Timeout"); - assert_eq!( - format!("{}", SnmpError::NetworkUnreachable), - "Network unreachable" - ); - } - - #[test] - fn test_crash_recovered_display() { - let err = SnmpError::CrashRecovered { - signal: 11, - message: "child killed by SIGSEGV".to_string(), - }; - assert_eq!( - format!("{}", err), - "SNMP crash recovered (signal 11): child killed by SIGSEGV" - ); - } - - #[test] - fn test_snmp_error_is_error() { - let error: &dyn std::error::Error = &SnmpError::Timeout; - assert_eq!(format!("{}", error), "Timeout"); - } - - #[test] - fn test_snmp_value_as_i64() { - assert_eq!(SnmpValue::Integer(42).as_i64(), Some(42)); - assert_eq!(SnmpValue::Counter32(100).as_i64(), Some(100)); - assert_eq!(SnmpValue::Counter64(1000).as_i64(), Some(1000)); - assert_eq!(SnmpValue::Gauge32(50).as_i64(), Some(50)); - assert_eq!(SnmpValue::TimeTicks(200).as_i64(), Some(200)); - assert_eq!(SnmpValue::String("test".to_string()).as_i64(), None); - assert_eq!(SnmpValue::IpAddress("1.2.3.4".to_string()).as_i64(), None); - } - - #[test] - fn test_snmp_value_as_f64() { - assert_eq!(SnmpValue::Integer(42).as_f64(), Some(42.0)); - assert_eq!(SnmpValue::Counter32(100).as_f64(), Some(100.0)); - assert_eq!(SnmpValue::Counter64(1000).as_f64(), Some(1000.0)); - assert_eq!(SnmpValue::Gauge32(50).as_f64(), Some(50.0)); - assert_eq!(SnmpValue::TimeTicks(200).as_f64(), Some(200.0)); - assert_eq!(SnmpValue::String("test".to_string()).as_f64(), None); - assert_eq!(SnmpValue::IpAddress("1.2.3.4".to_string()).as_f64(), None); - } -} diff --git a/src/ssh/client.rs b/src/ssh/client.rs deleted file mode 100644 index b127654..0000000 --- a/src/ssh/client.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::secret::SecretString; -use russh::client; -use russh::keys::PublicKey; -use std::future::Future; -use std::sync::Arc; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum SshError { - #[error("SSH connection failed: {0}")] - ConnectionFailed(String), - #[error("SSH authentication failed")] - AuthenticationFailed, - #[error("SSH command execution failed: {0}")] - CommandFailed(String), - #[error("SSH I/O error: {0}")] - IoError(#[from] std::io::Error), - #[error("SSH protocol error: {0}")] - Protocol(#[from] russh::Error), -} - -pub type SshResult = Result; - -struct Client; - -impl client::Handler for Client { - type Error = russh::Error; - - fn check_server_key( - &mut self, - server_public_key: &PublicKey, - ) -> impl Future> + Send { - // Accept any server key (similar to SSH -o StrictHostKeyChecking=no) - // In production, you might want to verify against known_hosts - let _ = server_public_key; // Suppress unused warning - async { Ok(true) } - } -} - -pub struct SshClient { - session: client::Handle, -} - -impl SshClient { - /// Connect to an SSH server and authenticate with password - pub async fn connect( - host: &str, - port: u32, - username: &str, - password: &SecretString, - ) -> SshResult { - let config = client::Config::default(); - let sh = Client; - - tracing::debug!("Connecting to {}:{} as {}", host, port, username); - - let mut session = client::connect(Arc::new(config), (host, port as u16), sh) - .await - .map_err(|e| SshError::ConnectionFailed(e.to_string()))?; - - let auth_result = session - .authenticate_password(username, password.expose()) - .await - .map_err(|e| SshError::ConnectionFailed(e.to_string()))?; - - if !auth_result.success() { - return Err(SshError::AuthenticationFailed); - } - - tracing::debug!("SSH authentication successful"); - - Ok(Self { session }) - } - - /// Execute a command and return the output as a String - pub async fn execute_command(&mut self, command: &str) -> SshResult { - tracing::debug!("Executing SSH command: {}", command); - - let mut channel = self - .session - .channel_open_session() - .await - .map_err(|e| SshError::CommandFailed(e.to_string()))?; - - channel - .exec(true, command) - .await - .map_err(|e| SshError::CommandFailed(e.to_string()))?; - - let mut output = Vec::new(); - let mut stderr_output = Vec::new(); - - loop { - let Some(msg) = channel.wait().await else { - break; - }; - - match msg { - russh::ChannelMsg::Data { ref data } => { - output.extend_from_slice(data); - } - russh::ChannelMsg::ExtendedData { ref data, ext: 1 } => { - // stderr - stderr_output.extend_from_slice(data); - } - russh::ChannelMsg::ExitStatus { exit_status } => { - tracing::debug!("Command exit status: {}", exit_status); - if exit_status != 0 { - let stderr_str = String::from_utf8_lossy(&stderr_output); - return Err(SshError::CommandFailed(format!( - "Command exited with status {}: {}", - exit_status, stderr_str - ))); - } - } - russh::ChannelMsg::Eof => { - break; - } - _ => {} - } - } - - channel - .eof() - .await - .map_err(|e| SshError::CommandFailed(e.to_string()))?; - - channel - .close() - .await - .map_err(|e| SshError::CommandFailed(e.to_string()))?; - - let output_str = String::from_utf8_lossy(&output).to_string(); - tracing::debug!( - "Command output: {} bytes, {} lines", - output_str.len(), - output_str.lines().count() - ); - - Ok(output_str) - } - - /// Close the SSH session - pub async fn close(self) -> SshResult<()> { - self.session - .disconnect(russh::Disconnect::ByApplication, "", "") - .await?; - Ok(()) - } -} diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs deleted file mode 100644 index 00fc73c..0000000 --- a/src/ssh/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod client; - -pub use client::SshClient; diff --git a/src/version.rs b/src/version.rs deleted file mode 100644 index 7405e1c..0000000 --- a/src/version.rs +++ /dev/null @@ -1,45 +0,0 @@ -/// Get compile timestamp at runtime - returns RFC 3339 formatted timestamp from build.rs -/// Format: YYYY-MM-DDTHH:MM:SSZ (e.g., "2025-02-09T15:30:45Z") -pub fn current_version() -> &'static str { - option_env!("BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) -} - -/// Startup check - logs current version -pub fn check_for_updates() { - let current_ver = current_version(); - tracing::info!("Current version: {}", current_ver); - tracing::info!("Watchtower will automatically update to new versions"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_current_version() { - let version = current_version(); - assert!(!version.is_empty(), "Version should not be empty"); - // Version comes from env! macro at compile time - // Just verify it's a non-empty string - } - - #[test] - fn test_current_version_format() { - let version = current_version(); - // Version should be RFC 3339 timestamp format (YYYY-MM-DDTHH:MM:SSZ) - // Regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$ - let rfc3339_pattern = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$").unwrap(); - assert!( - rfc3339_pattern.is_match(version), - "Version should be RFC 3339 timestamp, got: {}", - version - ); - } - - #[test] - fn test_check_for_updates() { - // This function just logs, but we can call it to verify it doesn't panic - check_for_updates(); - // If we get here, the function completed without panicking - } -} diff --git a/src/websocket_client.rs b/src/websocket_client.rs deleted file mode 100644 index 18fb3ed..0000000 --- a/src/websocket_client.rs +++ /dev/null @@ -1,1580 +0,0 @@ -/// WebSocket-based agent client for Towerops. -/// -/// This replaces the complex REST API + polling architecture with a single -/// persistent WebSocket connection. The server sends SNMP query jobs as protobuf -/// messages, the agent executes raw SNMP queries, and sends results back. -/// -/// Connection URL: {url}/socket/agent/websocket -/// Authentication: Token sent in Phoenix channel join payload -use crate::secret::SecretString; -use futures::stream::SplitStream; -use futures::{SinkExt, StreamExt}; -use prost::Message; -use std::collections::HashMap; -use tokio::net::TcpStream; -use tokio::sync::{mpsc, watch}; -use tokio::time::{interval, timeout, Duration}; -use tokio_tungstenite::{ - connect_async, tungstenite::protocol::Message as WsMessage, MaybeTlsStream, WebSocketStream, -}; -use zeroize::Zeroize; - -/// Connection timeout for WebSocket establishment (30 seconds) -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); - -type Result = std::result::Result>; - -use crate::proto::agent::{ - AgentHeartbeat, AgentJob, AgentJobList, CredentialTestResult, JobType, MikrotikResult, - MikrotikSentence, MonitoringCheck, QueryType, SnmpResult, -}; -use crate::snmp::{DeviceConfig, PollerRegistry, SnmpValue}; - -/// Phoenix channel message format (JSON wrapper around binary protobuf). -#[derive(Debug, serde::Serialize, serde::Deserialize)] -struct PhoenixMessage { - topic: String, - event: String, - payload: serde_json::Value, - #[serde(rename = "ref")] - reference: Option, -} - -/// Channel capacity for result backpressure. If the WebSocket write side -/// falls behind, job tasks will slow down rather than consuming unbounded memory. -const RESULT_CHANNEL_CAPACITY: usize = 1000; - -/// Channel capacity for outgoing WebSocket messages routed through the writer task. -const WS_WRITE_CHANNEL_CAPACITY: usize = 500; - -/// WebSocket client for agent communication. -/// -/// The WebSocket stream is split into a read half (owned here) and a write half -/// (owned by a dedicated writer task). All outgoing messages are sent through -/// `ws_write_tx`, allowing reads and writes to proceed concurrently. -pub struct AgentClient { - ws_read: SplitStream>>, - ws_write_tx: mpsc::Sender, - agent_id: String, - result_tx: mpsc::Sender, - result_rx: mpsc::Receiver, - mikrotik_result_tx: mpsc::Sender, - mikrotik_result_rx: mpsc::Receiver, - credential_test_tx: mpsc::Sender, - credential_test_rx: mpsc::Receiver, - monitoring_check_tx: mpsc::Sender, - monitoring_check_rx: mpsc::Receiver, - poller_registry: PollerRegistry, - /// Counter for Phoenix transport heartbeat refs - phx_heartbeat_ref: u64, -} - -impl AgentClient { - /// Connect to Towerops server via WebSocket. - /// - /// # Arguments - /// * `url` - Server URL (e.g., "wss://towerops.net") - /// * `token` - Agent authentication token - pub async fn connect(url: &str, token: &SecretString) -> Result { - // Strip trailing slash from base URL to avoid double slashes - let base_url = url.trim_end_matches('/'); - let ws_url = format!("{}/socket/agent/websocket", base_url); - tracing::info!( - "Connecting to WebSocket: {} (timeout: {}s)", - ws_url, - CONNECTION_TIMEOUT.as_secs() - ); - - // Wrap connection in timeout to avoid hanging indefinitely on bad network - let (ws_stream, _) = match timeout(CONNECTION_TIMEOUT, connect_async(&ws_url)).await { - Ok(Ok(result)) => result, - Ok(Err(e)) => { - tracing::error!("WebSocket connection failed: {}", e); - return Err(format!("Failed to connect to WebSocket: {}", e).into()); - } - Err(_) => { - tracing::error!( - "WebSocket connection timed out after {}s", - CONNECTION_TIMEOUT.as_secs() - ); - return Err(format!( - "Connection timed out after {}s", - CONNECTION_TIMEOUT.as_secs() - ) - .into()); - } - }; - - tracing::info!("Connected to Towerops server at {}", url); - - let agent_id = generate_agent_id(); - let (result_tx, result_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY); - let (mikrotik_result_tx, mikrotik_result_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY); - let (credential_test_tx, credential_test_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY); - let (monitoring_check_tx, monitoring_check_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY); - - // Split the WebSocket stream so reads and writes can proceed concurrently. - // The write half is owned by a dedicated writer task. - let (ws_write, ws_read) = ws_stream.split(); - let (ws_write_tx, ws_write_rx) = mpsc::channel::(WS_WRITE_CHANNEL_CAPACITY); - - tokio::spawn(ws_writer_task(ws_write, ws_write_rx)); - - // Join Phoenix channel with token in payload - let join_msg = PhoenixMessage { - topic: format!("agent:{}", agent_id), - event: "phx_join".to_string(), - payload: serde_json::json!({"token": token.expose()}), - reference: Some("1".to_string()), - }; - - let join_text = serde_json::to_string(&join_msg)?; - ws_write_tx - .send(WsMessage::Text(join_text.into())) - .await - .map_err(|e| format!("Failed to send join message: {}", e))?; - tracing::debug!( - "Sent channel join request with token for agent:{}", - agent_id - ); - - Ok(Self { - ws_read, - ws_write_tx, - agent_id, - result_tx, - result_rx, - mikrotik_result_tx, - mikrotik_result_rx, - credential_test_tx, - credential_test_rx, - monitoring_check_tx, - monitoring_check_rx, - poller_registry: PollerRegistry::new(), - phx_heartbeat_ref: 0, - }) - } - - /// Main event loop for agent operation. - /// - /// Handles: - /// - Receiving jobs from server - /// - Executing SNMP queries - /// - Sending results back - /// - Periodic heartbeats - /// - Graceful shutdown on SIGTERM - pub async fn run(&mut self, mut shutdown_rx: watch::Receiver) -> Result<()> { - let mut heartbeat_interval = interval(Duration::from_secs(60)); - let mut phx_heartbeat_interval = interval(Duration::from_secs(25)); - - loop { - tokio::select! { - // Check for shutdown signal (highest priority) - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - tracing::info!("Shutdown signal received, closing WebSocket connection gracefully"); - // Send close frame through the writer task - let _ = self.ws_write_tx.send(WsMessage::Close(None)).await; - break Ok(()); - } - } - - // Receive messages from server - msg = self.ws_read.next() => { - match msg { - Some(Ok(WsMessage::Binary(data))) => { - if let Err(e) = self.handle_message(&data).await { - tracing::error!("Error handling binary message: {}", e); - } - } - Some(Ok(WsMessage::Text(text))) => { - if let Err(e) = self.handle_text_message(&text).await { - tracing::error!("Error handling text message: {}", e); - } - } - Some(Ok(WsMessage::Close(_))) => { - tracing::info!("Server closed connection"); - self.poller_registry.shutdown_all(); - break Ok(()); - } - Some(Err(e)) => { - tracing::error!("WebSocket error: {}", e); - self.poller_registry.shutdown_all(); - break Err(e.into()); - } - None => { - tracing::info!("Connection closed"); - self.poller_registry.shutdown_all(); - break Ok(()); - } - _ => {} - } - } - - // Receive SNMP results from job tasks - Some(snmp_result) = self.result_rx.recv() => { - if let Err(e) = self.send_snmp_result(snmp_result).await { - tracing::error!("Error sending SNMP result: {}", e); - } - } - - // Receive MikroTik results from job tasks - Some(mikrotik_result) = self.mikrotik_result_rx.recv() => { - if let Err(e) = self.send_mikrotik_result(mikrotik_result).await { - tracing::error!("Error sending MikroTik result: {}", e); - } - } - - // Receive credential test results from job tasks - Some(credential_test_result) = self.credential_test_rx.recv() => { - if let Err(e) = self.send_credential_test_result(credential_test_result).await { - tracing::error!("Error sending credential test result: {}", e); - } - } - - // Receive monitoring check results from job tasks - Some(monitoring_check) = self.monitoring_check_rx.recv() => { - if let Err(e) = self.send_monitoring_check(monitoring_check).await { - tracing::error!("Error sending monitoring check: {}", e); - } - } - - // Send periodic heartbeats - _ = heartbeat_interval.tick() => { - if let Err(e) = self.send_heartbeat().await { - tracing::error!("Error sending heartbeat: {}", e); - } - // Log active poller count - let count = self.poller_registry.count(); - if count > 0 { - tracing::debug!("Active device pollers: {}", count); - } - } - - // Send Phoenix transport heartbeats to keep connection alive - _ = phx_heartbeat_interval.tick() => { - if let Err(e) = self.send_phx_heartbeat().await { - tracing::error!("Error sending Phoenix heartbeat: {}", e); - } - } - } - } - } - - /// Handle Phoenix channel message (JSON-wrapped). - async fn handle_text_message(&mut self, text: &str) -> Result<()> { - let phoenix_msg: PhoenixMessage = serde_json::from_str(text)?; - - match phoenix_msg.event.as_str() { - "phx_reply" => { - tracing::debug!("Channel join reply: {:?}", phoenix_msg.payload); - } - // Handle all job events the same way - agent doesn't care about the context - "jobs" | "discovery_job" | "backup_job" => { - // Extract binary protobuf from payload - if let serde_json::Value::Object(map) = phoenix_msg.payload { - if let Some(serde_json::Value::String(binary_b64)) = map.get("binary") { - let binary = base64_decode(binary_b64)?; - let job_list = AgentJobList::decode(&binary[..])?; - self.handle_jobs(job_list).await?; - } - } - } - "restart" => { - tracing::info!("Restart requested by server, exiting for container restart"); - std::process::exit(0); - } - "update" => { - if let serde_json::Value::Object(map) = phoenix_msg.payload { - let url = map.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let checksum = map.get("checksum").and_then(|v| v.as_str()).unwrap_or(""); - - if url.is_empty() { - tracing::error!("Update requested but no URL provided"); - } else { - tracing::info!("Update requested, downloading from: {}", url); - match self_update(url, checksum).await { - Ok(()) => { - // self_update calls exec() which replaces the process - // If we get here, something went wrong - tracing::error!("Self-update returned unexpectedly"); - } - Err(e) => { - tracing::error!("Self-update failed: {}", e); - } - } - } - } - } - _ => { - tracing::debug!("Ignoring unknown event: {}", phoenix_msg.event); - } - } - - Ok(()) - } - - /// Handle binary protobuf message. - async fn handle_message(&self, data: &[u8]) -> Result<()> { - // Try to decode as AgentJobList - if let Ok(job_list) = AgentJobList::decode(data) { - self.handle_jobs(job_list).await?; - } - - Ok(()) - } - - /// Process job list from server. - /// - /// Each job is executed once in the background and results are sent back. - /// No long-running tasks are spawned - the agent is stateless. - /// Server handles all scheduling and retries via Oban. - async fn handle_jobs(&self, job_list: AgentJobList) -> Result<()> { - tracing::info!("Received {} jobs from server", job_list.jobs.len()); - - // Collect device IDs from current jobs - let mut current_device_ids = std::collections::HashSet::new(); - for job in &job_list.jobs { - current_device_ids.insert(job.device_id.clone()); - } - - // Clean up pollers for devices no longer in job list - let active_devices = self.poller_registry.list_devices(); - for device_id in active_devices { - if !current_device_ids.contains(&device_id) { - tracing::debug!( - "Removing poller for device no longer in job list: {}", - device_id - ); - self.poller_registry.remove(&device_id); - } - } - - for job in job_list.jobs { - let job_type = JobType::try_from(job.job_type).unwrap_or(JobType::Poll); - tracing::info!("Starting job: {} (type: {:?})", job.job_id, job_type); - - match job_type { - JobType::Mikrotik => { - // Execute MikroTik API job - let mikrotik_result_tx = self.mikrotik_result_tx.clone(); - - tokio::spawn(async move { - if let Err(e) = execute_mikrotik_job(job, mikrotik_result_tx).await { - tracing::error!("MikroTik job execution failed: {}", e); - } - }); - } - JobType::TestCredentials => { - // Execute credential test - let credential_test_tx = self.credential_test_tx.clone(); - - tokio::spawn(async move { - if let Err(e) = execute_credential_test(job, credential_test_tx).await { - tracing::error!("Credential test execution failed: {}", e); - } - }); - } - JobType::Ping => { - // Execute ICMP ping health check - let monitoring_check_tx = self.monitoring_check_tx.clone(); - - tokio::spawn(async move { - if let Err(e) = execute_ping_job(job, monitoring_check_tx).await { - tracing::error!("Ping job execution failed: {}", e); - } - }); - } - _ => { - // Execute SNMP job (discovery or polling) - let result_tx = self.result_tx.clone(); - let poller_registry = self.poller_registry.clone(); - - tokio::spawn(async move { - if let Err(e) = execute_snmp_job(job, result_tx, poller_registry).await { - tracing::error!("SNMP job execution failed: {}", e); - } - }); - } - } - } - - Ok(()) - } - - /// Send heartbeat to server. - async fn send_heartbeat(&mut self) -> Result<()> { - let heartbeat = AgentHeartbeat { - version: crate::version::current_version().to_string(), - hostname: String::new(), - uptime_seconds: get_uptime_seconds(), - ip_address: String::new(), - arch: std::env::consts::ARCH.to_string(), - }; - - let binary = heartbeat.encode_to_vec(); - - // Phoenix channel format - let msg = PhoenixMessage { - topic: format!("agent:{}", self.agent_id), - event: "heartbeat".to_string(), - payload: serde_json::json!({"binary": base64_encode(&binary)}), - reference: None, - }; - - let text = serde_json::to_string(&msg)?; - self.ws_write_tx - .send(WsMessage::Text(text.into())) - .await - .map_err(|e| format!("Writer task closed: {}", e))?; - - tracing::debug!("Sent heartbeat"); - Ok(()) - } - - /// Send Phoenix transport heartbeat to keep the WebSocket connection alive. - /// - /// This is separate from the application heartbeat. Phoenix's transport layer - /// expects periodic messages on the "phoenix" topic to detect dead connections. - async fn send_phx_heartbeat(&mut self) -> Result<()> { - self.phx_heartbeat_ref += 1; - - let msg = PhoenixMessage { - topic: "phoenix".to_string(), - event: "heartbeat".to_string(), - payload: serde_json::json!({}), - reference: Some(self.phx_heartbeat_ref.to_string()), - }; - - let text = serde_json::to_string(&msg)?; - self.ws_write_tx - .send(WsMessage::Text(text.into())) - .await - .map_err(|e| format!("Writer task closed: {}", e))?; - - tracing::debug!( - "Sent Phoenix transport heartbeat (ref: {})", - self.phx_heartbeat_ref - ); - Ok(()) - } - - /// Send SNMP results to server. - async fn send_snmp_result(&mut self, result: SnmpResult) -> Result<()> { - let binary = result.encode_to_vec(); - - let msg = PhoenixMessage { - topic: format!("agent:{}", self.agent_id), - event: "result".to_string(), - payload: serde_json::json!({"binary": base64_encode(&binary)}), - reference: None, - }; - - let text = serde_json::to_string(&msg)?; - self.ws_write_tx - .send(WsMessage::Text(text.into())) - .await - .map_err(|e| format!("Writer task closed: {}", e))?; - - tracing::info!( - "Completed SNMP job for device {} ({} OIDs)", - result.device_id, - result.oid_values.len() - ); - Ok(()) - } - - /// Send MikroTik results to server. - async fn send_mikrotik_result(&mut self, result: MikrotikResult) -> Result<()> { - let binary = result.encode_to_vec(); - - let msg = PhoenixMessage { - topic: format!("agent:{}", self.agent_id), - event: "mikrotik_result".to_string(), - payload: serde_json::json!({"binary": base64_encode(&binary)}), - reference: None, - }; - - let text = serde_json::to_string(&msg)?; - self.ws_write_tx - .send(WsMessage::Text(text.into())) - .await - .map_err(|e| format!("Writer task closed: {}", e))?; - - tracing::info!( - "Completed MikroTik job for device {} (job: {})", - result.device_id, - result.job_id - ); - Ok(()) - } - - /// Send credential test result to server. - async fn send_credential_test_result(&mut self, result: CredentialTestResult) -> Result<()> { - let binary = result.encode_to_vec(); - - let msg = PhoenixMessage { - topic: format!("agent:{}", self.agent_id), - event: "credential_test_result".to_string(), - payload: serde_json::json!({"binary": base64_encode(&binary)}), - reference: None, - }; - - let text = serde_json::to_string(&msg)?; - self.ws_write_tx - .send(WsMessage::Text(text.into())) - .await - .map_err(|e| format!("Writer task closed: {}", e))?; - - tracing::info!( - "Completed credential test (test_id: {}, success: {})", - result.test_id, - result.success - ); - Ok(()) - } - - /// Send monitoring check result to server. - async fn send_monitoring_check(&mut self, result: MonitoringCheck) -> Result<()> { - let binary = result.encode_to_vec(); - - let msg = PhoenixMessage { - topic: format!("agent:{}", self.agent_id), - event: "monitoring_check".to_string(), - payload: serde_json::json!({"binary": base64_encode(&binary)}), - reference: None, - }; - - let text = serde_json::to_string(&msg)?; - self.ws_write_tx - .send(WsMessage::Text(text.into())) - .await - .map_err(|e| format!("Writer task closed: {}", e))?; - - tracing::info!( - "Completed ping check for device {} (status: {})", - result.device_id, - result.status - ); - Ok(()) - } -} - -/// Dedicated writer task that owns the WebSocket write half. -/// -/// All outgoing messages are funnelled through an mpsc channel, allowing the -/// main event loop to continue reading while writes are in progress. -async fn ws_writer_task( - mut ws_sink: futures::stream::SplitSink>, WsMessage>, - mut rx: mpsc::Receiver, -) { - while let Some(msg) = rx.recv().await { - let is_close = matches!(msg, WsMessage::Close(_)); - if let Err(e) = ws_sink.send(msg).await { - tracing::error!("WebSocket write error: {}", e); - break; - } - if is_close { - break; - } - } - tracing::debug!("WebSocket writer task stopped"); -} - -/// Redact SNMP community string for logging. -fn redact_community(community: &str) -> &'static str { - if community.is_empty() { - "(empty)" - } else { - "***" - } -} - -/// Execute an SNMP job and collect results. -async fn execute_snmp_job( - job: AgentJob, - result_tx: mpsc::Sender, - poller_registry: PollerRegistry, -) -> Result<()> { - let mut snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?; - - // Build v3 config if version is "3" - let v3_config = if snmp_device.version == "3" { - let config = crate::snmp::V3Config { - username: snmp_device.v3_username.clone(), - auth_password: if !snmp_device.v3_auth_password.is_empty() { - Some(zeroize::Zeroizing::new( - snmp_device.v3_auth_password.clone(), - )) - } else { - None - }, - priv_password: if !snmp_device.v3_priv_password.is_empty() { - Some(zeroize::Zeroizing::new( - snmp_device.v3_priv_password.clone(), - )) - } else { - None - }, - auth_protocol: if !snmp_device.v3_auth_protocol.is_empty() { - Some(snmp_device.v3_auth_protocol.clone()) - } else { - None - }, - priv_protocol: if !snmp_device.v3_priv_protocol.is_empty() { - Some(snmp_device.v3_priv_protocol.clone()) - } else { - None - }, - security_level: snmp_device.v3_security_level.clone(), - }; - - Some(config) - } else { - None - }; - - // Log SNMP connection parameters for debugging (mask community for security) - let community_masked = redact_community(&snmp_device.community); - - tracing::debug!( - "Executing SNMP job for device {} at {}:{} (community: {}, version: {})", - job.device_id, - snmp_device.ip, - snmp_device.port, - community_masked, - snmp_device.version - ); - - // Build device config and get or create persistent poller - let device_config = DeviceConfig { - ip: snmp_device.ip.clone(), - port: snmp_device.port as u16, - version: snmp_device.version.clone(), - community: SecretString::new(snmp_device.community.clone()), - v3_config, - transport: if snmp_device.transport.is_empty() { - "udp".to_string() - } else { - snmp_device.transport.clone() - }, - }; - - // Zeroize credentials in protobuf message after extraction - snmp_device.community.zeroize(); - snmp_device.v3_auth_password.zeroize(); - snmp_device.v3_priv_password.zeroize(); - - let poller = poller_registry.get_or_create(job.device_id.clone(), device_config); - - let mut oid_values: HashMap = HashMap::new(); - - for query in job.queries { - let query_type = QueryType::try_from(query.query_type).unwrap_or(QueryType::Get); - - match query_type { - QueryType::Get => { - // Execute SNMP GET for each OID - for oid in &query.oids { - match poller.get(oid.clone()).await { - Ok(value) => { - oid_values.insert(oid.clone(), value_to_string(value)); - } - Err(e) => { - tracing::warn!( - "SNMP GET failed for device {} at {}:{} (version: {}, community: {}), OID {}: {}", - job.device_id, - snmp_device.ip, - snmp_device.port, - snmp_device.version, - community_masked, - oid, - e - ); - } - } - } - } - QueryType::Walk => { - // Execute SNMP WALK for each base OID - for base_oid in &query.oids { - match poller.walk(base_oid.clone()).await { - Ok(results) => { - for (oid, value) in results { - oid_values.insert(oid, value_to_string(value)); - } - } - Err(e) => { - tracing::warn!( - "SNMP WALK failed for device {} at {}:{} (version: {}, community: {}), OID {}: {}", - job.device_id, - snmp_device.ip, - snmp_device.port, - snmp_device.version, - community_masked, - base_oid, - e - ); - } - } - } - } - } - } - - // Build result - let result = SnmpResult { - device_id: job.device_id.clone(), - job_type: job.job_type, - job_id: job.job_id.clone(), - oid_values, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() as i64, - }; - - tracing::info!( - "Collected {} OID values for job {}", - result.oid_values.len(), - job.job_id - ); - - // Send result back to main client task - if let Err(e) = result_tx.send(result).await { - tracing::warn!( - "Failed to send SNMP result for job {}: channel closed (connection may have dropped)", - job.job_id - ); - return Err(format!("Result channel closed: {}", e).into()); - } - - Ok(()) -} - -/// Execute a credential test job. -/// -/// Tests SNMP credentials by performing a simple GET on sysDescr.0. -/// Returns success with system description or failure with error message. -async fn execute_credential_test( - job: AgentJob, - result_tx: mpsc::Sender, -) -> Result<()> { - let mut snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?; - - tracing::debug!( - "Testing SNMP credentials for {}:{} (version: {})", - snmp_device.ip, - snmp_device.port, - snmp_device.version - ); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() as i64; - - // Build v3 config if version is "3" - let v3_config = if snmp_device.version == "3" { - Some(crate::snmp::V3Config { - username: snmp_device.v3_username.clone(), - auth_password: if !snmp_device.v3_auth_password.is_empty() { - Some(zeroize::Zeroizing::new( - snmp_device.v3_auth_password.clone(), - )) - } else { - None - }, - priv_password: if !snmp_device.v3_priv_password.is_empty() { - Some(zeroize::Zeroizing::new( - snmp_device.v3_priv_password.clone(), - )) - } else { - None - }, - auth_protocol: if !snmp_device.v3_auth_protocol.is_empty() { - Some(snmp_device.v3_auth_protocol.clone()) - } else { - None - }, - priv_protocol: if !snmp_device.v3_priv_protocol.is_empty() { - Some(snmp_device.v3_priv_protocol.clone()) - } else { - None - }, - security_level: snmp_device.v3_security_level.clone(), - }) - } else { - None - }; - - // Zeroize credentials in protobuf message after extraction - snmp_device.community.zeroize(); - snmp_device.v3_auth_password.zeroize(); - snmp_device.v3_priv_password.zeroize(); - - // Create a temporary SNMP client for testing (don't use persistent poller) - let snmp_client = crate::snmp::SnmpClient::new(); - - // Test with sysDescr.0 (standard system description OID) - let test_oid = "1.3.6.1.2.1.1.1.0".to_string(); - - let result = match snmp_client - .get( - &snmp_device.ip, - &snmp_device.community, - &snmp_device.version, - snmp_device.port as u16, - &test_oid, - v3_config, - ) - .await - { - Ok(value) => { - let sys_descr = value_to_string(value); - tracing::debug!("✓ Credential test succeeded: {}", sys_descr); - - CredentialTestResult { - test_id: job.job_id.clone(), - success: true, - error_message: String::new(), - system_description: sys_descr, - timestamp, - } - } - Err(e) => { - let error_msg = format!("SNMP test failed: {}", e); - tracing::warn!("✗ Credential test failed: {}", error_msg); - - CredentialTestResult { - test_id: job.job_id.clone(), - success: false, - error_message: error_msg, - system_description: String::new(), - timestamp, - } - } - }; - - // Send result back to main client task - if let Err(e) = result_tx.send(result).await { - tracing::warn!( - "Failed to send credential test result for job {}: channel closed", - job.job_id - ); - return Err(format!("Result channel closed: {}", e).into()); - } - - Ok(()) -} - -/// Execute a ping job using ICMP ping to check device health. -async fn execute_ping_job(job: AgentJob, result_tx: mpsc::Sender) -> Result<()> { - let device_id = job.device_id.clone(); - let snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?; - let ip_address = &snmp_device.ip; - - // Use 5-second timeout for pings (same as Phoenix DeviceMonitorWorker) - let timeout_ms = 5000; - - tracing::debug!( - "Executing health check for device {} at {}", - device_id, - ip_address - ); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() as i64; - - // Execute ping - let result = match crate::ping::ping_device(ip_address, timeout_ms).await { - Ok(response_time_ms) => { - tracing::debug!( - "✓ Device {} is up (response time: {:.1}ms)", - device_id, - response_time_ms - ); - - MonitoringCheck { - device_id: device_id.clone(), - status: "success".to_string(), - response_time_ms, - timestamp, - } - } - Err(e) => { - tracing::warn!("✗ Device {} is down: {}", device_id, e); - - MonitoringCheck { - device_id: device_id.clone(), - status: "failure".to_string(), - response_time_ms: 0.0, - timestamp, - } - } - }; - - // Send result back to main client task - if let Err(e) = result_tx.send(result).await { - tracing::warn!( - "Failed to send monitoring check for device {}: channel closed", - device_id - ); - return Err(format!("Result channel closed: {}", e).into()); - } - - Ok(()) -} - -/// Execute a MikroTik API job and collect results. -async fn execute_mikrotik_job( - job: AgentJob, - result_tx: mpsc::Sender, -) -> Result<()> { - use crate::mikrotik::MikrotikClient; - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() as i64; - - // Check if this is a backup job (job_id starts with "backup:") - // Backup jobs use SSH instead of API because /export doesn't work via API - if job.job_id.starts_with("backup:") { - let mikrotik_device = job - .mikrotik_device - .clone() - .ok_or("Job missing MikroTik device info")?; - return execute_mikrotik_backup_via_ssh(job, mikrotik_device, result_tx, timestamp).await; - } - - let mut mikrotik_device = job - .mikrotik_device - .ok_or("Job missing MikroTik device info")?; - - tracing::debug!( - "Executing MikroTik job {} for device {} at {}:{} (ssl: {})", - job.job_id, - job.device_id, - mikrotik_device.ip, - mikrotik_device.port, - mikrotik_device.use_ssl - ); - - let password = SecretString::new(&mikrotik_device.password); - - // Connect and authenticate to MikroTik RouterOS API - let mut client = if mikrotik_device.use_ssl { - match MikrotikClient::connect( - &mikrotik_device.ip, - mikrotik_device.port as u16, - &mikrotik_device.username, - &password, - ) - .await - { - Ok(client) => client, - Err(e) => { - let result = MikrotikResult { - device_id: job.device_id, - job_id: job.job_id, - sentences: vec![], - error: format!("Connection failed: {}", e), - timestamp, - }; - let _ = result_tx.send(result).await; - return Err(format!("MikroTik connection failed: {}", e).into()); - } - } - } else { - match MikrotikClient::connect_plain( - &mikrotik_device.ip, - mikrotik_device.port as u16, - &mikrotik_device.username, - &password, - ) - .await - { - Ok(client) => client, - Err(e) => { - let result = MikrotikResult { - device_id: job.device_id, - job_id: job.job_id, - sentences: vec![], - error: format!("Connection failed: {}", e), - timestamp, - }; - let _ = result_tx.send(result).await; - return Err(format!("MikroTik connection failed: {}", e).into()); - } - } - }; - - // Zeroize credentials in protobuf message after extraction - mikrotik_device.password.zeroize(); - - // Execute each command and collect results - let mut all_sentences = Vec::new(); - let mut error_message = String::new(); - - for cmd in &job.mikrotik_commands { - // Convert HashMap to Vec<(&str, &str)> for the client API - let args: Vec<(&str, &str)> = cmd - .args - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - - tracing::debug!( - "Executing MikroTik command '{}' with {} args: {:?}", - cmd.command, - args.len(), - args - ); - - match client.execute(&cmd.command, &args).await { - Ok(response) => { - // Check for error in response - if let Some(err) = response.error { - error_message = format!("Command '{}' error: {}", cmd.command, err); - tracing::error!( - "MikroTik command error for device {}: {}", - job.device_id, - error_message - ); - break; - } - - tracing::debug!( - "Command '{}' returned {} sentences", - cmd.command, - response.sentences.len() - ); - - // Convert sentences to protobuf format and log attribute keys - for (idx, sentence) in response.sentences.iter().enumerate() { - let attr_keys: Vec<&String> = sentence.attributes.keys().collect(); - let total_size: usize = sentence.attributes.values().map(|v| v.len()).sum(); - - tracing::debug!( - "Sentence {}: {} attributes ({} bytes total): {:?}", - idx, - sentence.attributes.len(), - total_size, - attr_keys - ); - - // Log when we hit EOF during /file/read - if cmd.command == "/file/read" { - if let Some(data) = sentence.attributes.get("data") { - if data.is_empty() { - tracing::debug!("Reached end of file (empty chunk)"); - } - } - } - - all_sentences.push(MikrotikSentence { - attributes: sentence.attributes.clone(), - }); - } - } - Err(e) => { - error_message = format!("Command '{}' failed: {}", cmd.command, e); - tracing::error!( - "MikroTik command failed for device {}: {}", - job.device_id, - error_message - ); - break; - } - } - } - - // Build and send result - let result = MikrotikResult { - device_id: job.device_id, - job_id: job.job_id, - sentences: all_sentences, - error: error_message, - timestamp, - }; - - tracing::debug!( - "MikroTik job {} completed with {} sentences", - result.job_id, - result.sentences.len() - ); - - let job_id_for_error = result.job_id.clone(); - if let Err(e) = result_tx.send(result).await { - tracing::warn!( - "Failed to send MikroTik result for job {}: channel closed", - job_id_for_error - ); - return Err(format!("Result channel closed: {}", e).into()); - } - - Ok(()) -} - -/// Execute a MikroTik backup job via SSH (because /export doesn't work via API). -async fn execute_mikrotik_backup_via_ssh( - job: AgentJob, - mut mikrotik_device: crate::proto::agent::MikrotikDevice, - result_tx: mpsc::Sender, - timestamp: i64, -) -> Result<()> { - use crate::ssh::SshClient; - - tracing::debug!( - "Executing backup via SSH for device {} at {}:{} (job: {})", - job.device_id, - mikrotik_device.ip, - mikrotik_device.ssh_port, - job.job_id - ); - - let password = SecretString::new(mikrotik_device.password.clone()); - - // Connect via SSH - let mut ssh_client = match SshClient::connect( - &mikrotik_device.ip, - mikrotik_device.ssh_port, - &mikrotik_device.username, - &password, - ) - .await - { - Ok(client) => client, - Err(e) => { - let error_msg = format!("SSH connection failed: {}", e); - tracing::error!("{}", error_msg); - let result = MikrotikResult { - device_id: job.device_id, - job_id: job.job_id, - sentences: vec![], - error: error_msg, - timestamp, - }; - let _ = result_tx.send(result).await; - return Err(format!("SSH connection failed: {}", e).into()); - } - }; - - // Execute /export compact command - let config = match ssh_client.execute_command("/export compact").await { - Ok(output) => output, - Err(e) => { - let error_msg = format!("SSH command failed: {}", e); - tracing::error!("{}", error_msg); - let result = MikrotikResult { - device_id: job.device_id, - job_id: job.job_id, - sentences: vec![], - error: error_msg, - timestamp, - }; - let _ = result_tx.send(result).await; - let _ = ssh_client.close().await; - return Err(format!("SSH command failed: {}", e).into()); - } - }; - - // Close SSH connection - let _ = ssh_client.close().await; - - // Zeroize credentials in protobuf message after use - mikrotik_device.password.zeroize(); - - tracing::debug!( - "Backup completed: {} bytes, {} lines", - config.len(), - config.lines().count() - ); - - // Return the config as a single sentence with "config" attribute - let mut attributes = std::collections::HashMap::new(); - attributes.insert("config".to_string(), config); - - let job_id_for_log = job.job_id.clone(); - - let result = MikrotikResult { - device_id: job.device_id, - job_id: job.job_id, - sentences: vec![MikrotikSentence { attributes }], - error: String::new(), - timestamp, - }; - - tracing::debug!( - "MikroTik backup job {} completed successfully", - result.job_id - ); - - if let Err(e) = result_tx.send(result).await { - tracing::warn!( - "Failed to send MikroTik backup result for job {}: channel closed", - job_id_for_log - ); - return Err(format!("Result channel closed: {}", e).into()); - } - - Ok(()) -} - -/// Convert SnmpValue to String for protobuf transmission. -fn value_to_string(value: SnmpValue) -> String { - match value { - SnmpValue::Integer(i) => i.to_string(), - SnmpValue::String(s) => s, - SnmpValue::OctetString(bytes) => { - // Convert to hex string for non-printable data - bytes - .iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join(":") - } - SnmpValue::Oid(oid) => oid, - SnmpValue::Counter32(c) => c.to_string(), - SnmpValue::Counter64(c) => c.to_string(), - SnmpValue::Gauge32(g) => g.to_string(), - SnmpValue::TimeTicks(t) => t.to_string(), - SnmpValue::IpAddress(ip) => ip, - SnmpValue::Null => "null".to_string(), - SnmpValue::Unsupported(s) => s, - } -} - -/// Base64 encode bytes to string. -fn base64_encode(data: &[u8]) -> String { - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4); - - for chunk in data.chunks(3) { - let mut buf = [0u8; 3]; - for (i, &byte) in chunk.iter().enumerate() { - buf[i] = byte; - } - - result.push(ALPHABET[((buf[0] >> 2) & 0x3F) as usize]); - result.push(ALPHABET[(((buf[0] << 4) | (buf[1] >> 4)) & 0x3F) as usize]); - result.push(if chunk.len() > 1 { - ALPHABET[(((buf[1] << 2) | (buf[2] >> 6)) & 0x3F) as usize] - } else { - b'=' - }); - result.push(if chunk.len() > 2 { - ALPHABET[(buf[2] & 0x3F) as usize] - } else { - b'=' - }); - } - - String::from_utf8(result).unwrap() -} - -/// Base64 decode string to bytes. -fn base64_decode(encoded: &str) -> Result> { - let mut decode_map = [0xFF; 256]; - for (i, &byte) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - .iter() - .enumerate() - { - decode_map[byte as usize] = i as u8; - } - - let input = encoded.as_bytes(); - let mut result = Vec::with_capacity((input.len() / 4) * 3); - - for chunk in input.chunks(4) { - if chunk.len() < 4 { - break; - } - - let mut buf = [0u8; 4]; - for (i, &byte) in chunk.iter().enumerate() { - if byte == b'=' { - buf[i] = 0; - } else { - let val = decode_map[byte as usize]; - if val == 0xFF { - return Err("Invalid base64 character".into()); - } - buf[i] = val; - } - } - - result.push((buf[0] << 2) | (buf[1] >> 4)); - if chunk[2] != b'=' { - result.push((buf[1] << 4) | (buf[2] >> 2)); - } - if chunk[3] != b'=' { - result.push((buf[2] << 6) | buf[3]); - } - } - - Ok(result) -} - -/// Generate a unique agent ID. -fn generate_agent_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - format!("agent-{}", timestamp) -} - -/// Get system uptime in seconds. -fn get_uptime_seconds() -> u64 { - // Linux: read /proc/uptime (format: "uptime idle") - if let Ok(uptime_str) = std::fs::read_to_string("/proc/uptime") { - if let Some(uptime) = uptime_str.split_whitespace().next() { - if let Ok(secs) = uptime.parse::() { - return secs as u64; - } - } - } - - // Fallback - 0 -} - -/// Download a new binary from the given URL, verify its SHA256 checksum, -/// and replace the current process via exec(). -async fn self_update(url: &str, expected_checksum: &str) -> Result<()> { - use sha2::{Digest, Sha256}; - use std::io::Write; - - // Download binary to temp file - tracing::info!("Downloading update binary..."); - let response = reqwest::get(url).await?; - - if !response.status().is_success() { - return Err(format!("Download failed with status: {}", response.status()).into()); - } - - let bytes = response.bytes().await?; - tracing::info!("Downloaded {} bytes", bytes.len()); - - // Verify SHA256 checksum - if !expected_checksum.is_empty() { - let mut hasher = Sha256::new(); - hasher.update(&bytes); - let actual_checksum = format!("{:x}", hasher.finalize()); - - if actual_checksum != expected_checksum { - return Err(format!( - "Checksum mismatch: expected {}, got {}", - expected_checksum, actual_checksum - ) - .into()); - } - tracing::info!("Checksum verified"); - } - - // Write to temp file - let current_exe = std::env::current_exe()?; - let temp_path = current_exe.with_extension("update"); - - { - let mut file = std::fs::File::create(&temp_path)?; - file.write_all(&bytes)?; - file.sync_all()?; - } - - // Make executable - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755))?; - } - - // Replace current binary - std::fs::rename(&temp_path, ¤t_exe)?; - tracing::info!("Binary replaced at {:?}", current_exe); - - // Re-exec with same arguments - let args: Vec = std::env::args().collect(); - tracing::info!("Re-executing with args: {:?}", &args[..]); - - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - let err = std::process::Command::new(¤t_exe) - .args(&args[1..]) - .exec(); - // exec() only returns on error - Err(format!("exec() failed: {}", err).into()) - } - - #[cfg(not(unix))] - { - tracing::error!("Self-update exec() not supported on this platform, exiting for restart"); - std::process::exit(0); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_value_to_string_integer() { - let value = SnmpValue::Integer(42); - assert_eq!(value_to_string(value), "42"); - } - - #[test] - fn test_value_to_string_string() { - let value = SnmpValue::String("test".to_string()); - assert_eq!(value_to_string(value), "test"); - } - - #[test] - fn test_value_to_string_counter32() { - let value = SnmpValue::Counter32(12345); - assert_eq!(value_to_string(value), "12345"); - } - - #[test] - fn test_value_to_string_counter64() { - let value = SnmpValue::Counter64(9876543210); - assert_eq!(value_to_string(value), "9876543210"); - } - - #[test] - fn test_value_to_string_gauge32() { - let value = SnmpValue::Gauge32(999); - assert_eq!(value_to_string(value), "999"); - } - - #[test] - fn test_value_to_string_timeticks() { - let value = SnmpValue::TimeTicks(12345678); - assert_eq!(value_to_string(value), "12345678"); - } - - #[test] - fn test_value_to_string_ip_address() { - let value = SnmpValue::IpAddress("192.168.1.1".to_string()); - assert_eq!(value_to_string(value), "192.168.1.1"); - } - - #[test] - fn test_generate_agent_id() { - let id = generate_agent_id(); - assert!(id.starts_with("agent-")); - - // Verify the timestamp part is a number - let timestamp_str = id.strip_prefix("agent-").unwrap(); - let timestamp: u64 = timestamp_str.parse().expect("Timestamp should be a number"); - assert!(timestamp > 0); - } - - #[test] - fn test_get_uptime_seconds() { - let uptime = get_uptime_seconds(); - // On Linux with /proc/uptime, should return non-zero - // On other platforms or if file doesn't exist, returns 0 - // uptime is u64, so always >= 0 - just verify it's callable - let _ = uptime; - } - - #[test] - fn test_base64_encode() { - assert_eq!(base64_encode(b"hello"), "aGVsbG8="); - assert_eq!(base64_encode(b""), ""); - assert_eq!(base64_encode(b"f"), "Zg=="); - assert_eq!(base64_encode(b"fo"), "Zm8="); - assert_eq!(base64_encode(b"foo"), "Zm9v"); - assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); - assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); - assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); - } - - #[test] - fn test_base64_decode() { - assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello"); - assert_eq!(base64_decode("").unwrap(), b""); - assert_eq!(base64_decode("Zg==").unwrap(), b"f"); - assert_eq!(base64_decode("Zm8=").unwrap(), b"fo"); - assert_eq!(base64_decode("Zm9v").unwrap(), b"foo"); - assert_eq!(base64_decode("Zm9vYg==").unwrap(), b"foob"); - assert_eq!(base64_decode("Zm9vYmE=").unwrap(), b"fooba"); - assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar"); - } - - #[test] - fn test_base64_roundtrip() { - let data = b"The quick brown fox jumps over the lazy dog"; - let encoded = base64_encode(data); - let decoded = base64_decode(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn test_phoenix_message_serialization() { - let msg = PhoenixMessage { - topic: "agent:123".to_string(), - event: "phx_join".to_string(), - payload: serde_json::json!({"token": "test"}), - reference: Some("1".to_string()), - }; - - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("agent:123")); - assert!(json.contains("phx_join")); - assert!(json.contains("token")); - assert!(json.contains("test")); - } - - #[test] - fn test_phoenix_message_deserialization() { - let json = - r#"{"topic":"agent:123","event":"phx_reply","payload":{"status":"ok"},"ref":"1"}"#; - let msg: PhoenixMessage = serde_json::from_str(json).unwrap(); - assert_eq!(msg.topic, "agent:123"); - assert_eq!(msg.event, "phx_reply"); - assert_eq!(msg.reference, Some("1".to_string())); - } - - #[test] - fn test_phoenix_message_no_reference() { - let json = r#"{"topic":"agent:123","event":"job","payload":{},"ref":null}"#; - let msg: PhoenixMessage = serde_json::from_str(json).unwrap(); - assert_eq!(msg.topic, "agent:123"); - assert_eq!(msg.event, "job"); - assert!(msg.reference.is_none()); - } - - // Note: AgentClient methods require WebSocket connection and are tested via integration tests - - #[test] - fn test_redact_community_normal() { - assert_eq!(redact_community("public"), "***"); - } - - #[test] - fn test_redact_community_short() { - assert_eq!(redact_community("ab"), "***"); - assert_eq!(redact_community("a"), "***"); - } - - #[test] - fn test_redact_community_empty() { - assert_eq!(redact_community(""), "(empty)"); - } - - #[test] - fn test_redact_community_three_chars() { - assert_eq!(redact_community("abc"), "***"); - } - - #[test] - fn test_redact_community_long() { - assert_eq!(redact_community("mysecretcommunity"), "***"); - } -} diff --git a/ssh.go b/ssh.go new file mode 100644 index 0000000..078361b --- /dev/null +++ b/ssh.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "log/slog" + "time" + + "github.com/towerops-app/towerops-agent/pb" + "golang.org/x/crypto/ssh" +) + +// executeMikrotikBackup connects via SSH and runs /export compact. +func executeMikrotikBackup(ip string, port uint16, username, password string) (string, error) { + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ssh.Password(password)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 30 * time.Second, + } + + addr := fmt.Sprintf("%s:%d", ip, port) + conn, err := ssh.Dial("tcp", addr, config) + if err != nil { + return "", fmt.Errorf("ssh dial %s: %w", addr, err) + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + return "", fmt.Errorf("ssh session: %w", err) + } + defer session.Close() + + output, err := session.CombinedOutput("/export compact") + if err != nil { + // MikroTik SSH doesn't use exit codes the same way - check if we got output + if len(output) > 0 { + return string(output), nil + } + return "", fmt.Errorf("ssh command: %w", err) + } + + return string(output), nil +} + +// executePingJob pings a device and sends a monitoring check result. +func executePingJob(job *pb.AgentJob, resultCh chan<- *pb.MonitoringCheck) { + dev := job.SnmpDevice + if dev == nil { + slog.Error("job missing device info for ping", "job_id", job.JobId) + return + } + + timestamp := time.Now().Unix() + responseTime, err := pingDevice(dev.Ip, 5000) + + if err != nil { + slog.Warn("device down", "device", job.DeviceId, "error", err) + resultCh <- &pb.MonitoringCheck{ + DeviceId: job.DeviceId, + Status: "failure", + Timestamp: timestamp, + } + return + } + + slog.Debug("device up", "device", job.DeviceId, "response_time_ms", responseTime) + resultCh <- &pb.MonitoringCheck{ + DeviceId: job.DeviceId, + Status: "success", + ResponseTimeMs: responseTime, + Timestamp: timestamp, + } +} diff --git a/tests/snmp_crash_test.rs b/tests/snmp_crash_test.rs deleted file mode 100644 index 8595031..0000000 --- a/tests/snmp_crash_test.rs +++ /dev/null @@ -1,1009 +0,0 @@ -//! Integration tests for SNMP crash scenarios. -//! -//! These tests use a mock SNMP UDP server that returns crafted BER-encoded -//! responses to exercise all value type handling paths in snmp_helper.c, -//! particularly the `snmp_walk` switch statement where NULL pointer -//! dereferences and unhandled exception types can cause SIGSEGV. - -use std::ffi::{c_char, CStr, CString}; -use std::net::UdpSocket; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; - -// ─── FFI declarations matching snmp_helper.h ──────────────────────────────── - -#[repr(C)] -struct SnmpWalkResult { - oid: [u8; 256], - value: [u8; 1024], - value_len: usize, - value_type: i32, -} - -#[repr(C)] -struct SnmpIsolatedWalkHeader { - status: i32, - num_results: u32, - child_signal: i32, - error_buf: [c_char; 512], -} - -#[repr(C)] -struct SnmpIsolatedGetResult { - status: i32, - value_type: i32, - child_signal: i32, - error_buf: [c_char; 512], - value_buf: [u8; 1024], -} - -extern "C" { - fn snmp_walk_isolated( - ip_address: *const c_char, - port: u16, - community: *const c_char, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: *const std::ffi::c_void, - oid_str: *const c_char, - header: *mut SnmpIsolatedWalkHeader, - results: *mut SnmpWalkResult, - max_results: usize, - ); - - fn snmp_get_isolated( - ip_address: *const c_char, - port: u16, - community: *const c_char, - version: i32, - timeout_us: i64, - retries: i32, - v3_config: *const std::ffi::c_void, - oid_str: *const c_char, - result: *mut SnmpIsolatedGetResult, - ); -} - -// ─── BER encoding helpers ─────────────────────────────────────────────────── - -/// BER ASN.1 type tags -const BER_SEQUENCE: u8 = 0x30; -const BER_INTEGER: u8 = 0x02; -const BER_OCTET_STRING: u8 = 0x04; -const BER_NULL: u8 = 0x05; -const BER_OID: u8 = 0x06; -const BER_IPADDRESS: u8 = 0x40; // Application[0], primitive -const BER_COUNTER32: u8 = 0x41; // Application[1], primitive -const BER_GAUGE32: u8 = 0x42; // Application[2], primitive -const BER_TIMETICKS: u8 = 0x43; // Application[3], primitive -const BER_OPAQUE: u8 = 0x44; // Application[4], primitive -const BER_COUNTER64: u8 = 0x46; // Application[6], primitive - -const SNMP_GET_RESPONSE: u8 = 0xA2; -const SNMP_GET_NEXT_REQUEST: u8 = 0xA1; - -// SNMP exception types (context-specific, primitive) -const SNMP_NOSUCHOBJECT: u8 = 0x80; -const SNMP_NOSUCHINSTANCE: u8 = 0x81; -const SNMP_ENDOFMIBVIEW: u8 = 0x82; - -fn ber_encode_length(len: usize) -> Vec { - if len < 128 { - vec![len as u8] - } else if len < 256 { - vec![0x81, len as u8] - } else { - vec![0x82, (len >> 8) as u8, len as u8] - } -} - -fn ber_encode_tlv(tag: u8, content: &[u8]) -> Vec { - let mut result = vec![tag]; - result.extend(ber_encode_length(content.len())); - result.extend(content); - result -} - -fn ber_encode_integer(value: i64) -> Vec { - // Encode integer value in minimum bytes, two's complement - let mut bytes = Vec::new(); - if value == 0 { - bytes.push(0); - } else if value > 0 { - let mut v = value; - while v > 0 { - bytes.push((v & 0xFF) as u8); - v >>= 8; - } - // Add leading zero if high bit set (would be negative) - if bytes.last().unwrap() & 0x80 != 0 { - bytes.push(0); - } - bytes.reverse(); - } else { - let mut v = value; - loop { - bytes.push((v & 0xFF) as u8); - v >>= 8; - if v == -1 && (bytes.last().unwrap() & 0x80) != 0 { - break; - } - } - bytes.reverse(); - } - ber_encode_tlv(BER_INTEGER, &bytes) -} - -fn ber_encode_unsigned32(tag: u8, value: u32) -> Vec { - let mut bytes = value.to_be_bytes().to_vec(); - // Remove leading zeros but keep at least one byte - while bytes.len() > 1 && bytes[0] == 0 && (bytes[1] & 0x80) == 0 { - bytes.remove(0); - } - // Add leading zero if high bit set (ASN.1 unsigned encoding) - if bytes[0] & 0x80 != 0 { - bytes.insert(0, 0); - } - ber_encode_tlv(tag, &bytes) -} - -fn ber_encode_counter64(value: u64) -> Vec { - let mut bytes = value.to_be_bytes().to_vec(); - while bytes.len() > 1 && bytes[0] == 0 && (bytes[1] & 0x80) == 0 { - bytes.remove(0); - } - if bytes[0] & 0x80 != 0 { - bytes.insert(0, 0); - } - ber_encode_tlv(BER_COUNTER64, &bytes) -} - -fn ber_encode_oid(components: &[u32]) -> Vec { - if components.len() < 2 { - return ber_encode_tlv(BER_OID, &[]); - } - let mut encoded = vec![(40 * components[0] + components[1]) as u8]; - for &c in &components[2..] { - if c < 128 { - encoded.push(c as u8); - } else { - // Base-128 encoding with continuation bits - let mut temp = Vec::new(); - let mut v = c; - temp.push((v & 0x7F) as u8); - v >>= 7; - while v > 0 { - temp.push((v & 0x7F) as u8 | 0x80); - v >>= 7; - } - temp.reverse(); - encoded.extend(temp); - } - } - ber_encode_tlv(BER_OID, &encoded) -} - -fn ber_encode_octet_string(value: &[u8]) -> Vec { - ber_encode_tlv(BER_OCTET_STRING, value) -} - -fn ber_encode_null() -> Vec { - vec![BER_NULL, 0x00] -} - -/// Build an SNMP GetResponse PDU with one varbind. -fn build_snmp_response( - request_id: i64, - community: &[u8], - oid_components: &[u32], - value_encoding: &[u8], // Pre-encoded TLV for the value -) -> Vec { - // VarBind: SEQUENCE { OID, value } - let varbind_content = [ber_encode_oid(oid_components).as_slice(), value_encoding].concat(); - let varbind = ber_encode_tlv(BER_SEQUENCE, &varbind_content); - - // VarBindList: SEQUENCE OF VarBind - let varbind_list = ber_encode_tlv(BER_SEQUENCE, &varbind); - - // GetResponse-PDU: [2] { request-id, error-status(0), error-index(0), varbind-list } - let pdu_content = [ - ber_encode_integer(request_id).as_slice(), - &ber_encode_integer(0), // error-status = noError - &ber_encode_integer(0), // error-index = 0 - &varbind_list, - ] - .concat(); - let pdu = ber_encode_tlv(SNMP_GET_RESPONSE, &pdu_content); - - // SNMP Message: SEQUENCE { version, community, pdu } - let msg_content = [ - ber_encode_integer(1).as_slice(), // version = 1 (SNMPv2c) - &ber_encode_tlv(BER_OCTET_STRING, community), - &pdu, - ] - .concat(); - - ber_encode_tlv(BER_SEQUENCE, &msg_content) -} - -// ─── BER decoding helpers (minimal, for parsing incoming requests) ────────── - -fn ber_decode_tlv(data: &[u8]) -> Option<(u8, &[u8], &[u8])> { - if data.len() < 2 { - return None; - } - let tag = data[0]; - let (length, header_len) = if data[1] < 128 { - (data[1] as usize, 2) - } else if data[1] == 0x81 && data.len() >= 3 { - (data[2] as usize, 3) - } else if data[1] == 0x82 && data.len() >= 4 { - (((data[2] as usize) << 8) | data[3] as usize, 4) - } else { - return None; - }; - - if header_len + length > data.len() { - return None; - } - - let content = &data[header_len..header_len + length]; - let rest = &data[header_len + length..]; - Some((tag, content, rest)) -} - -fn ber_decode_integer(data: &[u8]) -> Option<(i64, &[u8])> { - let (tag, content, rest) = ber_decode_tlv(data)?; - if tag != BER_INTEGER || content.is_empty() { - return None; - } - let mut value: i64 = if content[0] & 0x80 != 0 { -1 } else { 0 }; - for &byte in content { - value = (value << 8) | byte as i64; - } - Some((value, rest)) -} - -/// Parse an incoming SNMP request enough to extract request-id and the first OID. -fn parse_snmp_request(data: &[u8]) -> Option<(i64, Vec)> { - // Outer SEQUENCE - let (_tag, msg_content, _) = ber_decode_tlv(data)?; - - // Skip version (INTEGER) - let (_, rest) = ber_decode_integer(msg_content)?; - - // Skip community (OCTET STRING) - let (_, community_content, rest) = ber_decode_tlv(rest)?; - let _ = community_content; - - // PDU (GetNextRequest = 0xA1 or GetRequest = 0xA0) - let (pdu_tag, pdu_content, _) = ber_decode_tlv(rest)?; - if pdu_tag != SNMP_GET_NEXT_REQUEST && pdu_tag != 0xA0 { - return None; - } - - // Request ID - let (request_id, rest) = ber_decode_integer(pdu_content)?; - - // Skip error-status, error-index - let (_, rest) = ber_decode_integer(rest)?; - let (_, rest) = ber_decode_integer(rest)?; - - // VarBindList SEQUENCE - let (_, vbl_content, _) = ber_decode_tlv(rest)?; - - // First VarBind SEQUENCE - let (_, vb_content, _) = ber_decode_tlv(vbl_content)?; - - // OID - return raw bytes for comparison - let (tag, oid_content, _) = ber_decode_tlv(vb_content)?; - if tag != BER_OID { - return None; - } - - Some((request_id, oid_content.to_vec())) -} - -// ─── Mock SNMP server ────────────────────────────────────────────────────── - -/// Configuration for a varbind response from the mock server. -#[derive(Clone)] -struct MockVarbind { - /// OID to return in the response (the "next" OID in the walk) - response_oid: Vec, - /// Pre-encoded TLV for the value - value_tlv: Vec, -} - -/// A mock SNMP UDP server that returns crafted responses. -struct MockSnmpServer { - port: u16, - stop: Arc, - handle: Option>, -} - -impl MockSnmpServer { - /// Start a mock server that returns the given varbinds in sequence. - /// After all varbinds are exhausted, returns an OID outside the subtree - /// (2.0) to terminate the walk. - fn start(varbinds: Vec) -> Self { - let socket = UdpSocket::bind("127.0.0.1:0").expect("bind mock SNMP server"); - let port = socket.local_addr().unwrap().port(); - socket - .set_read_timeout(Some(std::time::Duration::from_millis(500))) - .unwrap(); - - let stop = Arc::new(AtomicBool::new(false)); - let stop_clone = stop.clone(); - - let handle = thread::spawn(move || { - let community = b"public"; - let mut request_count = 0usize; - let mut buf = [0u8; 4096]; - - while !stop_clone.load(Ordering::Relaxed) { - let (len, src) = match socket.recv_from(&mut buf) { - Ok(v) => v, - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, - Err(_) => break, - }; - - let data = &buf[..len]; - - // Parse request to get request-id - let request_id = match parse_snmp_request(data) { - Some((id, _oid)) => id, - None => continue, - }; - - // Build response - let response = if request_count < varbinds.len() { - let vb = &varbinds[request_count]; - build_snmp_response(request_id, community, &vb.response_oid, &vb.value_tlv) - } else { - // Return OID outside subtree to end the walk - // Use OID 2.0 which is outside any 1.x subtree - build_snmp_response(request_id, community, &[2, 0], &ber_encode_null()) - }; - - let _ = socket.send_to(&response, src); - request_count += 1; - } - }); - - MockSnmpServer { - port, - stop, - handle: Some(handle), - } - } -} - -impl Drop for MockSnmpServer { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(h) = self.handle.take() { - let _ = h.join(); - } - } -} - -// ─── Test helpers ────────────────────────────────────────────────────────── - -const TEST_TIMEOUT_US: i64 = 2_000_000; // 2 seconds for mock tests -const TEST_RETRIES: i32 = 1; -const MAX_RESULTS: usize = 100; - -/// The base OID we walk in all tests: 1.3.6.1.2.1.1 (system subtree) -/// OID within the subtree for test responses: 1.3.6.1.2.1.1.1.0 -const RESPONSE_OID_1: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 1, 0]; -/// Second OID within the subtree: 1.3.6.1.2.1.1.2.0 -const RESPONSE_OID_2: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 2, 0]; -/// Third OID: 1.3.6.1.2.1.1.3.0 -const RESPONSE_OID_3: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 3, 0]; - -fn do_walk_isolated(port: u16, oid: &str) -> (SnmpIsolatedWalkHeader, Vec) { - let ip = CString::new("127.0.0.1").unwrap(); - let community = CString::new("public").unwrap(); - let oid_cstr = CString::new(oid).unwrap(); - - let mut header = SnmpIsolatedWalkHeader { - status: -1, - num_results: 0, - child_signal: 0, - error_buf: [0; 512], - }; - - let mut results: Vec = (0..MAX_RESULTS) - .map(|_| SnmpWalkResult { - oid: [0; 256], - value: [0; 1024], - value_len: 0, - value_type: 0, - }) - .collect(); - - unsafe { - snmp_walk_isolated( - ip.as_ptr(), - port, - community.as_ptr(), - 2, // SNMPv2c - TEST_TIMEOUT_US, - TEST_RETRIES, - std::ptr::null(), - oid_cstr.as_ptr(), - &mut header, - results.as_mut_ptr(), - MAX_RESULTS, - ); - } - - (header, results) -} - -fn do_get_isolated(port: u16, oid: &str) -> SnmpIsolatedGetResult { - let ip = CString::new("127.0.0.1").unwrap(); - let community = CString::new("public").unwrap(); - let oid_cstr = CString::new(oid).unwrap(); - - let mut result = SnmpIsolatedGetResult { - status: -1, - value_type: 0, - child_signal: 0, - error_buf: [0; 512], - value_buf: [0; 1024], - }; - - unsafe { - snmp_get_isolated( - ip.as_ptr(), - port, - community.as_ptr(), - 2, // SNMPv2c - TEST_TIMEOUT_US, - TEST_RETRIES, - std::ptr::null(), - oid_cstr.as_ptr(), - &mut result, - ); - } - - result -} - -fn header_error(header: &SnmpIsolatedWalkHeader) -> String { - unsafe { - CStr::from_ptr(header.error_buf.as_ptr()) - .to_string_lossy() - .to_string() - } -} - -fn get_error(result: &SnmpIsolatedGetResult) -> String { - unsafe { - CStr::from_ptr(result.error_buf.as_ptr()) - .to_string_lossy() - .to_string() - } -} - -fn assert_no_crash(header: &SnmpIsolatedWalkHeader, scenario: &str) { - assert_ne!( - header.status, - -2, - "{}: child process crashed with signal {} ({})", - scenario, - header.child_signal, - header_error(header) - ); -} - -fn assert_get_no_crash(result: &SnmpIsolatedGetResult, scenario: &str) { - assert_ne!( - result.status, - -2, - "{}: child process crashed with signal {} ({})", - scenario, - result.child_signal, - get_error(result) - ); -} - -// ─── Walk tests with exception types ─────────────────────────────────────── - -#[test] -fn test_walk_nosuchobject_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: vec![SNMP_NOSUCHOBJECT, 0x00], // NoSuchObject, length 0 - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "NoSuchObject"); -} - -#[test] -fn test_walk_nosuchinstance_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], // NoSuchInstance, length 0 - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "NoSuchInstance"); -} - -#[test] -fn test_walk_endofmibview_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: vec![SNMP_ENDOFMIBVIEW, 0x00], // EndOfMibView, length 0 - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "EndOfMibView"); -} - -// ─── Walk tests with NULL type ───────────────────────────────────────────── - -#[test] -fn test_walk_null_value_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_null(), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "ASN_NULL"); -} - -// ─── Walk tests with standard types ──────────────────────────────────────── - -#[test] -fn test_walk_integer_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_integer(42), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "Integer"); - assert_eq!(header.status, 0, "walk should succeed"); - assert!(header.num_results >= 1, "should have at least 1 result"); - assert_eq!(results[0].value_type, BER_INTEGER as i32); -} - -#[test] -fn test_walk_octet_string_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(b"Hello SNMP"), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "OctetString"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); - assert_eq!(results[0].value_type, BER_OCTET_STRING as i32); - assert_eq!(results[0].value_len, 10); - assert_eq!(&results[0].value[..10], b"Hello SNMP"); -} - -#[test] -fn test_walk_empty_octet_string_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(b""), // Empty string - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "EmptyOctetString"); - // Empty strings get value_len=0, which means result is skipped - // This is acceptable behavior -} - -#[test] -fn test_walk_binary_octet_string() { - // Simulate a binary value like a MAC address (common in LLDP) - let mac = vec![0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(&mac), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "BinaryOctetString"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); - assert_eq!(&results[0].value[..6], &mac[..]); -} - -#[test] -fn test_walk_counter32_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_unsigned32(BER_COUNTER32, 123456), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "Counter32"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); - assert_eq!(results[0].value_type, BER_COUNTER32 as i32); -} - -#[test] -fn test_walk_gauge32_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_unsigned32(BER_GAUGE32, 99999), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "Gauge32"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); -} - -#[test] -fn test_walk_timeticks_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_unsigned32(BER_TIMETICKS, 500000), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "TimeTicks"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); -} - -#[test] -fn test_walk_counter64_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_counter64(0x0001_0000_0000_ABCD), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "Counter64"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); - assert_eq!(results[0].value_type, BER_COUNTER64 as i32); -} - -#[test] -fn test_walk_oid_value() { - // Value is itself an OID (e.g., sysObjectID) - let oid_value = ber_encode_oid(&[1, 3, 6, 1, 4, 1, 41112, 1, 4]); // Ubiquiti OID - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: oid_value, - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "OID value"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); - assert_eq!(results[0].value_type, BER_OID as i32); -} - -#[test] -fn test_walk_ipaddress_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_tlv(BER_IPADDRESS, &[10, 0, 0, 1]), - }]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "IpAddress"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); -} - -#[test] -fn test_walk_opaque_value() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_tlv(BER_OPAQUE, &[0x9F, 0x78, 0x04, 0x42, 0x8C, 0xCC, 0xCD]), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "Opaque"); - assert_eq!(header.status, 0); - // Opaque values may or may not be returned depending on net-snmp's parsing -} - -// ─── Walk tests with edge cases ──────────────────────────────────────────── - -#[test] -fn test_walk_unknown_type_does_not_crash() { - // Use a type tag not in the switch statement (e.g., BIT STRING = 0x03) - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_tlv(0x03, &[0x00, 0xFF, 0xAA]), // BIT STRING - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "UnknownType(BIT_STRING)"); -} - -#[test] -fn test_walk_large_octet_string_does_not_crash() { - // Value larger than the 1024-byte result buffer - let large_value = vec![0x41; 2000]; // 2000 bytes of 'A' - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(&large_value), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "LargeOctetString"); - // Large values should be skipped (not overflow the buffer) -} - -#[test] -fn test_walk_zero_integer_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_integer(0), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "ZeroInteger"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); -} - -#[test] -fn test_walk_negative_integer_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_integer(-1), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "NegativeInteger"); - assert_eq!(header.status, 0); - assert!(header.num_results >= 1); -} - -#[test] -fn test_walk_max_counter64_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_counter64(u64::MAX), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "MaxCounter64"); -} - -// ─── Walk tests with mixed types (simulating real device responses) ──────── - -#[test] -fn test_walk_mixed_types_like_real_device() { - // Simulate a realistic SNMP walk returning various system MIB values - let server = MockSnmpServer::start(vec![ - // sysDescr.0 = OctetString - MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(b"EdgeSwitch 24-Port 250W"), - }, - // sysObjectID.0 = OID - MockVarbind { - response_oid: RESPONSE_OID_2.to_vec(), - value_tlv: ber_encode_oid(&[1, 3, 6, 1, 4, 1, 41112, 1, 6]), - }, - // sysUpTime.0 = TimeTicks - MockVarbind { - response_oid: RESPONSE_OID_3.to_vec(), - value_tlv: ber_encode_unsigned32(BER_TIMETICKS, 123456789), - }, - ]); - - let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "MixedTypes"); - assert_eq!(header.status, 0); - assert_eq!(header.num_results, 3, "should have 3 results"); - - // Verify types - assert_eq!(results[0].value_type, BER_OCTET_STRING as i32); - assert_eq!(results[1].value_type, BER_OID as i32); - assert_eq!(results[2].value_type, BER_TIMETICKS as i32); -} - -#[test] -fn test_walk_mixed_with_exceptions() { - // Simulate walk where some OIDs return exceptions (common on Ubiquiti) - let server = MockSnmpServer::start(vec![ - // First result: normal string - MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(b"Normal value"), - }, - // Second result: NoSuchInstance (device doesn't implement this OID) - MockVarbind { - response_oid: RESPONSE_OID_2.to_vec(), - value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], - }, - // Third result: normal integer after the exception - MockVarbind { - response_oid: RESPONSE_OID_3.to_vec(), - value_tlv: ber_encode_integer(100), - }, - ]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "MixedWithExceptions"); - assert_eq!(header.status, 0); - // Exception values get value_len=0 so they're skipped - // We should get at least the normal values -} - -// ─── Walk test simulating LLDP responses (Ubiquiti-like) ─────────────────── - -#[test] -fn test_walk_lldp_binary_chassis_id() { - // LLDP lldpRemChassisId returns binary MAC address - // OID: 1.0.8802.1.1.2.1.4.1.1.5.0.1 - let lldp_base: Vec = vec![1, 0, 8802, 1, 1, 2, 1, 4, 1, 1]; - let mut oid1 = lldp_base.clone(); - oid1.extend(&[5, 0, 1]); - - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: oid1, - value_tlv: ber_encode_octet_string(&[0x04, 0xF0, 0x21, 0xBE, 0xAC, 0x10]), // MAC address - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.0.8802.1.1.2.1.4.1.1"); - assert_no_crash(&header, "LLDP binary chassis ID"); -} - -#[test] -fn test_walk_lldp_with_all_exception_types() { - // Some Ubiquiti devices return exceptions for LLDP sub-OIDs - let lldp_base: Vec = vec![1, 0, 8802, 1, 1, 2, 1, 4, 1, 1]; - let mut oid1 = lldp_base.clone(); - oid1.extend(&[1, 0, 1]); - let mut oid2 = lldp_base.clone(); - oid2.extend(&[2, 0, 1]); - let mut oid3 = lldp_base.clone(); - oid3.extend(&[3, 0, 1]); - - let server = MockSnmpServer::start(vec![ - MockVarbind { - response_oid: oid1, - value_tlv: vec![SNMP_NOSUCHOBJECT, 0x00], - }, - MockVarbind { - response_oid: oid2, - value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], - }, - MockVarbind { - response_oid: oid3, - value_tlv: vec![SNMP_ENDOFMIBVIEW, 0x00], - }, - ]); - - let (header, _results) = do_walk_isolated(server.port, "1.0.8802.1.1.2.1.4.1.1"); - assert_no_crash(&header, "LLDP all exception types"); -} - -// ─── GET tests with exception types ──────────────────────────────────────── - -// Note: GET requests use GetRequest (0xA0), and the mock server responds to -// both 0xA0 and 0xA1. But `snmp_get_isolated` sends a GET PDU (0xA0), -// and the mock needs to handle that. Since we configured the mock to accept -// both tags, this should work. However, GET operations send a GetRequest, -// not GetNextRequest, so we need our mock to handle 0xA0 too. -// The mock's parse_snmp_request already accepts both 0xA0 and 0xA1. - -// For GET tests, the mock returns exactly one response (no walk iteration). - -#[test] -fn test_get_nosuchobject_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: vec![SNMP_NOSUCHOBJECT, 0x00], - }]); - - let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); - assert_get_no_crash(&result, "GET NoSuchObject"); -} - -#[test] -fn test_get_nosuchinstance_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], - }]); - - let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); - assert_get_no_crash(&result, "GET NoSuchInstance"); -} - -#[test] -fn test_get_endofmibview_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: vec![SNMP_ENDOFMIBVIEW, 0x00], - }]); - - let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); - assert_get_no_crash(&result, "GET EndOfMibView"); -} - -#[test] -fn test_get_null_value_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_null(), - }]); - - let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); - assert_get_no_crash(&result, "GET NULL"); -} - -#[test] -fn test_get_normal_string() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(b"test value"), - }]); - - let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); - assert_get_no_crash(&result, "GET string"); - assert!(result.status >= 0, "GET should succeed"); - assert_eq!(result.value_type, BER_OCTET_STRING as i32); -} - -#[test] -fn test_get_empty_octet_string_does_not_crash() { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_octet_string(b""), - }]); - - let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); - assert_get_no_crash(&result, "GET empty string"); -} - -// ─── Stress / concurrent tests ───────────────────────────────────────────── - -#[test] -fn test_walk_many_sequential_operations() { - // Run multiple walks to the same mock to verify no resource leaks - for i in 0..5 { - let server = MockSnmpServer::start(vec![MockVarbind { - response_oid: RESPONSE_OID_1.to_vec(), - value_tlv: ber_encode_integer(i), - }]); - - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, &format!("Sequential walk {}", i)); - } -} - -#[test] -fn test_walk_many_results() { - // Walk that returns many results to test the results buffer handling - let mut varbinds = Vec::new(); - for i in 0..50 { - let mut oid = vec![1u32, 3, 6, 1, 2, 1, 1, 1]; - oid.push(i); - varbinds.push(MockVarbind { - response_oid: oid, - value_tlv: ber_encode_integer(i as i64), - }); - } - - let server = MockSnmpServer::start(varbinds); - let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); - assert_no_crash(&header, "ManyResults"); - assert_eq!(header.status, 0); - assert_eq!(header.num_results, 50); -} diff --git a/tests/tls_provider.rs b/tests/tls_provider.rs deleted file mode 100644 index a0711e3..0000000 --- a/tests/tls_provider.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Integration tests verifying the rustls CryptoProvider is configured correctly. -//! -//! These tests catch the panic that occurs when both ring and aws-lc-rs features -//! are enabled transitively but no default provider is explicitly installed. -//! Without the install_default() call in main(), any TLS operation panics with: -//! "no process-level CryptoProvider was set" - -use std::sync::Once; - -static INIT: Once = Once::new(); - -/// Install the ring crypto provider once for all tests in this module, -/// matching what main() does at startup. -fn ensure_crypto_provider() { - INIT.call_once(|| { - rustls::crypto::ring::default_provider() - .install_default() - .expect("Failed to install rustls CryptoProvider"); - }); -} - -#[test] -fn test_crypto_provider_is_installed() { - ensure_crypto_provider(); - - // After install_default(), the process-level provider must be available. - // This is the call that panicked before the fix. - let provider = rustls::crypto::CryptoProvider::get_default(); - assert!(provider.is_some(), "CryptoProvider should be installed"); -} - -#[test] -fn test_rustls_client_config_builder_does_not_panic() { - ensure_crypto_provider(); - - // ClientConfig::builder() uses the default CryptoProvider internally. - // Before the fix, this panicked with "no process-level CryptoProvider was set". - let config = rustls::ClientConfig::builder() - .with_root_certificates(rustls::RootCertStore::empty()) - .with_no_client_auth(); - - // Verify config was created with TLS 1.2 and 1.3 support - assert!( - config.alpn_protocols.is_empty(), - "Default config should have no ALPN protocols" - ); -} - -#[test] -fn test_reqwest_tls_client_creation() { - ensure_crypto_provider(); - - // reqwest::Client with rustls-tls needs a working CryptoProvider. - // This would panic without the provider installed. - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .expect("Should be able to build reqwest client with rustls TLS"); - - // Verify the client is usable (doesn't panic on creation) - drop(client); -} - -#[test] -fn test_tokio_rustls_connector_creation() { - ensure_crypto_provider(); - - // This mirrors how the MikroTik client creates its TLS connector. - // ClientConfig::builder() was the exact call site of the original panic. - let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(std::sync::Arc::new(TestVerifier)) - .with_no_client_auth(); - - let _connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config)); -} - -#[test] -fn test_websocket_tls_connector_available() { - ensure_crypto_provider(); - - // Verify the full TLS config chain used by WebSocket connections works. - // tokio-tungstenite uses rustls internally for wss:// connections. - let mut root_store = rustls::RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - - let config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); - - assert!( - !config - .crypto_provider() - .signature_verification_algorithms - .all - .is_empty(), - "Crypto provider should have signature verification algorithms" - ); -} - -/// Dummy certificate verifier for testing (accepts all certs). -#[derive(Debug)] -struct TestVerifier; - -impl rustls::client::danger::ServerCertVerifier for TestVerifier { - fn verify_server_cert( - &self, - _end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ED25519, - ] - } -} diff --git a/update.go b/update.go new file mode 100644 index 0000000..02de34d --- /dev/null +++ b/update.go @@ -0,0 +1,63 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "syscall" +) + +// selfUpdate downloads a new binary, verifies its checksum, replaces the current binary, and re-execs. +func selfUpdate(downloadURL, expectedChecksum string) error { + slog.Info("downloading update", "url", downloadURL) + + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + slog.Info("downloaded update", "bytes", len(body)) + + // Verify SHA256 checksum + if expectedChecksum != "" { + actual := fmt.Sprintf("%x", sha256.Sum256(body)) + if actual != expectedChecksum { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual) + } + slog.Info("checksum verified") + } + + // Write to temp file next to current binary + currentExe, err := os.Executable() + if err != nil { + return fmt.Errorf("get executable path: %w", err) + } + tempPath := currentExe + ".update" + + if err := os.WriteFile(tempPath, body, 0755); err != nil { + return fmt.Errorf("write temp: %w", err) + } + + // Replace current binary + if err := os.Rename(tempPath, currentExe); err != nil { + os.Remove(tempPath) + return fmt.Errorf("rename: %w", err) + } + slog.Info("binary replaced", "path", currentExe) + + // Re-exec with same arguments + slog.Info("re-executing", "args", os.Args) + return syscall.Exec(currentExe, os.Args, os.Environ()) +} diff --git a/update_test.go b/update_test.go new file mode 100644 index 0000000..f6fe647 --- /dev/null +++ b/update_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSelfUpdateBadURL(t *testing.T) { + err := selfUpdate("http://127.0.0.1:1/nonexistent", "") + if err == nil { + t.Error("expected error for unreachable URL") + } +} + +func TestSelfUpdateChecksumMismatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("fake binary")) + })) + defer srv.Close() + + err := selfUpdate(srv.URL, "0000000000000000000000000000000000000000000000000000000000000000") + if err == nil { + t.Error("expected checksum mismatch error") + } +} + +func TestSelfUpdateChecksumMatch(t *testing.T) { + body := []byte("test binary content") + checksum := fmt.Sprintf("%x", sha256.Sum256(body)) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(body) + })) + defer srv.Close() + + // This will fail at the rename step (writing to os.Executable path), + // but the checksum verification should pass + err := selfUpdate(srv.URL, checksum) + if err == nil { + t.Error("expected error (can't replace running binary in test)") + } + // The error should NOT be about checksum + if err != nil && err.Error() != "" { + // As long as it's not a checksum error, the checksum verification passed + t.Logf("got expected post-checksum error: %v", err) + } +} diff --git a/websocket.go b/websocket.go new file mode 100644 index 0000000..6d1e71a --- /dev/null +++ b/websocket.go @@ -0,0 +1,215 @@ +package main + +import ( + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "net" + "net/url" + "strings" + "sync" +) + +const ( + opText = 1 + opBinary = 2 + opClose = 8 + opPing = 9 + opPong = 10 +) + +// WSConn is a minimal RFC 6455 WebSocket client. +type WSConn struct { + conn io.ReadWriteCloser + mu sync.Mutex // serializes writes +} + +// WSDial connects to a WebSocket endpoint and performs the HTTP upgrade handshake. +func WSDial(rawURL string) (*WSConn, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("parse url: %w", err) + } + + useTLS := u.Scheme == "wss" + host := u.Host + if !strings.Contains(host, ":") { + if useTLS { + host += ":443" + } else { + host += ":80" + } + } + + var conn net.Conn + if useTLS { + conn, err = tls.Dial("tcp", host, &tls.Config{MinVersion: tls.VersionTLS12}) + } else { + conn, err = net.Dial("tcp", host) + } + if err != nil { + return nil, fmt.Errorf("dial %s: %w", host, err) + } + + // Generate random key for Sec-WebSocket-Key + keyBytes := make([]byte, 16) + if _, err := rand.Read(keyBytes); err != nil { + conn.Close() + return nil, fmt.Errorf("generate key: %w", err) + } + key := base64.StdEncoding.EncodeToString(keyBytes) + + path := u.RequestURI() + req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n", + path, u.Host, key) + + if _, err := conn.Write([]byte(req)); err != nil { + conn.Close() + return nil, fmt.Errorf("write handshake: %w", err) + } + + // Read HTTP response (look for 101 Switching Protocols) + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + conn.Close() + return nil, fmt.Errorf("read handshake: %w", err) + } + resp := string(buf[:n]) + if !strings.Contains(resp, "101") { + conn.Close() + return nil, fmt.Errorf("handshake failed: %s", strings.SplitN(resp, "\r\n", 2)[0]) + } + + return &WSConn{conn: conn}, nil +} + +// ReadMessage reads the next text or binary message, handling control frames internally. +func (ws *WSConn) ReadMessage() ([]byte, int, error) { + for { + opcode, payload, err := ws.readFrame() + if err != nil { + return nil, 0, err + } + switch opcode { + case opText, opBinary: + return payload, opcode, nil + case opPing: + if err := ws.writeFrame(opPong, payload); err != nil { + return nil, 0, fmt.Errorf("pong: %w", err) + } + case opClose: + ws.writeFrame(opClose, nil) // best-effort close reply + return nil, opClose, io.EOF + } + } +} + +// WriteText sends a masked text frame. +func (ws *WSConn) WriteText(data []byte) error { + return ws.writeFrame(opText, data) +} + +// Close sends a close frame and closes the underlying connection. +func (ws *WSConn) Close() error { + ws.writeFrame(opClose, nil) // best-effort + return ws.conn.Close() +} + +func (ws *WSConn) readFrame() (opcode int, payload []byte, err error) { + var header [2]byte + if _, err = io.ReadFull(ws.conn, header[:]); err != nil { + return 0, nil, err + } + + opcode = int(header[0] & 0x0F) + masked := header[1]&0x80 != 0 + length := uint64(header[1] & 0x7F) + + switch length { + case 126: + var ext [2]byte + if _, err = io.ReadFull(ws.conn, ext[:]); err != nil { + return 0, nil, err + } + length = uint64(binary.BigEndian.Uint16(ext[:])) + case 127: + var ext [8]byte + if _, err = io.ReadFull(ws.conn, ext[:]); err != nil { + return 0, nil, err + } + length = binary.BigEndian.Uint64(ext[:]) + } + + var maskKey [4]byte + if masked { + if _, err = io.ReadFull(ws.conn, maskKey[:]); err != nil { + return 0, nil, err + } + } + + payload = make([]byte, length) + if length > 0 { + if _, err = io.ReadFull(ws.conn, payload); err != nil { + return 0, nil, err + } + } + + if masked { + for i := range payload { + payload[i] ^= maskKey[i%4] + } + } + + return opcode, payload, nil +} + +func (ws *WSConn) writeFrame(opcode int, payload []byte) error { + ws.mu.Lock() + defer ws.mu.Unlock() + + length := len(payload) + // Max header: 2 + 8 + 4 (mask) = 14 bytes + header := make([]byte, 2, 14) + header[0] = 0x80 | byte(opcode) // FIN + opcode + header[1] = 0x80 // masked (client must mask) + + switch { + case length <= 125: + header[1] |= byte(length) + case length <= 65535: + header[1] |= 126 + ext := make([]byte, 2) + binary.BigEndian.PutUint16(ext, uint16(length)) + header = append(header, ext...) + default: + header[1] |= 127 + ext := make([]byte, 8) + binary.BigEndian.PutUint64(ext, uint64(length)) + header = append(header, ext...) + } + + // Generate mask key + maskKey := make([]byte, 4) + rand.Read(maskKey) + header = append(header, maskKey...) + + // Mask payload + masked := make([]byte, length) + for i := range payload { + masked[i] = payload[i] ^ maskKey[i%4] + } + + if _, err := ws.conn.Write(header); err != nil { + return err + } + if length > 0 { + if _, err := ws.conn.Write(masked); err != nil { + return err + } + } + return nil +} diff --git a/websocket_test.go b/websocket_test.go new file mode 100644 index 0000000..6997207 --- /dev/null +++ b/websocket_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestWriteFrameMasked(t *testing.T) { + // Verify that writeFrame produces a valid masked client frame + var buf bytes.Buffer + ws := &WSConn{conn: &nopCloser{readWriter: &buf}} + + payload := []byte("hello") + if err := ws.writeFrame(opText, payload); err != nil { + t.Fatal(err) + } + + frame := buf.Bytes() + + // First byte: FIN + opcode + if frame[0] != 0x81 { // 0x80 (FIN) | 0x01 (text) + t.Errorf("first byte: got %#x, want 0x81", frame[0]) + } + + // Second byte: MASK + length + if frame[1] != 0x85 { // 0x80 (mask) | 5 (length) + t.Errorf("second byte: got %#x, want 0x85", frame[1]) + } + + // Mask key is bytes 2-5 + maskKey := frame[2:6] + maskedPayload := frame[6:] + + // Unmask and verify + for i := range maskedPayload { + maskedPayload[i] ^= maskKey[i%4] + } + if string(maskedPayload) != "hello" { + t.Errorf("unmasked payload: got %q, want %q", maskedPayload, "hello") + } +} + +func TestWriteFrameExtendedLength(t *testing.T) { + // Test 16-bit extended length (126-65535 bytes) + var buf bytes.Buffer + ws := &WSConn{conn: &nopCloser{readWriter: &buf}} + + payload := make([]byte, 300) // > 125, uses 2-byte extended + if err := ws.writeFrame(opBinary, payload); err != nil { + t.Fatal(err) + } + + frame := buf.Bytes() + if frame[1]&0x7F != 126 { + t.Errorf("expected 126 length marker, got %d", frame[1]&0x7F) + } + extLen := binary.BigEndian.Uint16(frame[2:4]) + if extLen != 300 { + t.Errorf("extended length: got %d, want 300", extLen) + } +} + +func TestReadFrame(t *testing.T) { + // Build an unmasked server frame + var buf bytes.Buffer + payload := []byte("world") + buf.WriteByte(0x81) // FIN + text + buf.WriteByte(byte(len(payload))) + buf.Write(payload) + + ws := &WSConn{conn: &nopCloser{readWriter: &buf}} + opcode, data, err := ws.readFrame() + if err != nil { + t.Fatal(err) + } + if opcode != opText { + t.Errorf("opcode: got %d, want %d", opcode, opText) + } + if string(data) != "world" { + t.Errorf("data: got %q, want %q", data, "world") + } +} + +func TestReadFramePingPong(t *testing.T) { + // Server sends a ping, ReadMessage should auto-respond with pong and continue + var buf bytes.Buffer + + // Ping frame + buf.WriteByte(0x80 | byte(opPing)) + buf.WriteByte(0) // no payload + + // Then a text frame + text := []byte("data") + buf.WriteByte(0x81) + buf.WriteByte(byte(len(text))) + buf.Write(text) + + rw := &captureWriter{Reader: &buf} + ws := &WSConn{conn: &nopCloser{readWriter: rw}} + + data, _, err := ws.ReadMessage() + if err != nil { + t.Fatal(err) + } + if string(data) != "data" { + t.Errorf("got %q, want %q", data, "data") + } + + // Verify pong was written + if len(rw.written) == 0 { + t.Error("expected pong frame to be written") + } +} + +// nopCloser wraps a ReadWriter with a no-op Close. +type nopCloser struct { + readWriter interface { + Read([]byte) (int, error) + Write([]byte) (int, error) + } +} + +func (n *nopCloser) Read(p []byte) (int, error) { return n.readWriter.Read(p) } +func (n *nopCloser) Write(p []byte) (int, error) { return n.readWriter.Write(p) } +func (n *nopCloser) Close() error { return nil } + +// captureWriter captures written data while reading from a separate Reader. +type captureWriter struct { + Reader *bytes.Buffer + written []byte +} + +func (c *captureWriter) Read(p []byte) (int, error) { return c.Reader.Read(p) } +func (c *captureWriter) Write(p []byte) (int, error) { c.written = append(c.written, p...); return len(p), nil } +func (c *captureWriter) Close() error { return nil }