Skip to main content

rns_ctl/
api.rs

1use serde_json::{json, Value};
2
3use rns_crypto::identity::Identity;
4use rns_net::{
5    DestHash, Destination, IdentityHash, ProofStrategy, QueryRequest, QueryResponse, RnsNode,
6};
7
8use crate::auth::check_auth;
9use crate::config::CtlConfig;
10use crate::encode::{from_base64, hex_to_array, to_base64, to_hex};
11use crate::http::{parse_query, HttpRequest, HttpResponse};
12use crate::state::{DestinationEntry, SharedState};
13
14/// Handle for the node, wrapped so shutdown() can consume it.
15pub type NodeHandle = std::sync::Arc<std::sync::Mutex<Option<RnsNode>>>;
16
17/// Execute a closure with a reference to the node, returning an error response if the node is gone.
18fn with_node<F>(node: &NodeHandle, f: F) -> HttpResponse
19where
20    F: FnOnce(&RnsNode) -> HttpResponse,
21{
22    let guard = node.lock().unwrap();
23    match guard.as_ref() {
24        Some(n) => f(n),
25        None => HttpResponse::internal_error("Node is shutting down"),
26    }
27}
28
29/// Route dispatch: match method + path and call the appropriate handler.
30pub fn handle_request(
31    req: &HttpRequest,
32    node: &NodeHandle,
33    state: &SharedState,
34    config: &CtlConfig,
35) -> HttpResponse {
36    // Health check — no auth required
37    if req.method == "GET" && req.path == "/health" {
38        return HttpResponse::ok(json!({"status": "healthy"}));
39    }
40
41    // All other endpoints require auth
42    if let Err(resp) = check_auth(req, config) {
43        return resp;
44    }
45
46    match (req.method.as_str(), req.path.as_str()) {
47        // Read endpoints
48        ("GET", "/api/info") => handle_info(node, state),
49        ("GET", "/api/interfaces") => handle_interfaces(node),
50        ("GET", "/api/destinations") => handle_destinations(node, state),
51        ("GET", "/api/paths") => handle_paths(req, node),
52        ("GET", "/api/links") => handle_links(node),
53        ("GET", "/api/resources") => handle_resources(node),
54        ("GET", "/api/announces") => handle_event_list(req, state, "announces"),
55        ("GET", "/api/packets") => handle_event_list(req, state, "packets"),
56        ("GET", "/api/proofs") => handle_event_list(req, state, "proofs"),
57        ("GET", "/api/link_events") => handle_event_list(req, state, "link_events"),
58        ("GET", "/api/resource_events") => handle_event_list(req, state, "resource_events"),
59
60        // Identity recall: /api/identity/<dest_hash>
61        ("GET", path) if path.starts_with("/api/identity/") => {
62            let hash_str = &path["/api/identity/".len()..];
63            handle_recall_identity(hash_str, node)
64        }
65
66        // Action endpoints
67        ("POST", "/api/destination") => handle_post_destination(req, node, state),
68        ("POST", "/api/announce") => handle_post_announce(req, node, state),
69        ("POST", "/api/send") => handle_post_send(req, node, state),
70        ("POST", "/api/link") => handle_post_link(req, node),
71        ("POST", "/api/link/send") => handle_post_link_send(req, node),
72        ("POST", "/api/link/close") => handle_post_link_close(req, node),
73        ("POST", "/api/channel") => handle_post_channel(req, node),
74        ("POST", "/api/resource") => handle_post_resource(req, node),
75        ("POST", "/api/path/request") => handle_post_path_request(req, node),
76        ("POST", "/api/direct_connect") => handle_post_direct_connect(req, node),
77
78        // Hook management
79        ("GET", "/api/hooks") => handle_list_hooks(node),
80        ("POST", "/api/hook/load") => handle_load_hook(req, node),
81        ("POST", "/api/hook/unload") => handle_unload_hook(req, node),
82        ("POST", "/api/hook/reload") => handle_reload_hook(req, node),
83        ("POST", "/api/hook/enable") => handle_set_hook_enabled(req, node, true),
84        ("POST", "/api/hook/disable") => handle_set_hook_enabled(req, node, false),
85        ("POST", "/api/hook/priority") => handle_set_hook_priority(req, node),
86
87        _ => HttpResponse::not_found(),
88    }
89}
90
91// --- Read handlers ---
92
93fn handle_info(node: &NodeHandle, state: &SharedState) -> HttpResponse {
94    with_node(node, |n| {
95        let transport_id = match n.query(QueryRequest::TransportIdentity) {
96            Ok(QueryResponse::TransportIdentity(id)) => id,
97            _ => None,
98        };
99        let s = state.read().unwrap();
100        HttpResponse::ok(json!({
101            "transport_id": transport_id.map(|h| to_hex(&h)),
102            "identity_hash": s.identity_hash.map(|h| to_hex(&h)),
103            "uptime_seconds": s.uptime_seconds(),
104        }))
105    })
106}
107
108fn handle_interfaces(node: &NodeHandle) -> HttpResponse {
109    with_node(node, |n| match n.query(QueryRequest::InterfaceStats) {
110        Ok(QueryResponse::InterfaceStats(stats)) => {
111            let ifaces: Vec<Value> = stats
112                .interfaces
113                .iter()
114                .map(|i| {
115                    json!({
116                        "name": i.name,
117                        "status": if i.status { "up" } else { "down" },
118                        "mode": i.mode,
119                        "interface_type": i.interface_type,
120                        "rxb": i.rxb,
121                        "txb": i.txb,
122                        "rx_packets": i.rx_packets,
123                        "tx_packets": i.tx_packets,
124                        "bitrate": i.bitrate,
125                        "started": i.started,
126                        "ia_freq": i.ia_freq,
127                        "oa_freq": i.oa_freq,
128                    })
129                })
130                .collect();
131            HttpResponse::ok(json!({
132                "interfaces": ifaces,
133                "transport_enabled": stats.transport_enabled,
134                "transport_uptime": stats.transport_uptime,
135                "total_rxb": stats.total_rxb,
136                "total_txb": stats.total_txb,
137            }))
138        }
139        _ => HttpResponse::internal_error("Query failed"),
140    })
141}
142
143fn handle_destinations(node: &NodeHandle, state: &SharedState) -> HttpResponse {
144    with_node(node, |n| match n.query(QueryRequest::LocalDestinations) {
145        Ok(QueryResponse::LocalDestinations(dests)) => {
146            let s = state.read().unwrap();
147            let list: Vec<Value> = dests
148                .iter()
149                .map(|d| {
150                    let name = s
151                        .destinations
152                        .get(&d.hash)
153                        .map(|e| e.full_name.as_str())
154                        .unwrap_or("");
155                    json!({
156                        "hash": to_hex(&d.hash),
157                        "type": d.dest_type,
158                        "name": name,
159                    })
160                })
161                .collect();
162            HttpResponse::ok(json!({"destinations": list}))
163        }
164        _ => HttpResponse::internal_error("Query failed"),
165    })
166}
167
168fn handle_paths(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
169    let params = parse_query(&req.query);
170    let filter_hash: Option<[u8; 16]> = params.get("dest_hash").and_then(|s| hex_to_array(s));
171
172    with_node(node, |n| {
173        match n.query(QueryRequest::PathTable { max_hops: None }) {
174            Ok(QueryResponse::PathTable(paths)) => {
175                let list: Vec<Value> = paths
176                    .iter()
177                    .filter(|p| filter_hash.map_or(true, |h| p.hash == h))
178                    .map(|p| {
179                        json!({
180                            "hash": to_hex(&p.hash),
181                            "via": to_hex(&p.via),
182                            "hops": p.hops,
183                            "expires": p.expires,
184                            "interface": p.interface_name,
185                            "timestamp": p.timestamp,
186                        })
187                    })
188                    .collect();
189                HttpResponse::ok(json!({"paths": list}))
190            }
191            _ => HttpResponse::internal_error("Query failed"),
192        }
193    })
194}
195
196fn handle_links(node: &NodeHandle) -> HttpResponse {
197    with_node(node, |n| match n.query(QueryRequest::Links) {
198        Ok(QueryResponse::Links(links)) => {
199            let list: Vec<Value> = links
200                .iter()
201                .map(|l| {
202                    json!({
203                        "link_id": to_hex(&l.link_id),
204                        "state": l.state,
205                        "is_initiator": l.is_initiator,
206                        "dest_hash": to_hex(&l.dest_hash),
207                        "remote_identity": l.remote_identity.map(|h| to_hex(&h)),
208                        "rtt": l.rtt,
209                    })
210                })
211                .collect();
212            HttpResponse::ok(json!({"links": list}))
213        }
214        _ => HttpResponse::internal_error("Query failed"),
215    })
216}
217
218fn handle_resources(node: &NodeHandle) -> HttpResponse {
219    with_node(node, |n| match n.query(QueryRequest::Resources) {
220        Ok(QueryResponse::Resources(resources)) => {
221            let list: Vec<Value> = resources
222                .iter()
223                .map(|r| {
224                    json!({
225                        "link_id": to_hex(&r.link_id),
226                        "direction": r.direction,
227                        "total_parts": r.total_parts,
228                        "transferred_parts": r.transferred_parts,
229                        "complete": r.complete,
230                    })
231                })
232                .collect();
233            HttpResponse::ok(json!({"resources": list}))
234        }
235        _ => HttpResponse::internal_error("Query failed"),
236    })
237}
238
239fn handle_event_list(req: &HttpRequest, state: &SharedState, kind: &str) -> HttpResponse {
240    let params = parse_query(&req.query);
241    let clear = params.get("clear").map_or(false, |v| v == "true");
242
243    let mut s = state.write().unwrap();
244    let items: Vec<Value> = match kind {
245        "announces" => {
246            let v: Vec<Value> = s
247                .announces
248                .iter()
249                .map(|r| serde_json::to_value(r).unwrap_or_default())
250                .collect();
251            if clear {
252                s.announces.clear();
253            }
254            v
255        }
256        "packets" => {
257            let v: Vec<Value> = s
258                .packets
259                .iter()
260                .map(|r| serde_json::to_value(r).unwrap_or_default())
261                .collect();
262            if clear {
263                s.packets.clear();
264            }
265            v
266        }
267        "proofs" => {
268            let v: Vec<Value> = s
269                .proofs
270                .iter()
271                .map(|r| serde_json::to_value(r).unwrap_or_default())
272                .collect();
273            if clear {
274                s.proofs.clear();
275            }
276            v
277        }
278        "link_events" => {
279            let v: Vec<Value> = s
280                .link_events
281                .iter()
282                .map(|r| serde_json::to_value(r).unwrap_or_default())
283                .collect();
284            if clear {
285                s.link_events.clear();
286            }
287            v
288        }
289        "resource_events" => {
290            let v: Vec<Value> = s
291                .resource_events
292                .iter()
293                .map(|r| serde_json::to_value(r).unwrap_or_default())
294                .collect();
295            if clear {
296                s.resource_events.clear();
297            }
298            v
299        }
300        _ => Vec::new(),
301    };
302
303    let mut obj = serde_json::Map::new();
304    obj.insert(kind.to_string(), Value::Array(items));
305    HttpResponse::ok(Value::Object(obj))
306}
307
308fn handle_recall_identity(hash_str: &str, node: &NodeHandle) -> HttpResponse {
309    let dest_hash: [u8; 16] = match hex_to_array(hash_str) {
310        Some(h) => h,
311        None => return HttpResponse::bad_request("Invalid dest_hash hex (expected 32 hex chars)"),
312    };
313
314    with_node(node, |n| match n.recall_identity(&DestHash(dest_hash)) {
315        Ok(Some(ai)) => HttpResponse::ok(json!({
316            "dest_hash": to_hex(&ai.dest_hash.0),
317            "identity_hash": to_hex(&ai.identity_hash.0),
318            "public_key": to_hex(&ai.public_key),
319            "app_data": ai.app_data.as_ref().map(|d| to_base64(d)),
320            "hops": ai.hops,
321            "received_at": ai.received_at,
322        })),
323        Ok(None) => HttpResponse::not_found(),
324        Err(_) => HttpResponse::internal_error("Query failed"),
325    })
326}
327
328// --- Action handlers ---
329
330fn parse_json_body(req: &HttpRequest) -> Result<Value, HttpResponse> {
331    serde_json::from_slice(&req.body)
332        .map_err(|e| HttpResponse::bad_request(&format!("Invalid JSON: {}", e)))
333}
334
335fn handle_post_destination(
336    req: &HttpRequest,
337    node: &NodeHandle,
338    state: &SharedState,
339) -> HttpResponse {
340    let body = match parse_json_body(req) {
341        Ok(v) => v,
342        Err(r) => return r,
343    };
344
345    let dest_type_str = body["type"].as_str().unwrap_or("");
346    let app_name = match body["app_name"].as_str() {
347        Some(s) => s,
348        None => return HttpResponse::bad_request("Missing app_name"),
349    };
350    let aspects: Vec<&str> = body["aspects"]
351        .as_array()
352        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
353        .unwrap_or_default();
354
355    let (identity_hash, identity_prv_key, identity_pub_key) = {
356        let s = state.read().unwrap();
357        let ih = s.identity_hash;
358        let prv = s.identity.as_ref().and_then(|i| i.get_private_key());
359        let pubk = s.identity.as_ref().and_then(|i| i.get_public_key());
360        (ih, prv, pubk)
361    };
362
363    let (dest, signing_key) = match dest_type_str {
364        "single" => {
365            let direction = body["direction"].as_str().unwrap_or("in");
366            match direction {
367                "in" => {
368                    let ih = match identity_hash {
369                        Some(h) => IdentityHash(h),
370                        None => return HttpResponse::internal_error("No identity loaded"),
371                    };
372                    let dest = Destination::single_in(app_name, &aspects, ih)
373                        .set_proof_strategy(parse_proof_strategy(&body));
374                    (dest, identity_prv_key)
375                }
376                "out" => {
377                    let dh_str = match body["dest_hash"].as_str() {
378                        Some(s) => s,
379                        None => {
380                            return HttpResponse::bad_request(
381                                "OUT single requires dest_hash of remote",
382                            )
383                        }
384                    };
385                    let dh: [u8; 16] = match hex_to_array(dh_str) {
386                        Some(h) => h,
387                        None => return HttpResponse::bad_request("Invalid dest_hash"),
388                    };
389                    return with_node(node, |n| {
390                        match n.recall_identity(&DestHash(dh)) {
391                            Ok(Some(recalled)) => {
392                                let dest = Destination::single_out(app_name, &aspects, &recalled);
393                                // Register in state
394                                let full_name = format_dest_name(app_name, &aspects);
395                                let mut s = state.write().unwrap();
396                                s.destinations.insert(
397                                    dest.hash.0,
398                                    DestinationEntry {
399                                        destination: dest.clone(),
400                                        full_name: full_name.clone(),
401                                    },
402                                );
403                                HttpResponse::created(json!({
404                                    "dest_hash": to_hex(&dest.hash.0),
405                                    "name": full_name,
406                                    "type": "single",
407                                    "direction": "out",
408                                }))
409                            }
410                            Ok(None) => {
411                                HttpResponse::bad_request("No recalled identity for dest_hash")
412                            }
413                            Err(_) => HttpResponse::internal_error("Query failed"),
414                        }
415                    });
416                }
417                _ => return HttpResponse::bad_request("direction must be 'in' or 'out'"),
418            }
419        }
420        "plain" => {
421            let dest = Destination::plain(app_name, &aspects)
422                .set_proof_strategy(parse_proof_strategy(&body));
423            (dest, None)
424        }
425        "group" => {
426            let mut dest = Destination::group(app_name, &aspects)
427                .set_proof_strategy(parse_proof_strategy(&body));
428            if let Some(key_b64) = body["group_key"].as_str() {
429                match from_base64(key_b64) {
430                    Some(key) => {
431                        if let Err(e) = dest.load_private_key(key) {
432                            return HttpResponse::bad_request(&format!("Invalid group key: {}", e));
433                        }
434                    }
435                    None => return HttpResponse::bad_request("Invalid base64 group_key"),
436                }
437            } else {
438                dest.create_keys();
439            }
440            (dest, None)
441        }
442        _ => return HttpResponse::bad_request("type must be 'single', 'plain', or 'group'"),
443    };
444
445    with_node(node, |n| {
446        match n.register_destination_with_proof(&dest, signing_key) {
447            Ok(()) => {
448                // For inbound single dests, also register with link manager
449                // so incoming LINKREQUEST packets are accepted.
450                if dest_type_str == "single" && body["direction"].as_str().unwrap_or("in") == "in" {
451                    if let (Some(prv), Some(pubk)) = (identity_prv_key, identity_pub_key) {
452                        let mut sig_prv = [0u8; 32];
453                        sig_prv.copy_from_slice(&prv[32..64]);
454                        let mut sig_pub = [0u8; 32];
455                        sig_pub.copy_from_slice(&pubk[32..64]);
456                        let _ = n.register_link_destination(dest.hash.0, sig_prv, sig_pub, 0);
457                    }
458                }
459
460                let full_name = format_dest_name(app_name, &aspects);
461                let hash_hex = to_hex(&dest.hash.0);
462                let group_key_b64 = dest.get_private_key().map(to_base64);
463                let mut s = state.write().unwrap();
464                s.destinations.insert(
465                    dest.hash.0,
466                    DestinationEntry {
467                        destination: dest,
468                        full_name: full_name.clone(),
469                    },
470                );
471                let mut resp = json!({
472                    "dest_hash": hash_hex,
473                    "name": full_name,
474                    "type": dest_type_str,
475                });
476                if let Some(gk) = group_key_b64 {
477                    resp["group_key"] = Value::String(gk);
478                }
479                HttpResponse::created(resp)
480            }
481            Err(_) => HttpResponse::internal_error("Failed to register destination"),
482        }
483    })
484}
485
486fn handle_post_announce(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
487    let body = match parse_json_body(req) {
488        Ok(v) => v,
489        Err(r) => return r,
490    };
491
492    let dh_str = match body["dest_hash"].as_str() {
493        Some(s) => s,
494        None => return HttpResponse::bad_request("Missing dest_hash"),
495    };
496    let dh: [u8; 16] = match hex_to_array(dh_str) {
497        Some(h) => h,
498        None => return HttpResponse::bad_request("Invalid dest_hash"),
499    };
500
501    let app_data: Option<Vec<u8>> = body["app_data"].as_str().and_then(from_base64);
502
503    let (dest, identity) = {
504        let s = state.read().unwrap();
505        let dest = match s.destinations.get(&dh) {
506            Some(entry) => entry.destination.clone(),
507            None => return HttpResponse::bad_request("Destination not registered via API"),
508        };
509        let identity = match s.identity.as_ref().and_then(|i| i.get_private_key()) {
510            Some(prv) => Identity::from_private_key(&prv),
511            None => return HttpResponse::internal_error("No identity loaded"),
512        };
513        (dest, identity)
514    };
515
516    with_node(node, |n| {
517        match n.announce(&dest, &identity, app_data.as_deref()) {
518            Ok(()) => HttpResponse::ok(json!({"status": "announced", "dest_hash": dh_str})),
519            Err(_) => HttpResponse::internal_error("Announce failed"),
520        }
521    })
522}
523
524fn handle_post_send(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
525    let body = match parse_json_body(req) {
526        Ok(v) => v,
527        Err(r) => return r,
528    };
529
530    let dh_str = match body["dest_hash"].as_str() {
531        Some(s) => s,
532        None => return HttpResponse::bad_request("Missing dest_hash"),
533    };
534    let dh: [u8; 16] = match hex_to_array(dh_str) {
535        Some(h) => h,
536        None => return HttpResponse::bad_request("Invalid dest_hash"),
537    };
538    let data = match body["data"].as_str().and_then(from_base64) {
539        Some(d) => d,
540        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
541    };
542
543    let s = state.read().unwrap();
544    let dest = match s.destinations.get(&dh) {
545        Some(entry) => entry.destination.clone(),
546        None => return HttpResponse::bad_request("Destination not registered via API"),
547    };
548    drop(s);
549
550    with_node(node, |n| match n.send_packet(&dest, &data) {
551        Ok(ph) => HttpResponse::ok(json!({
552            "status": "sent",
553            "packet_hash": to_hex(&ph.0),
554        })),
555        Err(_) => HttpResponse::internal_error("Send failed"),
556    })
557}
558
559fn handle_post_link(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
560    let body = match parse_json_body(req) {
561        Ok(v) => v,
562        Err(r) => return r,
563    };
564
565    let dh_str = match body["dest_hash"].as_str() {
566        Some(s) => s,
567        None => return HttpResponse::bad_request("Missing dest_hash"),
568    };
569    let dh: [u8; 16] = match hex_to_array(dh_str) {
570        Some(h) => h,
571        None => return HttpResponse::bad_request("Invalid dest_hash"),
572    };
573
574    with_node(node, |n| {
575        // Recall identity to get signing public key
576        let recalled = match n.recall_identity(&DestHash(dh)) {
577            Ok(Some(ai)) => ai,
578            Ok(None) => return HttpResponse::bad_request("No recalled identity for dest_hash"),
579            Err(_) => return HttpResponse::internal_error("Query failed"),
580        };
581        // Extract Ed25519 public key (second 32 bytes of public_key)
582        let mut sig_pub = [0u8; 32];
583        sig_pub.copy_from_slice(&recalled.public_key[32..64]);
584
585        match n.create_link(dh, sig_pub) {
586            Ok(link_id) => HttpResponse::created(json!({
587                "link_id": to_hex(&link_id),
588            })),
589            Err(_) => HttpResponse::internal_error("Create link failed"),
590        }
591    })
592}
593
594fn handle_post_link_send(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
595    let body = match parse_json_body(req) {
596        Ok(v) => v,
597        Err(r) => return r,
598    };
599
600    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
601        Some(h) => h,
602        None => return HttpResponse::bad_request("Missing or invalid link_id"),
603    };
604    let data = match body["data"].as_str().and_then(from_base64) {
605        Some(d) => d,
606        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
607    };
608    let context = body["context"].as_u64().unwrap_or(0) as u8;
609
610    with_node(node, |n| match n.send_on_link(link_id, data, context) {
611        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
612        Err(_) => HttpResponse::internal_error("Send on link failed"),
613    })
614}
615
616fn handle_post_link_close(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
617    let body = match parse_json_body(req) {
618        Ok(v) => v,
619        Err(r) => return r,
620    };
621
622    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
623        Some(h) => h,
624        None => return HttpResponse::bad_request("Missing or invalid link_id"),
625    };
626
627    with_node(node, |n| match n.teardown_link(link_id) {
628        Ok(()) => HttpResponse::ok(json!({"status": "closed"})),
629        Err(_) => HttpResponse::internal_error("Teardown link failed"),
630    })
631}
632
633fn handle_post_channel(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
634    let body = match parse_json_body(req) {
635        Ok(v) => v,
636        Err(r) => return r,
637    };
638
639    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
640        Some(h) => h,
641        None => return HttpResponse::bad_request("Missing or invalid link_id"),
642    };
643    let msgtype = body["msgtype"].as_u64().unwrap_or(0) as u16;
644    let payload = match body["payload"].as_str().and_then(from_base64) {
645        Some(d) => d,
646        None => return HttpResponse::bad_request("Missing or invalid base64 payload"),
647    };
648
649    with_node(node, |n| {
650        match n.send_channel_message(link_id, msgtype, payload) {
651            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
652            Err(_) => HttpResponse::internal_error("Channel message failed"),
653        }
654    })
655}
656
657fn handle_post_resource(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
658    let body = match parse_json_body(req) {
659        Ok(v) => v,
660        Err(r) => return r,
661    };
662
663    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
664        Some(h) => h,
665        None => return HttpResponse::bad_request("Missing or invalid link_id"),
666    };
667    let data = match body["data"].as_str().and_then(from_base64) {
668        Some(d) => d,
669        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
670    };
671    let metadata = body["metadata"].as_str().and_then(from_base64);
672
673    with_node(node, |n| match n.send_resource(link_id, data, metadata) {
674        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
675        Err(_) => HttpResponse::internal_error("Resource send failed"),
676    })
677}
678
679fn handle_post_path_request(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
680    let body = match parse_json_body(req) {
681        Ok(v) => v,
682        Err(r) => return r,
683    };
684
685    let dh_str = match body["dest_hash"].as_str() {
686        Some(s) => s,
687        None => return HttpResponse::bad_request("Missing dest_hash"),
688    };
689    let dh: [u8; 16] = match hex_to_array(dh_str) {
690        Some(h) => h,
691        None => return HttpResponse::bad_request("Invalid dest_hash"),
692    };
693
694    with_node(node, |n| match n.request_path(&DestHash(dh)) {
695        Ok(()) => HttpResponse::ok(json!({"status": "requested"})),
696        Err(_) => HttpResponse::internal_error("Path request failed"),
697    })
698}
699
700fn handle_post_direct_connect(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
701    let body = match parse_json_body(req) {
702        Ok(v) => v,
703        Err(r) => return r,
704    };
705
706    let lid_str = match body["link_id"].as_str() {
707        Some(s) => s,
708        None => return HttpResponse::bad_request("Missing link_id"),
709    };
710    let link_id: [u8; 16] = match hex_to_array(lid_str) {
711        Some(h) => h,
712        None => return HttpResponse::bad_request("Invalid link_id"),
713    };
714
715    with_node(node, |n| match n.propose_direct_connect(link_id) {
716        Ok(()) => HttpResponse::ok(json!({"status": "proposed"})),
717        Err(_) => HttpResponse::internal_error("Direct connect proposal failed"),
718    })
719}
720
721// --- Hook handlers ---
722
723fn handle_list_hooks(node: &NodeHandle) -> HttpResponse {
724    with_node(node, |n| match n.list_hooks() {
725        Ok(hooks) => {
726            let list: Vec<Value> = hooks
727                .iter()
728                .map(|h| {
729                    json!({
730                        "name": h.name,
731                        "attach_point": h.attach_point,
732                        "priority": h.priority,
733                        "enabled": h.enabled,
734                        "consecutive_traps": h.consecutive_traps,
735                    })
736                })
737                .collect();
738            HttpResponse::ok(json!({"hooks": list}))
739        }
740        Err(_) => HttpResponse::internal_error("Query failed"),
741    })
742}
743
744/// Load a WASM hook from a filesystem path.
745///
746/// The `path` field in the JSON body refers to a file on the **server's** local
747/// filesystem. This means the CLI and the HTTP server must have access to the
748/// same filesystem for the path to resolve correctly.
749fn handle_load_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
750    let body = match parse_json_body(req) {
751        Ok(v) => v,
752        Err(r) => return r,
753    };
754
755    let name = match body["name"].as_str() {
756        Some(s) => s.to_string(),
757        None => return HttpResponse::bad_request("Missing name"),
758    };
759    let path = match body["path"].as_str() {
760        Some(s) => s,
761        None => return HttpResponse::bad_request("Missing path"),
762    };
763    let attach_point = match body["attach_point"].as_str() {
764        Some(s) => s.to_string(),
765        None => return HttpResponse::bad_request("Missing attach_point"),
766    };
767    let priority = body["priority"].as_i64().unwrap_or(0) as i32;
768
769    // Read WASM file
770    let wasm_bytes = match std::fs::read(path) {
771        Ok(b) => b,
772        Err(e) => return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e)),
773    };
774
775    with_node(node, |n| {
776        match n.load_hook(name, wasm_bytes, attach_point, priority) {
777            Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
778            Ok(Err(e)) => HttpResponse::bad_request(&e),
779            Err(_) => HttpResponse::internal_error("Driver unavailable"),
780        }
781    })
782}
783
784fn handle_unload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
785    let body = match parse_json_body(req) {
786        Ok(v) => v,
787        Err(r) => return r,
788    };
789
790    let name = match body["name"].as_str() {
791        Some(s) => s.to_string(),
792        None => return HttpResponse::bad_request("Missing name"),
793    };
794    let attach_point = match body["attach_point"].as_str() {
795        Some(s) => s.to_string(),
796        None => return HttpResponse::bad_request("Missing attach_point"),
797    };
798
799    with_node(node, |n| match n.unload_hook(name, attach_point) {
800        Ok(Ok(())) => HttpResponse::ok(json!({"status": "unloaded"})),
801        Ok(Err(e)) => HttpResponse::bad_request(&e),
802        Err(_) => HttpResponse::internal_error("Driver unavailable"),
803    })
804}
805
806fn handle_reload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
807    let body = match parse_json_body(req) {
808        Ok(v) => v,
809        Err(r) => return r,
810    };
811
812    let name = match body["name"].as_str() {
813        Some(s) => s.to_string(),
814        None => return HttpResponse::bad_request("Missing name"),
815    };
816    let path = match body["path"].as_str() {
817        Some(s) => s,
818        None => return HttpResponse::bad_request("Missing path"),
819    };
820    let attach_point = match body["attach_point"].as_str() {
821        Some(s) => s.to_string(),
822        None => return HttpResponse::bad_request("Missing attach_point"),
823    };
824
825    let wasm_bytes = match std::fs::read(path) {
826        Ok(b) => b,
827        Err(e) => return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e)),
828    };
829
830    with_node(node, |n| {
831        match n.reload_hook(name, attach_point, wasm_bytes) {
832            Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
833            Ok(Err(e)) => HttpResponse::bad_request(&e),
834            Err(_) => HttpResponse::internal_error("Driver unavailable"),
835        }
836    })
837}
838
839fn handle_set_hook_enabled(req: &HttpRequest, node: &NodeHandle, enabled: bool) -> HttpResponse {
840    let body = match parse_json_body(req) {
841        Ok(v) => v,
842        Err(r) => return r,
843    };
844
845    let name = match body["name"].as_str() {
846        Some(s) => s.to_string(),
847        None => return HttpResponse::bad_request("Missing name"),
848    };
849    let attach_point = match body["attach_point"].as_str() {
850        Some(s) => s.to_string(),
851        None => return HttpResponse::bad_request("Missing attach_point"),
852    };
853
854    with_node(node, |n| {
855        match n.set_hook_enabled(name, attach_point, enabled) {
856            Ok(Ok(())) => HttpResponse::ok(json!({
857                "status": if enabled { "enabled" } else { "disabled" }
858            })),
859            Ok(Err(e)) => HttpResponse::bad_request(&e),
860            Err(_) => HttpResponse::internal_error("Driver unavailable"),
861        }
862    })
863}
864
865fn handle_set_hook_priority(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
866    let body = match parse_json_body(req) {
867        Ok(v) => v,
868        Err(r) => return r,
869    };
870
871    let name = match body["name"].as_str() {
872        Some(s) => s.to_string(),
873        None => return HttpResponse::bad_request("Missing name"),
874    };
875    let attach_point = match body["attach_point"].as_str() {
876        Some(s) => s.to_string(),
877        None => return HttpResponse::bad_request("Missing attach_point"),
878    };
879    let priority = match body["priority"].as_i64() {
880        Some(v) => v as i32,
881        None => return HttpResponse::bad_request("Missing priority"),
882    };
883
884    with_node(node, |n| {
885        match n.set_hook_priority(name, attach_point, priority) {
886            Ok(Ok(())) => HttpResponse::ok(json!({"status": "priority_updated"})),
887            Ok(Err(e)) => HttpResponse::bad_request(&e),
888            Err(_) => HttpResponse::internal_error("Driver unavailable"),
889        }
890    })
891}
892
893// --- Helpers ---
894
895fn format_dest_name(app_name: &str, aspects: &[&str]) -> String {
896    if aspects.is_empty() {
897        app_name.to_string()
898    } else {
899        format!("{}.{}", app_name, aspects.join("."))
900    }
901}
902
903fn parse_proof_strategy(body: &Value) -> ProofStrategy {
904    match body["proof_strategy"].as_str() {
905        Some("all") => ProofStrategy::ProveAll,
906        Some("app") => ProofStrategy::ProveApp,
907        _ => ProofStrategy::ProveNone,
908    }
909}