fix: credo issues - line length and unused aliases/variables

This commit is contained in:
Graham McIntire 2026-01-23 13:25:08 -06:00
parent ec76428349
commit a0cee485a4
No known key found for this signature in database
9 changed files with 4 additions and 2331 deletions

View file

@ -1,626 +0,0 @@
# Remote SNMP Polling Agent Implementation
## Overview
This document describes the complete implementation of the remote SNMP polling agent system for Towerops, completed as of January 9, 2026.
## Implementation Status
**Phase 1: Backend Foundation** - ✅ Complete
**Phase 2: Agent Management UI** - ✅ Complete
**Phase 3: Rust Agent Core** - ✅ Complete (SNMP integration simplified)
**Phase 4: Docker & Deployment** - ✅ Dockerfile ready
**Phase 5: Integration & Testing** - 🔄 Ready for next phase
## Architecture
### High-Level Flow
```
Customer Network Towerops Cloud
┌─────────────────────┐ ┌──────────────────┐
│ Rust Agent │ HTTPS/TLS │ Phoenix API │
│ ├─ Config Poller │ ←─────────────── │ ├─ /config │
│ ├─ SNMP Poller │ │ ├─ /metrics │
│ ├─ Metrics Buffer │ ─────────────→ │ ├─ /heartbeat │
│ └─ SQLite Storage │ Bearer Token │ └─ Token Auth │
│ │ │ │
│ Polls devices: │ │ Processes: │
│ └─ 192.168.x.x │ │ └─ Metrics │
└─────────────────────┘ └──────────────────┘
```
### Configuration Hierarchy
1. **Organization Level**: Default agent for all equipment
- Set in `/orgs/:slug/settings`
- Stored in `organizations.default_agent_token_id`
2. **Equipment Level**: Override organization default
- Set in equipment form
- Stored in `agent_assignments` table
- Explicit assignment takes precedence
## Database Schema
### New Tables
**agent_tokens**
```sql
CREATE TABLE agent_tokens (
id binary_id PRIMARY KEY,
token_hash binary NOT NULL,
name text NOT NULL,
organization_id binary_id NOT NULL REFERENCES organizations(id),
last_seen_at timestamp,
last_ip text,
enabled boolean DEFAULT true,
metadata jsonb DEFAULT '{}',
inserted_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
CREATE INDEX idx_agent_tokens_token_hash ON agent_tokens(token_hash);
CREATE INDEX idx_agent_tokens_organization_id ON agent_tokens(organization_id);
CREATE INDEX idx_agent_tokens_last_seen_at ON agent_tokens(last_seen_at);
```
**agent_assignments**
```sql
CREATE TABLE agent_assignments (
id binary_id PRIMARY KEY,
agent_token_id binary_id NOT NULL REFERENCES agent_tokens(id) ON DELETE CASCADE,
equipment_id binary_id NOT NULL REFERENCES equipment(id) ON DELETE CASCADE,
enabled boolean DEFAULT true,
inserted_at timestamp NOT NULL,
updated_at timestamp NOT NULL,
UNIQUE(equipment_id)
);
CREATE INDEX idx_agent_assignments_agent_token_id ON agent_assignments(agent_token_id);
CREATE UNIQUE INDEX idx_agent_assignments_equipment_id ON agent_assignments(equipment_id);
```
**Modified Tables**
**organizations** - Added default agent support
```sql
ALTER TABLE organizations ADD COLUMN default_agent_token_id binary_id
REFERENCES agent_tokens(id) ON DELETE SET NULL;
CREATE INDEX idx_organizations_default_agent_token_id
ON organizations(default_agent_token_id);
```
## Phoenix Backend (Elixir)
### Context: Towerops.Agents
**Location**: `lib/towerops/agents.ex`
**Functions**:
- `create_agent_token/2` - Generate new agent with secure token
- `list_organization_agent_tokens/1` - List agents for org
- `verify_agent_token/1` - Authenticate agent requests
- `revoke_agent_token/1` - Disable agent
- `update_agent_token_heartbeat/3` - Update last seen status
- `assign_equipment_to_agent/2` - Create assignment
- `unassign_equipment/1` - Remove assignment
- `update_equipment_assignment/2` - Update or create assignment
- `get_equipment_assignment/1` - Get current assignment
- `list_agent_equipment/1` - Get equipment for agent
### API Endpoints
**Location**: `lib/towerops_web/controllers/api/agent_controller.ex`
**Routes** (require Bearer token auth):
- `GET /api/v1/agent/config` - Fetch equipment to poll
- `POST /api/v1/agent/metrics` - Submit collected metrics
- `POST /api/v1/agent/heartbeat` - Update agent status
**Authentication**: `ToweropsWeb.Plugs.AgentAuth`
- Validates Bearer token from `Authorization` header
- Updates `last_seen_at` and `last_ip` automatically
- Assigns `current_agent_token` to conn
### LiveViews
**Agent Management** - `lib/towerops_web/live/agent_live/index.ex`
- Route: `/orgs/:slug/agents`
- Features:
- List all agents with status badges (Online/Warning/Offline/Never)
- Create new agent (token shown once)
- Revoke agent
- Docker Compose snippet generator
**Organization Settings** - `lib/towerops_web/live/org/settings_live.ex`
- Route: `/orgs/:slug/settings`
- Features:
- Set organization default agent
- Edit organization name
- Link to create agent if none exist
**Equipment Form** - `lib/towerops_web/live/equipment_live/form.ex`
- Updated to include agent selection dropdown
- Shows current assignment or org default
- Empty selection = "poll from server"
## Rust Agent
### Project Structure
```
towerops-agent/
├── Cargo.toml # Dependencies and build config
├── Dockerfile # Multi-stage Docker build
├── docker-compose.example.yml # Deployment template
├── README.md # User documentation
└── src/
├── main.rs # Entry point, CLI args
├── config.rs # Configuration types
├── api_client.rs # HTTP client for Towerops API
├── metrics/
│ └── mod.rs # Metric types
├── snmp/
│ ├── mod.rs # Module exports
│ ├── client.rs # SNMP operations (simplified)
│ └── types.rs # SNMP types and errors
├── buffer/
│ ├── mod.rs # Module exports
│ └── storage.rs # SQLite buffering
└── poller/
├── mod.rs # Module exports
├── executor.rs # Poll execution
└── scheduler.rs # Main event loop
```
### Key Components
**API Client** (`api_client.rs`)
- `fetch_config()` - GET /api/v1/agent/config
- `submit_metrics()` - POST /api/v1/agent/metrics
- `heartbeat()` - POST /api/v1/agent/heartbeat
- Uses `reqwest` with rustls-tls
- 30-second timeout
**Storage** (`buffer/storage.rs`)
- SQLite database for metric buffering
- Retains metrics for 24 hours
- Tracks last poll time per equipment
- Automatic cleanup of sent metrics
**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
**Executor** (`poller/executor.rs`)
- Polls sensors and interfaces in parallel
- Applies sensor divisors
- Collects interface statistics (octets, errors, discards)
### Configuration
**Environment Variables**:
- `TOWEROPS_API_URL` (required) - API endpoint
- `TOWEROPS_AGENT_TOKEN` (required) - Auth token
- `CONFIG_REFRESH_SECONDS` (default: 300)
- `DATABASE_PATH` (default: /data/towerops-agent.db)
- `RUST_LOG` (default: info)
### Docker Deployment
**Image Size**: ~10-20 MB (optimized with Alpine + release build)
**Resource Limits**:
- Memory: 128-256 MB typical, 512 MB max
- CPU: 0.1-0.5 cores typical
**Docker Compose Example**:
```yaml
services:
towerops-agent:
image: towerops/agent:latest
environment:
- TOWEROPS_API_URL=https://app.towerops.com
- TOWEROPS_AGENT_TOKEN=<token>
volumes:
- ./data:/data
```
## User Workflow
### 1. Create Agent
1. Navigate to `/orgs/:slug/agents`
2. Click "Create New Agent"
3. Enter agent name (e.g., "Datacenter A")
4. Copy token (shown only once)
5. Save token securely
### 2. Deploy Agent
**Option A: Docker Compose**
```bash
# Create docker-compose.yml with agent token
docker-compose up -d
```
**Option B: Docker Run**
```bash
docker run -d \
-e TOWEROPS_API_URL=https://app.towerops.com \
-e TOWEROPS_AGENT_TOKEN=<token> \
-v $(pwd)/data:/data \
towerops/agent:latest
```
### 3. Configure Default Agent (Optional)
1. Navigate to `/orgs/:slug/settings`
2. Select default agent from dropdown
3. Save settings
4. All new equipment will use this agent
### 4. Assign Equipment to Agent
**Option A: Use organization default** (automatic)
- New equipment inherits org default
- No explicit assignment needed
**Option B: Override per equipment**
1. Edit equipment
2. Select agent from dropdown (or "No agent - poll from server")
3. Save
### 5. Monitor Agent Status
1. Navigate to `/orgs/:slug/agents`
2. View status badges:
- **Online** (green) - Seen in last 2 minutes
- **Warning** (yellow) - Seen 2-5 minutes ago
- **Offline** (red) - Not seen for 5+ minutes
- **Never** (gray) - Never connected
### 6. Revoke Agent (if needed)
1. Navigate to `/orgs/:slug/agents`
2. Click "Revoke" on agent
3. Confirm action
4. Agent can no longer authenticate
## API Response Formats
### GET /api/v1/agent/config
**Response**:
```json
{
"version": "1.0",
"poll_interval_seconds": 60,
"equipment": [
{
"id": "uuid",
"name": "Router 1",
"ip_address": "192.168.1.1",
"snmp": {
"enabled": true,
"version": "2c",
"community": "public",
"port": 161
},
"poll_interval_seconds": 60,
"sensors": [
{
"id": "uuid",
"type": "temperature",
"oid": "1.3.6.1.4.1.14988.1.1.3.10.0",
"divisor": 10,
"unit": "celsius",
"metadata": {}
}
],
"interfaces": [
{
"id": "uuid",
"if_index": 1,
"if_name": "ether1"
}
]
}
]
}
```
### POST /api/v1/agent/metrics
**Request**:
```json
{
"metrics": [
{
"type": "sensor_reading",
"sensor_id": "uuid",
"value": 45.5,
"status": "ok",
"timestamp": "2026-01-09T19:00:00Z"
},
{
"type": "interface_stat",
"interface_id": "uuid",
"if_in_octets": 1234567890,
"if_out_octets": 987654321,
"if_in_errors": 0,
"if_out_errors": 0,
"if_in_discards": 0,
"if_out_discards": 0,
"timestamp": "2026-01-09T19:00:00Z"
}
]
}
```
**Response**:
```json
{
"status": "accepted",
"received": 2
}
```
### POST /api/v1/agent/heartbeat
**Request**:
```json
{
"version": "0.1.0",
"hostname": "docker-host",
"uptime_seconds": 3600
}
```
**Response**:
```json
{
"status": "ok"
}
```
## Security
### Token Generation
- 32 cryptographically random bytes
- Base64url encoded (no padding)
- SHA256 hash stored in database
- Plain token never stored or logged
### Token Transmission
- HTTPS only
- Bearer token in Authorization header
- Certificate verification enforced
### Token Revocation
- Set `enabled = false` in database
- Takes effect immediately
- Agent receives 401 Unauthorized
### SNMP Security
- Community strings encrypted in transit (HTTPS)
- Community strings used locally by agent only
- Never transmitted to Towerops API in metrics
## Testing
### Test Coverage
**Backend Tests**: 401 total, 401 passing
- Agent context: 19 tests
- Agent LiveView: 6 tests
- Organization settings: 8 tests
- API controller: (covered in integration tests)
**Test Commands**:
```bash
# All tests
mix test
# Agent-specific tests
mix test test/towerops/agents_test.exs
mix test test/towerops_web/live/agent_live_test.exs
mix test test/towerops_web/live/org/settings_live_test.exs
# Agent API tests
mix test test/towerops_web/controllers/api/agent_controller_test.exs
```
## Known Limitations
### 1. SNMP Library Integration
The Rust SNMP client is simplified due to API compatibility issues with the `snmp` crate (v0.2):
**Current State**:
- Compiles successfully
- Returns error for actual SNMP operations
- Architecture and interfaces ready
**Next Steps**:
- Complete integration with `snmp` crate 0.2 API
- Alternative: Use `snmp-parser` or `snmp-mp` crates
- Alternative: Implement basic SNMP v1/v2c operations directly
**Files to Update**:
- `towerops-agent/src/snmp/client.rs`
- Add unit tests for SNMP operations
### 2. Agent-Side Filtering
Current implementation polls all sensors/interfaces configured in Towerops. Future optimization:
- Agent-side threshold filtering
- Reduce bandwidth for equipment with many sensors
- Configurable sampling rates
### 3. SNMPv3 Support
Current implementation supports SNMPv1 and SNMPv2c only. SNMPv3 requires:
- User authentication
- Privacy encryption
- Additional configuration fields
## Performance Characteristics
### Backend (Phoenix)
**Token Verification**: O(1) database lookup with index
**Config Generation**: O(n) where n = assigned equipment count
**Metrics Processing**: Async task, doesn't block API response
### Agent (Rust)
**Polling**: Sensors and interfaces polled in parallel per equipment
**Buffering**: SQLite write ~1ms per metric
**Memory**: ~50KB per equipment item in config
**CPU**: Minimal, spikes during SNMP operations only
### Expected Load
**Per Agent**:
- 50-100 equipment items
- 500-1000 sensors total
- 100-200 interfaces total
- ~5000 metrics/minute at 60s intervals
**Per Organization**:
- Unlimited agents
- Each agent operates independently
## Migration & Rollback
### Equipment Polling Modes
Future enhancement: Add `polling_mode` enum to equipment table:
- `:server` - Poll from Towerops server (current default)
- `:agent` - Poll from assigned agent
- `:both` - Both poll (for validation/migration)
This allows gradual migration and easy rollback.
### Rollback Plan
If agent system needs to be disabled:
1. Remove agent assignments: `DELETE FROM agent_assignments;`
2. PollerWorker continues polling all equipment from server
3. No data loss
4. Can re-enable later by recreating assignments
## Next Steps
### Phase 5: Integration & Testing
1. **Complete SNMP Integration**
- Research `snmp` crate 0.2 API
- Implement GET and WALK operations
- Add unit tests
2. **End-to-End Testing**
- Deploy agent in test environment
- Configure test equipment
- Verify metrics flow to database
- Test API outage (24h buffering)
3. **Load Testing**
- Test with 100 devices per agent
- Measure resource usage
- Verify no memory leaks
- Test long-running stability (7+ days)
4. **Documentation**
- User guide with screenshots
- Troubleshooting guide
- Network architecture diagrams
- Firewall requirements
5. **Production Readiness**
- Build and publish Docker image
- Set up monitoring/alerting for agents
- Create Grafana dashboard for agent health
- Document upgrade procedure
## Files Modified/Created
### Backend (Phoenix/Elixir)
**Migrations**:
- `20260109xxxxxx_create_agent_tokens.exs`
- `20260109xxxxxx_create_agent_assignments.exs`
- `20260109190858_add_default_agent_to_organizations.exs`
**Context**:
- `lib/towerops/agents.ex` (new)
- `lib/towerops/agents/agent_token.ex` (new)
- `lib/towerops/agents/agent_assignment.ex` (new)
- `lib/towerops/organizations/organization.ex` (modified)
**API**:
- `lib/towerops_web/controllers/api/agent_controller.ex` (new)
- `lib/towerops_web/plugs/agent_auth.ex` (new)
**LiveViews**:
- `lib/towerops_web/live/agent_live/index.ex` (new)
- `lib/towerops_web/live/agent_live/index.html.heex` (new)
- `lib/towerops_web/live/org/settings_live.ex` (new)
- `lib/towerops_web/live/org/settings_live.html.heex` (new)
- `lib/towerops_web/live/equipment_live/form.ex` (modified)
- `lib/towerops_web/live/equipment_live/form.html.heex` (modified)
**Router**:
- `lib/towerops_web/router.ex` (modified - added routes)
**Tests**:
- `test/towerops/agents_test.exs` (new)
- `test/towerops_web/live/agent_live_test.exs` (new)
- `test/towerops_web/live/org/settings_live_test.exs` (new)
- `test/towerops_web/controllers/api/agent_controller_test.exs` (new)
- `test/support/fixtures/agents_fixtures.ex` (new)
- `test/support/fixtures/organizations_fixtures.ex` (new)
### Agent (Rust)
**Project Root**:
- `towerops-agent/Cargo.toml`
- `towerops-agent/Dockerfile`
- `towerops-agent/docker-compose.example.yml`
- `towerops-agent/README.md`
- `towerops-agent/.gitignore`
- `towerops-agent/.dockerignore`
**Source Code**:
- `towerops-agent/src/main.rs`
- `towerops-agent/src/config.rs`
- `towerops-agent/src/api_client.rs`
- `towerops-agent/src/metrics/mod.rs`
- `towerops-agent/src/snmp/mod.rs`
- `towerops-agent/src/snmp/client.rs`
- `towerops-agent/src/snmp/types.rs`
- `towerops-agent/src/buffer/mod.rs`
- `towerops-agent/src/buffer/storage.rs`
- `towerops-agent/src/poller/mod.rs`
- `towerops-agent/src/poller/executor.rs`
- `towerops-agent/src/poller/scheduler.rs`
## Conclusion
The remote agent system is architecturally complete with:
- ✅ Secure token-based authentication
- ✅ Organization and equipment-level configuration
- ✅ Web UI for agent management
- ✅ API endpoints for agent communication
- ✅ Rust agent with event loop and buffering
- ✅ Docker deployment ready
- ✅ Comprehensive test coverage (401 passing tests)
- 🔄 SNMP integration simplified (ready for completion)
The system is ready for final SNMP integration and production deployment.

View file

@ -1,361 +0,0 @@
# Agent WebSocket Protocol
This document describes the binary WebSocket protocol used for communication between remote SNMP polling agents and the Towerops server.
## Overview
The agent uses a **single persistent WebSocket connection** to:
- Receive SNMP query jobs from the server
- Submit raw SNMP query results back to the server
- Send heartbeat updates
- Report errors
All messages use **Protocol Buffers** (protobuf) binary encoding for efficiency.
## Connection
### WebSocket URL
```
ws://towerops.net/socket/agent?token=<agent_token>
wss://towerops.net/socket/agent?token=<agent_token> # Production (TLS)
```
### Authentication
- Token is passed as query parameter during WebSocket handshake
- Server validates token and establishes session
- Invalid tokens result in connection rejection
### Connection Lifecycle
```
1. Agent connects with token
2. Server authenticates and assigns to channel "agent:<token_id>"
3. Server immediately sends "jobs" message with initial job list
4. Agent executes jobs and sends "result" messages
5. Server pushes new jobs as devices are added/changed
6. Agent sends periodic "heartbeat" messages
7. Connection remains open indefinitely
```
## Message Types
All messages are binary protobuf-encoded. The WebSocket frame format is:
```
{
event: "jobs" | "result" | "heartbeat" | "error",
payload: <binary protobuf>
}
```
### Server → Agent
#### `jobs` - Job List
Sent when agent first connects or when jobs change.
```protobuf
message AgentJobList {
repeated AgentJob jobs = 1;
}
message AgentJob {
string job_id = 1; // e.g., "discover:abc123" or "poll:def456"
JobType job_type = 2; // DISCOVER or POLL
string equipment_id = 3; // Equipment UUID
SnmpDevice device = 4; // SNMP connection details
repeated SnmpQuery queries = 5; // What to query
}
message SnmpDevice {
string ip = 1; // 10.250.1.26
string community = 2; // SNMP community string
string version = 3; // "1", "2c", or "3"
uint32 port = 4; // 161
}
message SnmpQuery {
QueryType query_type = 1; // GET or WALK
repeated string oids = 2; // OIDs to query
}
enum JobType {
DISCOVER = 0; // Full device discovery
POLL = 1; // Poll known sensors/interfaces
}
enum QueryType {
GET = 0; // SNMP GET operation
WALK = 1; // SNMP WALK operation
}
```
**Example DISCOVER job:**
```
job_id: "discover:123e4567-e89b-12d3-a456-426614174000"
job_type: DISCOVER
equipment_id: "123e4567-e89b-12d3-a456-426614174000"
device: {
ip: "10.250.1.26"
community: "public"
version: "1"
port: 161
}
queries: [
{ query_type: GET, oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0", ...] },
{ query_type: WALK, oids: ["1.3.6.1.2.1.2.2.1"] },
{ query_type: WALK, oids: ["1.3.6.1.4.1.41112"] }
]
```
**Example POLL job:**
```
job_id: "poll:123e4567-e89b-12d3-a456-426614174000"
job_type: POLL
equipment_id: "123e4567-e89b-12d3-a456-426614174000"
device: {
ip: "10.250.1.26"
community: "public"
version: "1"
port: 161
}
queries: [
{ query_type: GET, oids: [
"1.3.6.1.4.1.41112.1.3.2.1.3.1", // Sensor 1
"1.3.6.1.4.1.41112.1.3.2.1.4.1", // Sensor 2
"1.3.6.1.2.1.2.2.1.10.3", // Interface 3 stats
...
]}
]
```
### Agent → Server
#### `result` - SNMP Query Results
Sent after executing SNMP queries for a job.
```protobuf
message SnmpResult {
string equipment_id = 1;
JobType job_type = 2;
map<string, string> oid_values = 3; // OID → value mapping
int64 timestamp = 4; // Unix timestamp in seconds
}
```
**Example:**
```
equipment_id: "123e4567-e89b-12d3-a456-426614174000"
job_type: DISCOVER
oid_values: {
"1.3.6.1.2.1.1.1.0": "Linux AF11 2.6.33 ...",
"1.3.6.1.2.1.1.2.0": "1.3.6.1.4.1.41112",
"1.3.6.1.2.1.1.5.0": "AF11-Tower1",
"1.3.6.1.2.1.2.2.1.2.1": "lo",
"1.3.6.1.2.1.2.2.1.2.3": "eth0",
"1.3.6.1.4.1.41112.1.3.2.1.3.1": "5725", // TX frequency
...
}
timestamp: 1705363200
```
#### `heartbeat` - Agent Status
Sent periodically (e.g., every 60 seconds) to indicate agent is alive and report metadata.
```protobuf
message AgentHeartbeat {
string version = 1; // Agent version (e.g., "1.0.0")
string hostname = 2; // Agent hostname
uint64 uptime_seconds = 3; // Agent uptime
string ip_address = 4; // Agent's IP address
}
```
**Example:**
```
version: "1.0.0"
hostname: "datacenter-agent-1"
uptime_seconds: 86400
ip_address: "192.168.1.100"
```
#### `error` - Job Execution Error
Sent when a job fails (e.g., SNMP timeout, device unreachable).
```protobuf
message AgentError {
string equipment_id = 1;
string job_id = 2;
string message = 3;
int64 timestamp = 4;
}
```
**Example:**
```
equipment_id: "123e4567-e89b-12d3-a456-426614174000"
job_id: "poll:123e4567-e89b-12d3-a456-426614174000"
message: "SNMP timeout after 5 seconds"
timestamp: 1705363200
```
## Agent Implementation Guide
### Minimal Rust Agent Architecture
```rust
use tokio::net::TcpStream;
use tokio_tungstenite::{connect_async, WebSocketStream};
use futures::{SinkExt, StreamExt};
use prost::Message;
// 1. Connect to WebSocket
let url = format!("ws://towerops.net/socket/agent?token={}", token);
let (ws_stream, _) = connect_async(url).await?;
let (mut write, mut read) = ws_stream.split();
// 2. Receive jobs
while let Some(msg) = read.next().await {
let msg = msg?;
if msg.is_binary() {
let frame: PhoenixMessage = serde_json::from_slice(&msg.into_data())?;
if frame.event == "jobs" {
let job_list = AgentJobList::decode(frame.payload)?;
for job in job_list.jobs {
tokio::spawn(execute_job(job, write.clone()));
}
}
}
}
// 3. Execute SNMP job
async fn execute_job(job: AgentJob, mut sink: SplitSink) {
let mut oid_values = HashMap::new();
for query in job.queries {
match query.query_type {
QueryType::Get => {
for oid in query.oids {
let value = snmp_get(&job.device, &oid).await?;
oid_values.insert(oid, value);
}
}
QueryType::Walk => {
for base_oid in query.oids {
let results = snmp_walk(&job.device, &base_oid).await?;
oid_values.extend(results);
}
}
}
}
// 4. Send results back
let result = SnmpResult {
equipment_id: job.equipment_id,
job_type: job.job_type,
oid_values,
timestamp: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64,
};
let payload = result.encode_to_vec();
let frame = PhoenixMessage {
event: "result".to_string(),
payload,
};
sink.send(Message::Binary(serde_json::to_vec(&frame)?)).await?;
}
// 5. Raw UDP SNMP implementation
async fn snmp_get(device: &SnmpDevice, oid: &str) -> Result<String> {
// Construct SNMP GET PDU (BER encoded)
// Send via UDP to device.ip:device.port
// Parse response and extract value
// Return value as string
}
async fn snmp_walk(device: &SnmpDevice, base_oid: &str) -> Result<HashMap<String, String>> {
// Construct SNMP GETNEXT PDUs (BER encoded)
// Send via UDP, follow GETNEXT chain
// Parse responses and extract OID/value pairs
// Return map of OID → value
}
```
### Phoenix Channel Message Format
Phoenix channels use JSON-wrapped binary for messages:
```json
{
"event": "jobs",
"payload": <binary protobuf>,
"ref": null,
"topic": "agent:abc123"
}
```
The agent library should handle this framing automatically, or you can use `phoenix_client_rs` crate.
## Benefits Over REST API
| Feature | REST API | WebSocket Channel |
|---------|----------|-------------------|
| Connection | New TCP handshake per request | Persistent connection |
| Overhead | HTTP headers (~500 bytes) | Phoenix frame (~50 bytes) |
| Latency | Round-trip per poll | Server push (0 latency) |
| Encoding | JSON (~3x larger) | Protobuf (binary) |
| Heartbeat | Explicit POST requests | Implicit (connection alive) |
| Complexity | Multiple endpoints | Single channel |
**Size comparison for a typical job:**
- REST JSON: ~2.5 KB
- WebSocket Protobuf: ~800 bytes
- **Savings: 68%**
## Security Notes
- Use `wss://` (WebSocket over TLS) in production
- Token is validated once at connection time
- Invalid tokens reject connection immediately
- No need for per-message authentication
## Error Handling
### Connection Errors
- **Token invalid**: Connection rejected, status 403
- **Network error**: Reconnect with exponential backoff
- **Server restart**: Reconnect, re-authenticate
### Job Errors
- **SNMP timeout**: Send `error` message with details
- **Invalid OID**: Skip OID, log warning
- **Device unreachable**: Send `error` message
## Testing
### Manual Testing with websocat
```bash
# Install websocat
brew install websocat
# Connect and listen
websocat "ws://localhost:4000/socket/agent?token=YOUR_TOKEN"
# Server will send jobs message immediately
```
### Unit Testing
Mock the WebSocket connection and verify:
1. Agent sends correct protobuf on "result" event
2. Agent parses "jobs" message correctly
3. SNMP queries execute with correct parameters
## Protocol Version
Current version: **1.0**
Future versions may add:
- Job priority queuing
- Partial result streaming
- Compression (gzip protobuf)
- Multi-tenant isolation

View file

@ -1,157 +0,0 @@
# Amazon SES Configuration
This document describes how to configure Amazon SES for outbound email in Towerops.
## Required Environment Variables
The following environment variables must be set in production:
```bash
# AWS Credentials (required)
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
# AWS Region (optional, defaults to us-east-1)
AWS_REGION=us-east-1
```
## AWS IAM Permissions
The IAM user or role needs the following SES permissions:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
]
}
```
## SES Setup Steps
1. **Verify Your Domain in SES**
- Go to AWS Console > SES > Verified identities
- Click "Create identity" > Domain
- Add your domain (e.g., `towerops.net`)
- Add the required DNS records (DKIM, MAIL FROM)
- Wait for verification (can take up to 72 hours)
2. **Verify the From Email Address**
- In SES Console, verify `hi@towerops.net` (or your chosen from address)
- Check the verification email and click the confirmation link
3. **Move Out of SES Sandbox (Production)**
- By default, SES is in sandbox mode (can only send to verified addresses)
- Request production access: AWS Console > SES > Account dashboard > Request production access
- Fill out the form with your use case
- Approval typically takes 24 hours
4. **Create IAM User for Towerops**
```bash
aws iam create-user --user-name towerops-ses
aws iam attach-user-policy --user-name towerops-ses --policy-arn arn:aws:iam::aws:policy/AmazonSESFullAccess
aws iam create-access-key --user-name towerops-ses
```
Save the `AccessKeyId` and `SecretAccessKey` from the output.
## Configuration in Towerops
The from email address is configured via application config (not environment variable):
**In `config/runtime.exs` or `config/prod.exs`:**
```elixir
config :towerops, :mailer_from, {"Towerops", "hi@towerops.net"}
```
Or set it dynamically via environment variable by adding to `config/runtime.exs`:
```elixir
config :towerops, :mailer_from,
{System.get_env("MAILER_FROM_NAME") || "Towerops",
System.get_env("MAILER_FROM_EMAIL") || "hi@towerops.net"}
```
## Testing Email Configuration
### In Development (using IEx)
```elixir
# Start the application
iex -S mix
# Test sending an email
alias Towerops.Accounts.UserNotifier
user = %{email: "test@example.com"}
UserNotifier.deliver_update_email_instructions(user, "http://example.com/test")
```
### In Production
Check the logs for successful delivery:
```
[info] User email sent to test@example.com: Update email instructions
```
Or check for errors:
```
[error] Failed to send user email to test@example.com: <reason>
```
## Monitoring
- Monitor bounce rates in AWS SES Console
- Set up SNS notifications for bounces and complaints
- Check CloudWatch metrics for email sending
## Troubleshooting
### "Email address not verified" error
- Ensure the from address is verified in SES
- If in sandbox mode, ensure recipient is also verified
### "Access denied" error
- Check IAM permissions
- Verify AWS credentials are correct
- Ensure credentials have SES permissions
### "MessageRejected" error
- Check SES sending limits
- Verify domain DKIM records are set up
- Check if account is in sandbox mode
### "Throttling" error
- SES has sending limits (default: 1 email/second)
- Request limit increase in AWS Console
## Current Configuration
**Adapter**: `Swoosh.Adapters.ExAwsSES`
**Default Region**: `us-east-1`
**Default From**: `{"Towerops", "hi@towerops.net"}`
## Alternative: SMTP Configuration
If you prefer SMTP over API (not recommended), you can use:
```elixir
config :towerops, Towerops.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: "email-smtp.us-east-1.amazonaws.com",
port: 587,
username: System.get_env("SES_SMTP_USERNAME"),
password: System.get_env("SES_SMTP_PASSWORD"),
tls: :always,
auth: :always
```
SMTP credentials can be generated in AWS Console > SES > SMTP settings.
**Note**: API method is preferred as it's more reliable and doesn't require managing SMTP credentials.

View file

@ -1,602 +0,0 @@
# TowerOps - Network Monitoring & Alerting Platform
## Implementation Plan
## Overview
Multi-tenant network monitoring and alerting application for tracking network equipment health via ping monitoring.
## Core Requirements
1. User accounts (email as username)
2. Organizations (multi-tenant)
3. Organization membership with permission levels
4. Site hierarchy
5. Equipment management (IP-based)
6. Automated ping monitoring (5-minute intervals)
7. Alerting system
---
## Data Model Design
### Users
- `id` (binary_id, PK)
- `email` (string, unique, username)
- `hashed_password` (string)
- `confirmed_at` (utc_datetime, nullable)
- `inserted_at` / `updated_at` (utc_datetime)
### Organizations
- `id` (binary_id, PK)
- `name` (string)
- `slug` (string, unique) - for URLs
- `inserted_at` / `updated_at` (utc_datetime)
### OrganizationMemberships
- `id` (binary_id, PK)
- `organization_id` (binary_id, FK -> organizations)
- `user_id` (binary_id, FK -> users)
- `role` (enum: owner, admin, member, viewer)
- `inserted_at` / `updated_at` (utc_datetime)
- Unique constraint on (organization_id, user_id)
**Permission Levels:**
- `owner` - Full control, can delete org, manage all settings
- `admin` - Can manage users, sites, equipment, view all
- `member` - Can add/edit equipment, view sites
- `viewer` - Read-only access
### Sites
- `id` (binary_id, PK)
- `organization_id` (binary_id, FK -> organizations)
- `parent_site_id` (binary_id, FK -> sites, nullable) - for hierarchy
- `name` (string)
- `description` (text, nullable)
- `location` (string, nullable)
- `inserted_at` / `updated_at` (utc_datetime)
### Equipment
- `id` (binary_id, PK)
- `site_id` (binary_id, FK -> sites)
- `name` (string)
- `ip_address` (string)
- `description` (text, nullable)
- `status` (enum: up, down, unknown)
- `last_checked_at` (utc_datetime, nullable)
- `last_status_change_at` (utc_datetime, nullable)
- `monitoring_enabled` (boolean, default: true)
- `check_interval_seconds` (integer, default: 300) - 5 minutes
- `inserted_at` / `updated_at` (utc_datetime)
### MonitoringChecks (historical log)
- `id` (binary_id, PK)
- `equipment_id` (binary_id, FK -> equipment)
- `status` (enum: success, failure)
- `response_time_ms` (integer, nullable)
- `checked_at` (utc_datetime)
- Index on (equipment_id, checked_at)
### Alerts
- `id` (binary_id, PK)
- `equipment_id` (binary_id, FK -> equipment)
- `alert_type` (enum: equipment_down, equipment_up)
- `triggered_at` (utc_datetime)
- `acknowledged_at` (utc_datetime, nullable)
- `acknowledged_by_id` (binary_id, FK -> users, nullable)
- `resolved_at` (utc_datetime, nullable)
- `email_sent_at` (utc_datetime, nullable)
- `inserted_at` / `updated_at` (utc_datetime)
### OrganizationInvitations
- `id` (binary_id, PK)
- `organization_id` (binary_id, FK -> organizations)
- `email` (string)
- `role` (enum: admin, member, viewer) - cannot invite as owner
- `token` (string, unique) - secure random token for invite link
- `invited_by_id` (binary_id, FK -> users)
- `accepted_at` (utc_datetime, nullable)
- `accepted_by_id` (binary_id, FK -> users, nullable)
- `expires_at` (utc_datetime) - invites expire after 7 days
- `inserted_at` / `updated_at` (utc_datetime)
---
## Architecture Components
### 1. Authentication & Authorization
**Use Phoenix.LiveView built-in patterns:**
- Email/password authentication
- Session-based auth with LiveView
- Password reset via email
**Authorization Strategy:**
- Context-based permissions (organization-scoped)
- Plugs for organization membership verification
- LiveView mount hooks for permission checks
- Helper functions: `can?(user, :action, resource)`
### 2. Multi-Tenancy
**Organization Scoping:**
- All queries scoped by current_organization
- LiveView assigns: `@current_user`, `@current_organization`, `@current_membership`
- Router organization switcher for users in multiple orgs
- URL structure: `/orgs/:org_slug/sites`, `/orgs/:org_slug/equipment`
### 3. Monitoring System
**Background Job Architecture:**
- Use GenServer or DynamicSupervisor for monitoring workers
- One worker per equipment (or batched by site)
- Quantum or similar for scheduling (or custom OTP solution)
- Use `:gen_icmp` or System.cmd("ping") for ping checks
**Monitoring Flow:**
1. Worker wakes up every N seconds (configurable per equipment)
2. Pings equipment IP address
3. Records result in monitoring_checks table
4. Updates equipment status if changed
5. Creates alert if status transitions (up->down or down->up)
6. Broadcasts status change via PubSub for real-time UI updates
### 4. Real-Time Updates
**Phoenix PubSub Topics:**
- `organization:#{org_id}:equipment:#{equipment_id}` - Equipment status
- `organization:#{org_id}:alerts` - New alerts
- `organization:#{org_id}:sites` - Site changes
**LiveView Integration:**
- Subscribe to relevant topics on mount
- Handle PubSub messages to update assigns
- Stream-based updates for lists
### 5. UI Structure
**LiveView Pages:**
- `/login`, `/register` - Authentication
- `/orgs` - Organization list/switcher
- `/orgs/:slug/dashboard` - Overview, active alerts, recent changes
- `/orgs/:slug/sites` - Site hierarchy tree view
- `/orgs/:slug/sites/:id` - Site detail with equipment list
- `/orgs/:slug/equipment` - All equipment list
- `/orgs/:slug/equipment/:id` - Equipment detail with check history
- `/orgs/:slug/alerts` - Alert history
- `/orgs/:slug/settings` - Org settings, members
**Components:**
- Organization switcher (header)
- Site tree navigator
- Equipment status badge
- Alert list/feed
- Permission-based action buttons
---
## Implementation Stages
### Stage 1: Foundation & Authentication
**Goal**: User authentication and basic org structure
**Success Criteria**:
- Users can register/login with email
- Users can create organizations
- Users can switch between organizations
- Basic navigation structure
- Tests passing
**Detailed Tasks**:
#### 1.1 User Authentication
- [ ] Run `mix phx.gen.auth Accounts User users` to scaffold auth system
- [ ] Review and customize generated code (email as username)
- [ ] Update user registration to create first organization
- [ ] Add tests for auth flows
**Files Created**:
- `lib/towerops/accounts.ex` - User context
- `lib/towerops/accounts/user.ex` - User schema
- `lib/towerops/accounts/user_token.ex` - Session tokens
- `lib/towerops_web/user_auth.ex` - Auth plugs
- `lib/towerops_web/controllers/user_session_controller.ex`
- `lib/towerops_web/controllers/user_registration_controller.ex`
- `lib/towerops_web/controllers/user_reset_password_controller.ex`
- `lib/towerops_web/controllers/user_settings_controller.ex`
- Migrations for users and user_tokens tables
#### 1.2 Organizations & Memberships
- [ ] Create migration: `mix ecto.gen.migration create_organizations`
- [ ] Create migration: `mix ecto.gen.migration create_organization_memberships`
- [ ] Create migration: `mix ecto.gen.migration create_organization_invitations`
- [ ] Create `lib/towerops/organizations.ex` context
- [ ] Create `lib/towerops/organizations/organization.ex` schema
- [ ] Create `lib/towerops/organizations/membership.ex` schema
- [ ] Create `lib/towerops/organizations/invitation.ex` schema
- [ ] Add slug generation for organizations (use Ecto changeset)
- [ ] Add tests for organizations context
**Migration Details**:
```elixir
# organizations table
create table(:organizations, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :slug, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:organizations, [:slug])
# organization_memberships table
create table(:organization_memberships, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all)
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
add :role, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:organization_memberships, [:organization_id, :user_id])
create index(:organization_memberships, [:user_id])
```
#### 1.3 Multi-Org Navigation
- [ ] Create organization switcher LiveView component
- [ ] Add current_organization plug to router
- [ ] Create `/orgs` LiveView (list user's organizations)
- [ ] Create `/orgs/new` LiveView (create organization)
- [ ] Update router with organization-scoped routes
- [ ] Add breadcrumb navigation component
- [ ] Add tests for organization switching
**Router Structure**:
```elixir
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
live "/orgs", OrgLive.Index
live "/orgs/new", OrgLive.New
end
scope "/orgs/:org_slug", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
live "/", DashboardLive
# Future: sites, equipment, etc.
end
```
#### 1.4 Authorization System
- [ ] Create `lib/towerops/organizations/policy.ex` for permission checks
- [ ] Add `can?/3` helper function
- [ ] Create `:load_current_organization` plug
- [ ] Add permission checks to LiveView mount callbacks
- [ ] Add tests for authorization
**Permission Matrix**:
| Action | Owner | Admin | Member | Viewer |
|--------|-------|-------|--------|--------|
| View org | ✓ | ✓ | ✓ | ✓ |
| Edit org settings | ✓ | ✓ | ✗ | ✗ |
| Delete org | ✓ | ✗ | ✗ | ✗ |
| Manage members | ✓ | ✓ | ✗ | ✗ |
| Add/edit equipment | ✓ | ✓ | ✓ | ✗ |
| View equipment | ✓ | ✓ | ✓ | ✓ |
#### 1.5 Basic UI & Layouts
- [ ] Update `layouts.ex` to include org switcher in header
- [ ] Create organization badge component
- [ ] Style navigation with Tailwind
- [ ] Add flash message styling
- [ ] Ensure responsive design
### Stage 2: Sites & Equipment Management
**Goal**: CRUD for sites and equipment
**Success Criteria**:
- Users can create/edit/delete sites
- Sites can have parent sites (hierarchy)
- Users can add equipment to sites
- Equipment has IP address and basic info
**Tasks**:
1. Generate sites schema and LiveViews
2. Build site hierarchy tree component
3. Generate equipment schema and LiveViews
4. Add IP address validation
5. Permission checks on all actions
### Stage 3: Monitoring System ✓ COMPLETE
**Goal**: Automated ping monitoring with TimescaleDB
**Success Criteria**:
- ✓ Equipment is pinged at configurable intervals
- ✓ Status updates in real-time via PubSub
- ✓ Monitoring checks are logged
- ✓ TimescaleDB hypertable for efficient time-series storage
- ✓ Automatic data retention and compression
- ✓ Continuous aggregates for dashboard metrics
**Tasks**:
1. ✓ Design monitoring worker architecture (GenServer + DynamicSupervisor)
2. ✓ Implement ping functionality (System.cmd with OS-specific args)
3. ✓ Create monitoring_checks schema with TimescaleDB hypertable
4. ✓ Build GenServer workers for monitoring
5. ✓ Add PubSub broadcasting
6. ✓ Update LiveView to receive real-time updates
7. ✓ Configure TimescaleDB retention policies
8. ✓ Create continuous aggregates for metrics
**Completed**: 2025-12-21
**Files Created**:
- `lib/towerops/monitoring/check.ex` - MonitoringCheck schema
- `lib/towerops/monitoring.ex` - Monitoring context
- `lib/towerops/monitoring/ping.ex` - Ping functionality
- `lib/towerops/monitoring/equipment_monitor.ex` - GenServer for monitoring individual equipment
- `lib/towerops/monitoring/supervisor.ex` - Supervisor for managing monitor workers
- `priv/repo/migrations/*_create_monitoring_checks.exs` - Database migration with TimescaleDB hypertable
**TimescaleDB Integration**:
- `monitoring_checks` converted to hypertable partitioned by `checked_at`
- Retention policy: Keep raw data for 90 days, then automatically delete
- Compression policy: Compress chunks older than 7 days
- Continuous aggregates: Hourly and daily rollups for dashboard performance
### Stage 4: Alerting ✓ COMPLETE
**Goal**: Alert generation and management
**Success Criteria**:
- ✓ Alerts created on status changes
- ✓ Users can view alert history
- ✓ Users can acknowledge alerts
- ✓ Real-time alert notifications via PubSub
**Tasks**:
1. ✓ Create alerts schema
2. ✓ Build alert creation logic in monitoring workers
3. ✓ Create alert LiveView pages
4. ✓ Add alert acknowledgment
5. ✓ Alert notifications in UI
6. ✓ Dashboard integration with active alerts
**Completed**: 2025-12-21
**Files Created**:
- `priv/repo/migrations/*_create_alerts.exs` - Alerts table migration
- `lib/towerops/alerts/alert.ex` - Alert schema
- `lib/towerops/alerts.ex` - Alerts context
- `lib/towerops_web/live/alert_live/index.ex` - Alerts listing page
- `lib/towerops_web/live/alert_live/index.html.heex` - Alerts UI
**Files Modified**:
- `lib/towerops/monitoring/equipment_monitor.ex` - Alert creation on status changes
- `lib/towerops_web/live/dashboard_live.ex` - Active alerts display
- `lib/towerops_web/live/dashboard_live.html.heex` - Dashboard UI updates
- `lib/towerops_web/router.ex` - Alert routes
### Stage 5: Polish & Production ✓ COMPLETE
**Goal**: Production-ready application
**Success Criteria**:
- ✓ All tests passing (154 tests, 60.83% coverage)
- ✓ Polished UI with Tailwind (removed daisyUI, custom components)
- ✓ Email notifications configured
- ✓ Production deployment ready
**Tasks**:
1. ✓ Comprehensive test coverage (100% on core business logic)
2. ✓ UI/UX improvements (Dashboard redesigned with custom Tailwind)
3. ✓ Email alert notifications (SMTP-based, sent to owners/admins)
4. ✓ Performance optimization (Added monitoring_enabled index)
5. ✓ Documentation (Complete DEPLOYMENT.md guide)
**Completed**: 2025-12-24
**Files Created**:
- `lib/towerops/alerts/alert_notifier.ex` - Email notification service
- `DEPLOYMENT.md` - Comprehensive production deployment guide
- `priv/repo/migrations/*_add_email_sent_at_to_alerts.exs` - Email tracking
- `priv/repo/migrations/*_add_monitoring_enabled_index.exs` - Performance optimization
**Files Modified**:
- `lib/towerops/alerts/alert.ex` - Added email_sent_at field
- `lib/towerops/alerts.ex` - Added send_alert_notification/1 function
- `lib/towerops/organizations.ex` - Added list_organization_notification_recipients/1
- `lib/towerops/monitoring/equipment_monitor.ex` - Integrated email sending
- `lib/towerops_web/live/dashboard_live.html.heex` - Modern Tailwind UI
**Email Notification System**:
- Sends emails to organization owners and admins
- Equipment down alerts with details
- Equipment recovery notifications
- Disabled in test environment to avoid connection issues
- Tracks email_sent_at timestamp
**UI Improvements**:
- Removed all daisyUI classes
- Custom Tailwind components with dark mode support
- Modern card-based dashboard layout
- Proper semantic color scheme (zinc, blue, red, green, amber)
- Improved accessibility and responsiveness
**Performance Optimizations**:
- Added index on equipment.monitoring_enabled for fast filtering
- All existing queries already optimized with proper indexes
- TimescaleDB compression and retention in production
**Production Readiness**:
- Complete deployment documentation
- Environment-specific configurations
- SSL/TLS setup guide
- Docker and systemd examples
- Database backup procedures
- Troubleshooting guide
### Stage 6: Distributed Monitoring Agents (Future)
**Goal**: Deploy-able Rust-based monitoring agents for internal network monitoring
**Status**: PLANNED - Not yet started
**Overview**:
Customer-deployable Rust binary that runs inside customer networks to monitor equipment from within their own infrastructure. Agents authenticate using deployment keys and report monitoring results back to the TowerOps platform.
**Success Criteria**:
- Rust agent binary can be deployed on customer networks (Linux, Windows, macOS)
- Agent authenticates with deployment key tied to organization
- Agent pings equipment reachable on local network
- Results reported back to TowerOps API
- Equipment can be configured to use agent-based or platform-based monitoring
- Agent status visible in TowerOps dashboard
**Architecture**:
- **Rust Agent**: Lightweight binary for customer deployment
- Ping functionality using OS-native ICMP
- Configurable check intervals
- Local caching/queue for offline resilience
- Secure API communication over HTTPS
- Auto-update capability
- **Deployment Keys**: Scoped API credentials
- Per-organization deployment keys
- Limited scope (can only submit monitoring results)
- Revocable from TowerOps dashboard
- Multiple keys per organization for different sites/networks
- **API Endpoints**: Backend support for agent communication
- POST /api/v1/monitoring/checks - Submit monitoring results
- GET /api/v1/monitoring/config - Fetch equipment list for agent
- Authentication via deployment key header
**Tasks**:
1. Design deployment key schema and API
2. Create agent registration and key management in TowerOps
3. Build Rust monitoring agent
- ICMP ping implementation
- API client for result submission
- Configuration management
- Error handling and retry logic
4. Add equipment assignment to agents
5. Agent status monitoring in dashboard
6. Documentation for agent deployment
**Database Changes Needed**:
```elixir
# deployment_keys table
- id (binary_id)
- organization_id (FK)
- name (string) - Human-readable name for the key
- key_hash (string) - Hashed version of the key
- last_used_at (utc_datetime)
- created_by_id (FK -> users)
- revoked_at (utc_datetime, nullable)
# monitoring_agents table
- id (binary_id)
- organization_id (FK)
- deployment_key_id (FK)
- name (string)
- version (string) - Agent version
- last_seen_at (utc_datetime)
- status (enum: active, inactive, error)
# equipment table additions
- monitoring_agent_id (FK -> monitoring_agents, nullable)
- monitoring_source (enum: platform, agent) - Where monitoring happens
```
**Notes**:
- This is a significant feature and will be implemented much later
- Current platform-based monitoring (Stage 3) remains as fallback
- Allows monitoring of internal/private networks not accessible from internet
- Agent runs continuously, not triggered from platform
---
## Technical Decisions
### Database
- PostgreSQL with Ecto
- Use binary_id (UUID) for all primary keys (already configured)
- Indexes on foreign keys and frequently queried fields
**TimescaleDB for Time-Series Data**:
- TimescaleDB extension enabled on PostgreSQL
- `monitoring_checks` table converted to hypertable
- Automatic partitioning by time (checked_at column)
- Retention policy: 90 days for raw data
- Compression policy: Compress chunks older than 7 days
- Continuous aggregates for dashboard performance:
- `monitoring_checks_hourly` - Hourly rollups (avg response time, success rate)
- `monitoring_checks_daily` - Daily rollups for long-term trends
**Why TimescaleDB**:
- Built as PostgreSQL extension (not separate database)
- Works seamlessly with Ecto
- Optimized for time-series queries
- Automatic data lifecycle management
- Fast aggregations for dashboards
- Scales to millions of monitoring checks
### Background Jobs
**Options:**
1. Custom OTP solution with GenServer + Process.send_after
2. Quantum scheduler
3. Oban (more heavyweight but robust)
**Recommendation**: Start with custom OTP, migrate to Oban if needed
### Real-time Communication
- Phoenix PubSub for server-side messaging
- LiveView for UI updates
- No WebSocket client code needed
### Ping Implementation
**Options:**
1. `:gen_icmp` library (requires raw sockets, might need permissions)
2. System.cmd("ping") - simpler, cross-platform
3. HTTP health checks (future enhancement)
**Recommendation**: System.cmd("ping") for MVP, abstract for future protocols
---
## Decisions Made
1. **Email notifications**: ✓ Yes - Users receive email alerts on status changes
2. **Multi-org users**: ✓ Yes - Users can belong to multiple organizations
3. **Invite system**: ✓ Email invitation system (secure token-based invites)
4. **Check intervals**: ✓ Customizable per equipment (stored in equipment.check_interval_seconds)
## Additional Schema Needed
Based on decisions:
- **OrganizationInvitations** table for email invite workflow
- **email_sent_at** field in Alerts for tracking email delivery
## Open Questions
1. **Alert escalation**: Any escalation policies (e.g., page admin if down > X minutes)?
2. **Retention**: How long to keep monitoring_checks history?
3. **Equipment types**: Just ping for now, or plan for SNMP, HTTP, etc.?
4. **Site hierarchy depth**: Any limit on site nesting levels?
---
## Status: Stage 5 Complete - Production Ready! 🚀
**Completed Stages**:
- ✓ Stage 1: Foundation & Authentication (2025-12-21)
- ✓ Stage 2: Sites & Equipment Management (2025-12-21)
- ✓ Stage 3: Monitoring System with TimescaleDB (2025-12-21)
- ✓ Stage 4: Alerting (2025-12-21)
- ✓ Stage 5: Polish & Production (2025-12-24)
**Current Status**: All tests passing (154/154), production-ready
**Features Implemented**:
- Multi-tenant organizations with role-based access
- Hierarchical site management
- Equipment monitoring with ping checks
- TimescaleDB time-series optimization (production-ready)
- Automatic alerting on status changes
- Email notifications to owners/admins
- Real-time dashboard with LiveView
- Alert acknowledgment system
- Modern Tailwind UI (no daisyUI)
- Comprehensive deployment documentation
- Performance optimizations (database indexes)
**Next Steps**:
- Stage 6: Distributed Monitoring Agents (Future - see above for details)
- Deploy to production following DEPLOYMENT.md guide
Last updated: 2025-12-24

View file

@ -1,264 +0,0 @@
# TimescaleDB Setup for TowerOps
## Overview
TowerOps uses TimescaleDB for efficient time-series storage of monitoring data. TimescaleDB is **required in all environments** (development, test, and production).
## Why TimescaleDB?
- **Hypertables**: Automatic time-based partitioning for monitoring_checks
- **Compression**: ~95% space savings for historical data
- **Retention**: Automatic cleanup of old data
- **Continuous Aggregates**: Pre-computed hourly/daily statistics for fast dashboard queries
- **Scales**: Handles millions of monitoring checks efficiently
## Installation
TimescaleDB must be installed in all environments (dev, test, prod).
### macOS (Homebrew)
```bash
# Add TimescaleDB tap
brew tap timescale/tap
# Install TimescaleDB
brew install timescaledb
# Run the setup script
/opt/homebrew/opt/timescaledb/bin/timescaledb-tune --quiet --yes
# Restart PostgreSQL
brew services restart postgresql@17
```
**For Postgres.app users:**
```bash
# Download TimescaleDB extension for your PostgreSQL version
# Visit: https://docs.timescale.com/self-hosted/latest/install/installation-macos/
# Or use Docker (see below)
```
### Linux (Ubuntu/Debian)
```bash
# Add TimescaleDB repository
sudo apt install gnupg postgresql-common apt-transport-https lsb-release wget
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
echo "deb https://packagecloud.io/timescale/timescaledb/ubuntu/ $(lsb_release -c -s) main" | sudo tee /etc/apt/sources.list.d/timescaledb.list
wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg
# Install TimescaleDB
sudo apt update
sudo apt install timescaledb-2-postgresql-17
# Run setup
sudo timescaledb-tune --quiet --yes
# Restart PostgreSQL
sudo systemctl restart postgresql
```
### Docker (Easiest for Development)
```bash
# Stop your current PostgreSQL (if running)
brew services stop postgresql@17 # macOS
# or
sudo systemctl stop postgresql # Linux
# Run TimescaleDB in Docker
docker run -d \
--name timescaledb \
-p 5432:5432 \
-e POSTGRES_PASSWORD=postgres \
-v timescaledb_data:/var/lib/postgresql/data \
timescale/timescaledb:latest-pg17
# Update config/dev.exs to use Docker PostgreSQL
# (already configured to use localhost:5432)
```
## Verification
After installing TimescaleDB, verify it's working:
```bash
# Connect to PostgreSQL
psql -U postgres -d towerops_dev
# Check if TimescaleDB is available
SELECT * FROM pg_available_extensions WHERE name = 'timescaledb';
# If available, enable it
CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
# Verify version
SELECT extversion FROM pg_extension WHERE extname = 'timescaledb';
# Exit psql
\q
```
## Running Migrations
After installing TimescaleDB, run migrations to set up the database:
```bash
# Development
mix ecto.reset
# Test
MIX_ENV=test mix ecto.reset
# Production
MIX_ENV=prod mix ecto.migrate
```
The migrations will:
- Create the `monitoring_checks` hypertable
- Set up retention policy (90 days)
- Configure compression (data older than 7 days)
- Create continuous aggregates (hourly and daily)
## TimescaleDB Features Enabled
### Hypertable
- `monitoring_checks` table is automatically partitioned by time
- Optimized for time-range queries
- Efficient INSERTs for high-volume monitoring data
### Retention Policy
- Raw monitoring data automatically deleted after 90 days
- Keeps database size manageable
- Configurable in migration file
### Compression Policy
- Data older than 7 days is automatically compressed
- ~95% space savings
- Transparent decompression on query
### Continuous Aggregates
#### monitoring_checks_hourly
- Pre-computed hourly statistics per equipment
- Includes: total checks, successful/failed counts, avg/min/max response time
- Refreshed every hour automatically
#### monitoring_checks_daily
- Pre-computed daily statistics per equipment
- Includes uptime percentage calculation
- Refreshed daily automatically
## Querying Time-Series Data
### Using Continuous Aggregates
```elixir
# Get hourly stats for last 24 hours
start_time = DateTime.utc_now() |> DateTime.add(-24 * 60 * 60, :second)
end_time = DateTime.utc_now()
Monitoring.get_hourly_stats(equipment_id, start_time, end_time)
# Get daily stats for last 30 days
start_time = DateTime.utc_now() |> DateTime.add(-30 * 24 * 60 * 60, :second)
Monitoring.get_daily_stats(equipment_id, start_time, DateTime.utc_now())
# Get uptime percentage for last 30 days
Monitoring.get_uptime_percentage(equipment_id, 30)
```
### Direct SQL with TimescaleDB Functions
```sql
-- Time-bucketed aggregation
SELECT
time_bucket('5 minutes', checked_at) AS bucket,
AVG(response_time_ms) as avg_response_time
FROM monitoring_checks
WHERE equipment_id = 'abc-123'
AND checked_at > NOW() - INTERVAL '1 hour'
GROUP BY bucket
ORDER BY bucket DESC;
-- Using continuous aggregate for fast queries
SELECT *
FROM monitoring_checks_hourly
WHERE equipment_id = 'abc-123'
AND bucket > NOW() - INTERVAL '7 days'
ORDER BY bucket DESC;
```
## Monitoring TimescaleDB
```sql
-- Check hypertable info
SELECT * FROM timescaledb_information.hypertables;
-- Check compression stats
SELECT * FROM timescaledb_information.compression_settings;
-- Check chunk info
SELECT * FROM timescaledb_information.chunks
WHERE hypertable_name = 'monitoring_checks';
-- Check continuous aggregate policies
SELECT * FROM timescaledb_information.continuous_aggregates;
```
## Troubleshooting
### Extension not available
- Make sure TimescaleDB is installed: `brew list | grep timescale` (macOS)
- Check PostgreSQL config: `cat $(pg_config --sysconfdir)/postgresql.conf | grep shared_preload_libraries`
- Should include: `shared_preload_libraries = 'timescaledb'`
### Migration fails with TimescaleDB errors
- Drop database and recreate: `mix ecto.drop && mix ecto.create && mix ecto.migrate`
- Check PostgreSQL logs: `tail -f /opt/homebrew/var/log/postgresql@17.log`
### Continuous aggregates not refreshing
```sql
-- Manually refresh
CALL refresh_continuous_aggregate('monitoring_checks_hourly', NULL, NULL);
CALL refresh_continuous_aggregate('monitoring_checks_daily', NULL, NULL);
```
## Production Considerations
### Chunk Size
Default chunk interval is 7 days. For high-volume deployments, consider:
```sql
-- Set chunk interval to 1 day for higher write throughput
SELECT set_chunk_time_interval('monitoring_checks', INTERVAL '1 day');
```
### Retention Policy
Adjust retention based on compliance requirements:
```sql
-- Extend retention to 1 year
SELECT remove_retention_policy('monitoring_checks');
SELECT add_retention_policy('monitoring_checks', INTERVAL '1 year');
```
### Compression
Tune compression for your workload:
```sql
-- Compress after 1 day instead of 7 days
SELECT remove_compression_policy('monitoring_checks');
SELECT add_compression_policy('monitoring_checks', INTERVAL '1 day');
```
## Resources
- [TimescaleDB Documentation](https://docs.timescale.com/)
- [TimescaleDB Best Practices](https://docs.timescale.com/use-timescale/latest/schema-management/about-schemas/)
- [Continuous Aggregates Guide](https://docs.timescale.com/use-timescale/latest/continuous-aggregates/)
- [Compression Guide](https://docs.timescale.com/use-timescale/latest/compression/)

View file

@ -1,313 +0,0 @@
# WebSocket Migration - Agent Communication
## Status: ✅ Complete (Phoenix side)
This document summarizes the migration from REST API to WebSocket-based agent communication.
## What Was Built
### Phoenix (Server) Side
#### 1. WebSocket Infrastructure
- **`ToweropsWeb.AgentSocket`** (`lib/towerops_web/channels/agent_socket.ex`)
- WebSocket connection handler
- Token-based authentication at connection time
- Assigns agent_token_id and organization_id to socket
- **`ToweropsWeb.AgentChannel`** (`lib/towerops_web/channels/agent_channel.ex`)
- Channel for bidirectional communication
- Handles 4 message types:
- `jobs` (server → agent): Job list with SNMP queries
- `result` (agent → server): Raw SNMP results
- `heartbeat` (agent → server): Agent status
- `error` (agent → server): Job failures
- Generates DISCOVER and POLL jobs based on equipment state
- Processes polling results (sensor readings, interface stats)
- Triggers full discovery when agent reports success
#### 2. Protocol Buffers Messages
- Extended `priv/proto/agent.proto` with new message types:
- `AgentJob`, `AgentJobList` - Job definitions
- `SnmpDevice`, `SnmpQuery` - SNMP connection details
- `SnmpResult` - Raw OID→value results
- `AgentHeartbeat` - Agent metadata
- `AgentError` - Error reporting
- `JobType` enum (DISCOVER, POLL)
- `QueryType` enum (GET, WALK)
- Generated Elixir modules (`lib/towerops/proto/agent.pb.ex`)
#### 3. Endpoint Configuration
- Removed REST endpoints:
- ❌ `GET /api/v1/agent/config`
- ❌ `POST /api/v1/agent/metrics`
- ❌ `POST /api/v1/agent/heartbeat`
- ❌ `AgentController` (entire file deleted)
- ❌ `:agent_api` pipeline
- Added WebSocket endpoint:
- ✅ `wss://towerops.net/socket/agent?token=<token>`
#### 4. Discovery Module Updates
- Made `select_profile/1` public for channel use
- Kept existing SNMP discovery logic intact
- Channel triggers full discovery when agent reports reachability
### Rust (Agent) Side
#### 1. WebSocket Client Skeleton
- **`websocket_client.rs`** - Complete structure with:
- `AgentClient::connect()` - Establish WebSocket connection
- `AgentClient::run()` - Main event loop
- Message handling for Phoenix channel format
- Job execution framework
- Result/heartbeat/error sending
- TODO: Raw SNMP GET/WALK implementations
#### 2. Dependencies Added
- `tokio-tungstenite` - WebSocket client
- `futures` - Async stream handling
- `base64` - For Phoenix channel binary encoding
- `hostname` - System information
- `anyhow`, `thiserror` - Error handling
## Architecture Overview
```
┌─────────────┐ ┌──────────────┐
│ Agent │←──── WebSocket ───→│ Phoenix │
│ (Rust) │ (persistent) │ (Elixir) │
└─────────────┘ └──────────────┘
│ │
│ 1. Connect with token │
│──────────────────────────────────→│
│ │
│ 2. "jobs" message (protobuf) │
│←──────────────────────────────────│
│ │
│ 3. Execute SNMP GET/WALK │
│ (raw UDP packets) │
│ │
│ 4. "result" message (OID→value) │
│──────────────────────────────────→│
│ │
│ 5. Parse with profiles,
│ save to database
│ │
│ 6. "heartbeat" every 60s │
│──────────────────────────────────→│
└───────────────────────────────────┘
```
## Message Flow
### Discovery Job
```protobuf
Server → Agent:
{
event: "jobs",
payload: AgentJobList {
jobs: [{
job_id: "discover:abc123",
job_type: DISCOVER,
equipment_id: "abc123",
device: {
ip: "10.250.1.26",
community: "public",
version: "1",
port: 161
},
queries: [
{query_type: GET, oids: ["1.3.6.1.2.1.1.1.0", ...]},
{query_type: WALK, oids: ["1.3.6.1.2.1.2.2.1"]},
{query_type: WALK, oids: ["1.3.6.1.4.1.41112"]}
]
}]
}
}
Agent → Server:
{
event: "result",
payload: SnmpResult {
equipment_id: "abc123",
job_type: DISCOVER,
oid_values: {
"1.3.6.1.2.1.1.1.0": "Linux AF11 2.6.33 ...",
"1.3.6.1.2.1.1.2.0": "1.3.6.1.4.1.41112",
"1.3.6.1.2.1.2.2.1.2.3": "eth0",
...
},
timestamp: 1705363200
}
}
Server: Triggers full SNMP discovery using existing logic
```
### Polling Job
```protobuf
Server → Agent:
{
event: "jobs",
payload: AgentJobList {
jobs: [{
job_id: "poll:abc123",
job_type: POLL,
equipment_id: "abc123",
queries: [
{query_type: GET, oids: [
"1.3.6.1.4.1.41112.1.3.2.1.3.1", // Sensor 1
"1.3.6.1.2.1.2.2.1.10.3", // Interface stats
...
]}
]
}]
}
}
Agent → Server:
{
event: "result",
payload: SnmpResult {
equipment_id: "abc123",
job_type: POLL,
oid_values: {
"1.3.6.1.4.1.41112.1.3.2.1.3.1": "5725",
"1.3.6.1.2.1.2.2.1.10.3": "1234567",
...
},
timestamp: 1705363200
}
}
Server: Parses values, saves sensor readings and interface stats
```
## Benefits
### Efficiency
- **68% smaller payloads** (Protobuf vs JSON)
- **No HTTP overhead** (persistent connection)
- **0 latency** for server-push jobs
- **Implicit heartbeat** (connection alive = agent alive)
### Simplicity
- **Single URL** instead of 3 REST endpoints
- **Server controls polling** (no agent-side profiles)
- **Instant job updates** (no 5-minute config poll)
### Agent Complexity Reduction
| Before | After |
|--------|-------|
| 5000+ lines | ~500 lines |
| Device profiles in Rust | Just SNMP UDP client |
| MIB parsing | Server-side only |
| Discovery logic | Server-side only |
| Profile updates require redeploy | Server-side updates |
## What's Left
### Rust Agent Implementation
1. Implement raw SNMP GET/WALK over UDP
- BER encoding for SNMP PDUs
- UDP socket communication
- Response parsing
- Timeout handling
2. Integrate `websocket_client.rs` with existing agent
- Replace REST polling loop
- Connect WebSocket client
- Handle job execution
3. Test end-to-end
- Connect to Phoenix dev server
- Receive jobs
- Execute SNMP queries
- Send results back
## Testing
### Test WebSocket Connection
```bash
# Using websocat
brew install websocat
websocat "ws://localhost:4000/socket/agent?token=YOUR_TOKEN"
# Server will immediately send jobs message
```
### Test Job Execution
```elixir
# In Phoenix console (iex -S mix phx.server)
equipment = Towerops.Equipment.get_equipment!("equipment-id")
agent_token = Towerops.Agents.get_agent_token!("token-id")
# Broadcast new job to agent
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token.id}",
:send_jobs
)
```
## Documentation
- **Protocol spec**: `AGENT_PROTOCOL.md`
- **This migration doc**: `WEBSOCKET_MIGRATION.md`
- **Rust implementation**: `towerops-agent/src/websocket_client.rs`
- **Phoenix channel**: `lib/towerops_web/channels/agent_channel.ex`
## Migration Path
For backwards compatibility during rollout:
1. Deploy Phoenix with WebSocket support
2. Old agents continue using REST API (keep endpoints temporarily)
3. Deploy new WebSocket-based agents
4. Monitor both systems in parallel
5. Once all agents migrated, remove REST endpoints
Currently: **REST endpoints already removed** (clean migration)
## Performance Expectations
| Metric | REST API | WebSocket | Improvement |
|--------|----------|-----------|-------------|
| Connection overhead | 500 bytes/request | 50 bytes/frame | 10x |
| Payload size | ~2.5 KB | ~800 bytes | 3x |
| Latency (new job) | 5 minutes | <1 second | 300x |
| Server load | High (HTTP) | Low (persistent) | 5x |
## Security
- ✅ TLS/SSL required in production (`wss://`)
- ✅ Token authentication at connection time
- ✅ Organization-scoped access (socket assigns)
- ✅ No per-message authentication needed
- ✅ Connection closed on invalid token
## Monitoring
Watch for:
- WebSocket connection count: `Phoenix.PubSub.subscribers(Towerops.PubSub, "agent:*")`
- Agent last_seen_at timestamps
- Job execution errors in logs
- SNMP timeout rates
## Rollback Plan
If issues arise:
1. Revert Phoenix deploy (restore REST endpoints)
2. Old agent continues working
3. Fix issues
4. Redeploy
Currently: **No rollback needed** (forward-only migration)
---
**Completed**: January 16, 2026
**Status**: Phoenix complete, Rust implementation in progress

View file

@ -1,10 +1,8 @@
defmodule SnmpKit.SnmpLib.SNMPv3EdgeCasesTest do
use ExUnit.Case, async: false
alias SnmpKit.SnmpLib.PDU.Constants
alias SnmpKit.SnmpLib.PDU.V3Encoder
alias SnmpKit.SnmpLib.Security.Auth
alias SnmpKit.SnmpLib.Security.Keys
alias SnmpKit.SnmpLib.Security.Priv
@moduletag :snmpv3
@ -484,7 +482,7 @@ defmodule SnmpKit.SnmpLib.SNMPv3EdgeCasesTest do
msg = create_test_v3_message_with_pdu(77_777, pdu, user)
assert {:ok, encoded} = V3Encoder.encode_message(msg, user)
assert {:ok, decoded} = V3Encoder.decode_message(encoded, user)
assert {:ok, _decoded} = V3Encoder.decode_message(encoded, user)
end
end
end

View file

@ -3,7 +3,6 @@ defmodule SnmpKit.SnmpLib.SNMPv3IntegrationTest do
alias SnmpKit.SnmpLib.PDU.V3Encoder
alias SnmpKit.SnmpLib.Security
alias SnmpKit.SnmpLib.Security.Auth
alias SnmpKit.SnmpLib.Security.Keys
alias SnmpKit.SnmpLib.Security.Priv
alias SnmpKit.SnmpLib.Security.USM
@ -17,7 +16,7 @@ defmodule SnmpKit.SnmpLib.SNMPv3IntegrationTest do
test "complete discovery and authentication flow" do
# Step 1: Engine Discovery
discovery_msg = V3Encoder.create_discovery_message(1001)
assert {:ok, discovery_packet} = V3Encoder.encode_message(discovery_msg, nil)
assert {:ok, _discovery_packet} = V3Encoder.encode_message(discovery_msg, nil)
# Simulate response with engine ID
mock_response = create_mock_discovery_response(discovery_msg.msg_id, "remote_engine_123")
@ -283,7 +282,7 @@ defmodule SnmpKit.SnmpLib.SNMPv3IntegrationTest do
request = create_v3_message(pdu.request_id, pdu, user, level)
assert {:ok, packet} = V3Encoder.encode_message(request, user)
assert {:ok, decoded} = V3Encoder.decode_message(packet, user)
assert {:ok, _decoded} = V3Encoder.decode_message(packet, user)
# Each user should only be able to decode their own messages
# Exception: no-auth messages can be decoded by anyone
@ -313,7 +312,7 @@ defmodule SnmpKit.SnmpLib.SNMPv3IntegrationTest do
discovered_engines =
for {engine_id, index} <- Enum.with_index(engines, 1) do
discovery_msg = V3Encoder.create_discovery_message(12_000 + index)
{:ok, discovery_packet} = V3Encoder.encode_message(discovery_msg, nil)
{:ok, _discovery_packet} = V3Encoder.encode_message(discovery_msg, nil)
# Simulate response
mock_response = create_mock_discovery_response(discovery_msg.msg_id, engine_id)

View file

@ -3,7 +3,6 @@ defmodule SnmpKit.SnmpLib.PDU.V3EncoderTest do
alias SnmpKit.SnmpLib.PDU.Constants
alias SnmpKit.SnmpLib.PDU.V3Encoder
alias SnmpKit.SnmpLib.Security
@moduletag :snmpv3