Full rewrite in golang
This commit is contained in:
parent
99997e0c1f
commit
769ac1f623
54 changed files with 4293 additions and 13705 deletions
|
|
@ -1,4 +1,3 @@
|
|||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
Dockerfile
|
||||
|
|
@ -7,3 +6,6 @@ README.md
|
|||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
src/
|
||||
native/
|
||||
target/
|
||||
|
|
|
|||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
365
CLAUDE.md
365
CLAUDE.md
|
|
@ -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 <get-from-ui> \
|
||||
--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=<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 <token>`
|
||||
- Response: Equipment list with sensors and interfaces
|
||||
- Called every 5 minutes
|
||||
|
||||
**POST /api/v1/agent/metrics**
|
||||
- Headers: `Authorization: Bearer <token>`
|
||||
- Body: `{"metrics": [...]}`
|
||||
- Response: `{"status": "accepted", "received": N}`
|
||||
- Called every 30 seconds with pending metrics
|
||||
|
||||
**POST /api/v1/agent/heartbeat**
|
||||
- Headers: `Authorization: Bearer <token>`
|
||||
- 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 <copied-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 <device-ip>`
|
||||
- Test SNMP manually: `snmpget -v2c -c public <device-ip> <oid>`
|
||||
- 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)
|
||||
3819
Cargo.lock
generated
3819
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
43
Cargo.toml
43
Cargo.toml
|
|
@ -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
|
||||
91
Dockerfile
91
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"]
|
||||
|
|
|
|||
314
agent.go
Normal file
314
agent.go
Normal file
|
|
@ -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 }
|
||||
69
agent_test.go
Normal file
69
agent_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
42
build.rs
42
build.rs
|
|
@ -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()
|
||||
}
|
||||
29
flake.lock
generated
29
flake.lock
generated
|
|
@ -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": {
|
||||
|
|
|
|||
35
flake.nix
35
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"
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
26
fly.toml
26
fly.toml
|
|
@ -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
|
||||
11
go.mod
Normal file
11
go.mod
Normal file
|
|
@ -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
|
||||
20
go.sum
Normal file
20
go.sum
Normal file
|
|
@ -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=
|
||||
77
main.go
Normal file
77
main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
22
main_test.go
Normal file
22
main_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
328
mikrotik.go
Normal file
328
mikrotik.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
64
mikrotik_test.go
Normal file
64
mikrotik_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,953 +0,0 @@
|
|||
#include "snmp_helper.h"
|
||||
#include <net-snmp/net-snmp-config.h>
|
||||
#include <net-snmp/net-snmp-includes.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <pthread.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
#ifndef SNMP_HELPER_H
|
||||
#define SNMP_HELPER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* 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
|
||||
2287
pb/agent.pb.go
Normal file
2287
pb/agent.pb.go
Normal file
File diff suppressed because it is too large
Load diff
57
ping.go
Normal file
57
ping.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
56
ping_test.go
Normal file
56
ping_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
269
snmp.go
Normal file
269
snmp.go
Normal file
|
|
@ -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
|
||||
}
|
||||
134
snmp_test.go
Normal file
134
snmp_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EquipmentConfig>,
|
||||
}
|
||||
|
||||
/// 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<SensorConfig>,
|
||||
pub interfaces: Vec<InterfaceConfig>,
|
||||
}
|
||||
|
||||
/// 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<i32>,
|
||||
pub unit: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
608
src/main.rs
608
src/main.rs
|
|
@ -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<String>,
|
||||
|
||||
/// Agent authentication token
|
||||
#[arg(
|
||||
long,
|
||||
env = "TOWEROPS_AGENT_TOKEN",
|
||||
required_unless_present = "mikrotik_test"
|
||||
)]
|
||||
token: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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::<snmp::SnmpTrap>(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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TlsStream<TcpStream>>),
|
||||
Plain(TcpStream),
|
||||
}
|
||||
|
||||
impl AsyncRead for MikrotikStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
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<std::io::Result<usize>> {
|
||||
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<std::io::Result<()>> {
|
||||
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<std::io::Result<()>> {
|
||||
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<Self> {
|
||||
// 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::<std::net::IpAddr>() {
|
||||
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<Self> {
|
||||
// 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<CommandResponse> {
|
||||
// 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<CommandResponse> {
|
||||
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<Vec<String>> {
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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<u8>, 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<u8>, 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<String, String> {
|
||||
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<tokio_rustls::rustls::client::danger::ServerCertVerified, tokio_rustls::rustls::Error>
|
||||
{
|
||||
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<tokio_rustls::rustls::SignatureScheme> {
|
||||
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<String> = 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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<T> = Result<T, MikrotikError>;
|
||||
|
||||
/// A sentence from a RouterOS API response (key-value pairs)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Sentence {
|
||||
pub attributes: HashMap<String, String>,
|
||||
#[allow(dead_code)] // Reserved for future use with tagged API commands
|
||||
pub tag: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from a RouterOS API command
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CommandResponse {
|
||||
pub sentences: Vec<Sentence>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
113
src/ping.rs
113
src/ping.rs
|
|
@ -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<f64> {
|
||||
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<f64> {
|
||||
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::<f64>()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Generated protobuf code
|
||||
#[allow(dead_code)]
|
||||
pub mod agent {
|
||||
include!(concat!(env!("OUT_DIR"), "/towerops.agent.rs"));
|
||||
}
|
||||
|
|
@ -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<String>) -> 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());
|
||||
}
|
||||
}
|
||||
1305
src/snmp/client.rs
1305
src/snmp/client.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SnmpResult<SnmpValue>>,
|
||||
},
|
||||
Walk {
|
||||
base_oid: String,
|
||||
response_tx: oneshot::Sender<SnmpResult<Vec<(String, SnmpValue)>>>,
|
||||
},
|
||||
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<V3Config>,
|
||||
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<SnmpRequest>,
|
||||
}
|
||||
|
||||
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<SnmpValue> {
|
||||
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<Vec<(String, SnmpValue)>> {
|
||||
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<SnmpRequest>,
|
||||
) -> 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::<String>() {
|
||||
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<SnmpValue> {
|
||||
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<Vec<(String, SnmpValue)>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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<RwLock<HashMap<String, Arc<DevicePoller>>>>,
|
||||
}
|
||||
|
||||
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<DevicePoller> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
1485
src/snmp/trap.rs
1485
src/snmp/trap.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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<T> = Result<T, SnmpError>;
|
||||
|
||||
/// 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<u8>),
|
||||
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<i64> {
|
||||
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<f64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T> = Result<T, SshError>;
|
||||
|
||||
struct Client;
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
fn check_server_key(
|
||||
&mut self,
|
||||
server_public_key: &PublicKey,
|
||||
) -> impl Future<Output = Result<bool, Self::Error>> + 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<Client>,
|
||||
}
|
||||
|
||||
impl SshClient {
|
||||
/// Connect to an SSH server and authenticate with password
|
||||
pub async fn connect(
|
||||
host: &str,
|
||||
port: u32,
|
||||
username: &str,
|
||||
password: &SecretString,
|
||||
) -> SshResult<Self> {
|
||||
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<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pub mod client;
|
||||
|
||||
pub use client::SshClient;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
74
ssh.go
Normal file
74
ssh.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
]
|
||||
}
|
||||
}
|
||||
63
update.go
Normal file
63
update.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
50
update_test.go
Normal file
50
update_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
215
websocket.go
Normal file
215
websocket.go
Normal file
|
|
@ -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
|
||||
}
|
||||
136
websocket_test.go
Normal file
136
websocket_test.go
Normal file
|
|
@ -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 }
|
||||
Loading…
Add table
Reference in a new issue