Full rewrite in golang

This commit is contained in:
Graham McIntire 2026-02-11 10:04:30 -06:00
parent 99997e0c1f
commit 769ac1f623
No known key found for this signature in database
54 changed files with 4293 additions and 13705 deletions

View file

@ -1,4 +1,3 @@
target/
.git/
.gitignore
Dockerfile
@ -7,3 +6,6 @@ README.md
*.db
*.db-shm
*.db-wal
src/
native/
target/

View file

@ -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"

View file

@ -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
View file

@ -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
View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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
View 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
View 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
}

View file

@ -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
View file

@ -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": {

View file

@ -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"
'';
};
});
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}
})
}
}

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

57
ping.go Normal file
View 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
View 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)
}
})
}
}

View file

@ -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
View 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
View 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)
}
}
}

View file

@ -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,
}

View file

@ -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"
);
}
}

View file

@ -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()));
}
}

View file

@ -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};

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -1,5 +0,0 @@
// Generated protobuf code
#[allow(dead_code)]
pub mod agent {
include!(concat!(env!("OUT_DIR"), "/towerops.agent.rs"));
}

View file

@ -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());
}
}

File diff suppressed because it is too large Load diff

View file

@ -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
}
}

View file

@ -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"));
}
}

View file

@ -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;

View file

@ -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();
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);
}
}

View file

@ -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(())
}
}

View file

@ -1,3 +0,0 @@
pub mod client;
pub use client::SshClient;

View file

@ -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
View 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

View file

@ -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
View 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
View 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
View 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
View 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 }