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