Skip to main content

netget/server/rip/
actions.rs

1//! RIP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct RipProtocol;
15
16impl RipProtocol {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22// Implement Protocol trait (common functionality)
23impl Protocol for RipProtocol {
24    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
25        Vec::new()
26    }
27    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
28        vec![
29            send_rip_response_action(),
30            send_rip_request_action(),
31            ignore_request_action(),
32        ]
33    }
34    fn protocol_name(&self) -> &'static str {
35        "RIP"
36    }
37    fn get_event_types(&self) -> Vec<EventType> {
38        get_rip_event_types()
39    }
40    fn stack_name(&self) -> &'static str {
41        "ETH>IP>UDP>RIP"
42    }
43    fn keywords(&self) -> Vec<&'static str> {
44        vec!["rip"]
45    }
46    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
47        use crate::protocol::metadata::{
48            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
49        };
50
51        ProtocolMetadataV2::builder()
52            .connectionless()
53            .state(DevelopmentState::Experimental)
54            // Deliberately silent: A RIP response asserts routes. A fabricated one is
55            // installed in a real routing table.
56            .deliberately_silent()
57            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(520))
58            .well_known_udp_port(520)
59            .implementation("Manual RIPv2 packet construction (RFC 2453)")
60            .llm_control("Optional: which routes to advertise is a policy decision (LLM); with no policy configured the server stays silent with no LLM call")
61            .e2e_testing("Manual RIP packet construction with route entries")
62            .notes("Distance-vector routing protocol, 15 hop limit. A RIP response is NOT wire-determined — which routes to advertise (and their metrics, incl. 16=withdraw) is routing policy, like DNS/DHCP. So with no operator policy (no instruction, no handler) the server applies the spec-safe STATIC default of advertising nothing and answers with NO LLM round-trip; the LLM is consulted only when the operator supplies the routing policy. There is no mechanical RIP reply to synthesise (RIP has no Hello/keepalive machinery)")
63            .build()
64    }
65    fn description(&self) -> &'static str {
66        "Routing Information Protocol v2 for dynamic routing"
67    }
68    fn example_prompt(&self) -> &'static str {
69        "listen on port 520 via rip. Advertise routes for 192.168.1.0/24 (metric 1) and 10.0.0.0/8 (metric 5)"
70    }
71    fn group_name(&self) -> &'static str {
72        "Network"
73    }
74
75    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
76        use crate::llm::actions::StartupExamples;
77
78        StartupExamples::new(
79            // LLM mode
80            json!({
81                "type": "open_server",
82                "port": 520,
83                "base_stack": "rip",
84                "instruction": "RIPv2 routing server. Advertise routes for 192.168.1.0/24 (metric 1) and 10.0.0.0/8 (metric 5). Respond to route requests."
85            }),
86            // Script mode
87            json!({
88                "type": "open_server",
89                "port": 520,
90                "base_stack": "rip",
91                "event_handlers": [{
92                    "event_pattern": "rip_request",
93                    "handler": {
94                        "type": "script",
95                        "language": "python",
96                        "code": "if event.message_type == 'request' then return {type='send_rip_response', routes={{ip_address='192.168.1.0', subnet_mask='255.255.255.0', next_hop='0.0.0.0', metric=1}, {ip_address='10.0.0.0', subnet_mask='255.0.0.0', next_hop='0.0.0.0', metric=5}}} else return {type='ignore_request'} end"
97                    }
98                }]
99            }),
100            // Static mode
101            json!({
102                "type": "open_server",
103                "port": 520,
104                "base_stack": "rip",
105                "event_handlers": [{
106                    "event_pattern": "rip_request",
107                    "handler": {
108                        "type": "static",
109                        "actions": [{
110                            "type": "send_rip_response",
111                            "routes": [{
112                                "ip_address": "192.168.1.0",
113                                "subnet_mask": "255.255.255.0",
114                                "next_hop": "0.0.0.0",
115                                "metric": 1
116                            }]
117                        }]
118                    }
119                }]
120            }),
121        )
122    }
123}
124
125// Implement Server trait (server-specific functionality)
126impl Server for RipProtocol {
127    fn spawn(
128        &self,
129        ctx: crate::protocol::SpawnContext,
130    ) -> std::pin::Pin<
131        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
132    > {
133        Box::pin(async move {
134            use crate::server::rip::RipServer;
135            RipServer::spawn_with_llm_actions(
136                ctx.legacy_listen_addr(),
137                ctx.llm_client,
138                ctx.state,
139                ctx.status_tx,
140                ctx.server_id,
141            )
142            .await
143        })
144    }
145    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
146        let action_type = action
147            .get("type")
148            .and_then(|v| v.as_str())
149            .context("Missing 'type' field in action")?;
150
151        match action_type {
152            "send_rip_response" => self.execute_send_rip_response(action),
153            "send_rip_request" => self.execute_send_rip_request(action),
154            "ignore_request" => Ok(ActionResult::NoAction),
155            _ => Err(anyhow::anyhow!("Unknown RIP action: {}", action_type)),
156        }
157    }
158}
159
160impl RipProtocol {
161    fn execute_send_rip_response(&self, action: serde_json::Value) -> Result<ActionResult> {
162        // Extract routes from action
163        let routes = action
164            .get("routes")
165            .and_then(|v| v.as_array())
166            .context("Missing or invalid 'routes' field")?;
167
168        if routes.len() > 25 {
169            return Err(anyhow::anyhow!("Too many routes (max 25 per packet)"));
170        }
171
172        // Build RIP response packet
173        let mut packet = Vec::new();
174
175        // Header (4 bytes)
176        packet.push(2); // Command: Response
177        packet.push(2); // Version: RIPv2
178        packet.push(0); // Unused
179        packet.push(0); // Unused
180
181        // Route entries (20 bytes each)
182        for route in routes {
183            // Each of these is a fixed-width wire field. Truncating with `as` would put a
184            // different number on the wire than the model asked for and say nothing, so an
185            // out-of-range value is refused instead.
186            let afi = Self::wire_u16(route, "afi", 2)?; // IPv4 = 2
187            let route_tag = Self::wire_u16(route, "route_tag", 0)?;
188            let ip_str = route
189                .get("ip_address")
190                .and_then(|v| v.as_str())
191                .context("Missing 'ip_address' in route")?;
192            let subnet_mask_str = route
193                .get("subnet_mask")
194                .and_then(|v| v.as_str())
195                .unwrap_or("255.255.255.0");
196            let next_hop_str = route
197                .get("next_hop")
198                .and_then(|v| v.as_str())
199                .unwrap_or("0.0.0.0");
200            // RFC 2453 §3.4.2: 1-15 is a reachable distance, 16 is infinity (withdraw).
201            // Nothing above 16 is meaningful, and a value that overflowed a u32 used to be
202            // silently truncated into one that was.
203            let metric = route.get("metric").and_then(|v| v.as_u64()).unwrap_or(1);
204            if !(1..=16).contains(&metric) {
205                return Err(anyhow::anyhow!(
206                    "Invalid RIP metric {} for {}: must be 1-15 (reachable) or 16 (unreachable)",
207                    metric,
208                    ip_str
209                ));
210            }
211            let metric = metric as u32;
212
213            // Parse IP addresses
214            let ip_parts = Self::parse_ipv4(ip_str)?;
215            let subnet_parts = Self::parse_ipv4(subnet_mask_str)?;
216            let next_hop_parts = Self::parse_ipv4(next_hop_str)?;
217
218            // Build route entry
219            packet.extend_from_slice(&afi.to_be_bytes());
220            packet.extend_from_slice(&route_tag.to_be_bytes());
221            packet.extend_from_slice(&ip_parts);
222            packet.extend_from_slice(&subnet_parts);
223            packet.extend_from_slice(&next_hop_parts);
224            packet.extend_from_slice(&metric.to_be_bytes());
225        }
226
227        Ok(ActionResult::Output(packet))
228    }
229
230    fn execute_send_rip_request(&self, action: serde_json::Value) -> Result<ActionResult> {
231        // Extract optional specific routes to request
232        let routes = action.get("routes").and_then(|v| v.as_array());
233
234        let mut packet = Vec::new();
235
236        // Header (4 bytes)
237        packet.push(1); // Command: Request
238        packet.push(2); // Version: RIPv2
239        packet.push(0); // Unused
240        packet.push(0); // Unused
241
242        if let Some(routes_array) = routes {
243            // Same 25-entry ceiling as a Response: RFC 2453 §3.6 sizes a RIP datagram at
244            // 4 + 25*20 = 504 bytes. This was checked on the Response path only, so a Request
245            // could be built oversize.
246            if routes_array.len() > 25 {
247                return Err(anyhow::anyhow!("Too many routes (max 25 per packet)"));
248            }
249            // Request specific routes
250            for route in routes_array {
251                let afi = Self::wire_u16(route, "afi", 2)?;
252                let ip_str = route
253                    .get("ip_address")
254                    .and_then(|v| v.as_str())
255                    .context("Missing 'ip_address' in route")?;
256
257                let ip_parts = Self::parse_ipv4(ip_str)?;
258
259                // Build route entry (metric 16 = infinity for requests)
260                packet.extend_from_slice(&afi.to_be_bytes());
261                packet.extend_from_slice(&[0, 0]); // Route tag
262                packet.extend_from_slice(&ip_parts);
263                packet.extend_from_slice(&[0, 0, 0, 0]); // Subnet mask
264                packet.extend_from_slice(&[0, 0, 0, 0]); // Next hop
265                packet.extend_from_slice(&16u32.to_be_bytes()); // Metric = 16
266            }
267        } else {
268            // Request entire routing table (AFI=0, metric=16)
269            packet.extend_from_slice(&[0, 0]); // AFI = 0 (special)
270            packet.extend_from_slice(&[0, 0]); // Route tag
271            packet.extend_from_slice(&[0, 0, 0, 0]); // IP
272            packet.extend_from_slice(&[0, 0, 0, 0]); // Subnet mask
273            packet.extend_from_slice(&[0, 0, 0, 0]); // Next hop
274            packet.extend_from_slice(&16u32.to_be_bytes()); // Metric = 16
275        }
276
277        Ok(ActionResult::Output(packet))
278    }
279
280    /// Read a fixed-width 16-bit wire field, refusing a value that would not survive the cast.
281    fn wire_u16(route: &serde_json::Value, field: &str, default: u16) -> Result<u16> {
282        match route.get(field) {
283            None | Some(serde_json::Value::Null) => Ok(default),
284            Some(v) => {
285                let n = v
286                    .as_u64()
287                    .with_context(|| format!("'{field}' must be a non-negative number"))?;
288                u16::try_from(n)
289                    .with_context(|| format!("'{field}' value {n} does not fit in 16 bits"))
290            }
291        }
292    }
293
294    fn parse_ipv4(ip_str: &str) -> Result<[u8; 4]> {
295        let parts: Vec<&str> = ip_str.split('.').collect();
296        if parts.len() != 4 {
297            return Err(anyhow::anyhow!("Invalid IPv4 address: {}", ip_str));
298        }
299
300        Ok([
301            parts[0].parse().context("Invalid IP octet")?,
302            parts[1].parse().context("Invalid IP octet")?,
303            parts[2].parse().context("Invalid IP octet")?,
304            parts[3].parse().context("Invalid IP octet")?,
305        ])
306    }
307}
308
309// Event types
310pub static RIP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
311    EventType::new(
312        "rip_request",
313        "A RIP message arrived. Answer a 'request' with send_rip_response carrying the routes \
314         you want to advertise; a peer's unsolicited 'response' is usually best answered with \
315         ignore_request, or with send_rip_request to ask for its table.",
316        json!({
317            "type": "send_rip_response",
318            "routes": [
319                {"ip_address": "192.168.1.0", "subnet_mask": "255.255.255.0", "next_hop": "0.0.0.0", "metric": 1}
320            ]
321        }),
322    )
323    .with_parameters(vec![
324        Parameter {
325            name: "command".to_string(),
326            type_hint: "number".to_string(),
327            description: "RIP command type (1=request, 2=response)".to_string(),
328            required: true,
329        },
330        Parameter {
331            name: "version".to_string(),
332            type_hint: "number".to_string(),
333            description: "RIP version (typically 2 for RIPv2)".to_string(),
334            required: true,
335        },
336        Parameter {
337            name: "message_type".to_string(),
338            type_hint: "string".to_string(),
339            description: "Message type: 'request' or 'response'".to_string(),
340            required: true,
341        },
342        Parameter {
343            name: "routes".to_string(),
344            type_hint: "array".to_string(),
345            description: "Array of route entries (ip_address, subnet_mask, next_hop, metric)"
346                .to_string(),
347            required: true,
348        },
349        Parameter {
350            name: "peer_address".to_string(),
351            type_hint: "string".to_string(),
352            description: "Address of the RIP peer".to_string(),
353            required: true,
354        },
355        Parameter {
356            name: "bytes_received".to_string(),
357            type_hint: "number".to_string(),
358            description: "Size of the received packet".to_string(),
359            required: true,
360        },
361    ])
362    // All three sync actions answer this event, which is the protocol's only one. `call_llm`
363    // builds the model's tool list from the event type rather than from get_sync_actions(), so
364    // without this list the model had no way to reply to a RIP message at all.
365    .with_actions(vec![
366        send_rip_response_action(),
367        send_rip_request_action(),
368        ignore_request_action(),
369    ])
370    .with_log_template(
371        LogTemplate::new()
372            .with_info("RIP {message_type} from {peer_address} ({bytes_received}B)")
373            .with_debug("RIPv{version} {message_type} from {peer_address}, {routes_len} routes")
374            .with_trace("RIP message: {json_pretty(.)}"),
375    )
376});
377
378fn get_rip_event_types() -> Vec<EventType> {
379    vec![RIP_REQUEST_EVENT.clone()]
380}
381
382// Action definitions
383fn send_rip_response_action() -> ActionDefinition {
384    ActionDefinition {
385        name: "send_rip_response".to_string(),
386        description: "Send RIP response with routing table entries".to_string(),
387        parameters: vec![
388            Parameter {
389                name: "routes".to_string(),
390                type_hint: "array".to_string(),
391                description: "Array of route entries to advertise, at most 25. Each route must have: ip_address (string), subnet_mask (string, default 255.255.255.0), next_hop (string, default 0.0.0.0), metric (number 1-15 = hop count, or 16 = unreachable/withdraw the route; default 1), afi (number, default 2 for IPv4), route_tag (number 0-65535, default 0)".to_string(),
392                required: true,
393            },
394        ],
395        example: json!({
396            "type": "send_rip_response",
397            "routes": [
398                {
399                    "ip_address": "192.168.1.0",
400                    "subnet_mask": "255.255.255.0",
401                    "next_hop": "0.0.0.0",
402                    "metric": 1
403                },
404                {
405                    "ip_address": "10.0.0.0",
406                    "subnet_mask": "255.0.0.0",
407                    "next_hop": "192.168.1.1",
408                    "metric": 5
409                }
410            ]
411        }),
412        log_template: Some(
413            LogTemplate::new()
414                .with_info("-> RIP response ({routes_len} routes)")
415                .with_debug("RIP send_rip_response: routes={routes_len}"),
416        ),
417    }
418}
419
420fn send_rip_request_action() -> ActionDefinition {
421    ActionDefinition {
422        name: "send_rip_request".to_string(),
423        description: "Send RIP request to query routing table. If routes array is omitted, requests entire routing table.".to_string(),
424        parameters: vec![
425            Parameter {
426                name: "routes".to_string(),
427                type_hint: "array".to_string(),
428                description: "Optional array of specific routes to request, at most 25, each with an ip_address (string). Omit to request the entire table.".to_string(),
429                required: false,
430            },
431        ],
432        example: json!({
433            "type": "send_rip_request"
434        }),
435        log_template: Some(
436            LogTemplate::new()
437                .with_info("-> RIP request")
438                .with_debug("RIP send_rip_request"),
439        ),
440    }
441}
442
443fn ignore_request_action() -> ActionDefinition {
444    ActionDefinition {
445        name: "ignore_request".to_string(),
446        description: "Ignore the RIP message and send no response".to_string(),
447        parameters: vec![],
448        example: json!({
449            "type": "ignore_request"
450        }),
451        log_template: Some(
452            LogTemplate::new()
453                .with_info("-> RIP ignore")
454                .with_debug("RIP ignore_request"),
455        ),
456    }
457}