# 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
