Skip to main content

rns_ctl/
api.rs

1use serde_json::{json, Value};
2
3const DEFAULT_HOOK_TYPE: &str = {
4    #[cfg(feature = "rns-hooks-native")]
5    {
6        "native"
7    }
8    #[cfg(all(not(feature = "rns-hooks-native"), feature = "rns-hooks-wasm"))]
9    {
10        "wasm"
11    }
12    #[cfg(all(not(feature = "rns-hooks-native"), not(feature = "rns-hooks-wasm")))]
13    {
14        "wasm"
15    }
16};
17
18use rns_crypto::identity::Identity;
19use rns_net::{
20    event::LifecycleState, DestHash, Destination, IdentityHash, ProofStrategy, QueryRequest,
21    QueryResponse, RnsNode,
22};
23
24use crate::auth::check_auth;
25use crate::encode::{from_base64, hex_to_array, to_base64, to_hex};
26use crate::http::{parse_query, HttpRequest, HttpResponse};
27use crate::state::{
28    lock_node_handle, read_state, ControlPlaneConfigHandle, DestinationEntry, SharedState,
29};
30use crate::stats_api;
31
32/// Handle for the node, wrapped so shutdown() can consume it.
33pub type NodeHandle = std::sync::Arc<std::sync::Mutex<Option<RnsNode>>>;
34
35/// Execute a closure with a reference to the node, returning an error response if the node is gone.
36fn with_node<F>(node: &NodeHandle, f: F) -> HttpResponse
37where
38    F: FnOnce(&RnsNode) -> HttpResponse,
39{
40    let guard = lock_node_handle(node);
41    match guard.as_ref() {
42        Some(n) => f(n),
43        None => HttpResponse::internal_error("Node is shutting down"),
44    }
45}
46
47fn with_active_node<F>(node: &NodeHandle, f: F) -> HttpResponse
48where
49    F: FnOnce(&RnsNode) -> HttpResponse,
50{
51    with_node(node, |n| match n.query(QueryRequest::DrainStatus) {
52        Ok(QueryResponse::DrainStatus(status))
53            if !matches!(status.state, LifecycleState::Active) =>
54        {
55            HttpResponse::conflict(
56                status
57                    .detail
58                    .as_deref()
59                    .unwrap_or("Node is draining and not accepting new work"),
60            )
61        }
62        _ => f(n),
63    })
64}
65
66/// Route dispatch: match method + path and call the appropriate handler.
67pub fn handle_request(
68    req: &HttpRequest,
69    node: &NodeHandle,
70    state: &SharedState,
71    config: &ControlPlaneConfigHandle,
72) -> HttpResponse {
73    if req.method == "GET" && (req.path == "/" || req.path == "/ui") {
74        return HttpResponse::html(index_html(config));
75    }
76    if req.method == "GET" && req.path == "/assets/app.css" {
77        return HttpResponse::bytes(
78            200,
79            "OK",
80            "text/css; charset=utf-8",
81            include_str!("../assets/app.css").as_bytes().to_vec(),
82        );
83    }
84    if req.method == "GET" && req.path == "/assets/app.js" {
85        return HttpResponse::bytes(
86            200,
87            "OK",
88            "application/javascript; charset=utf-8",
89            include_str!("../assets/app.js").as_bytes().to_vec(),
90        );
91    }
92
93    // Health check — no auth required
94    if req.method == "GET" && req.path == "/health" {
95        return HttpResponse::ok(json!({"status": "healthy"}));
96    }
97
98    // All other endpoints require auth
99    if let Err(resp) = check_auth(req, config) {
100        return resp;
101    }
102
103    match (req.method.as_str(), req.path.as_str()) {
104        // Read endpoints
105        ("GET", "/api/node") => handle_node(node, state),
106        ("GET", "/api/config") => handle_config(state),
107        ("GET", "/api/config/schema") => handle_config_schema(state),
108        ("GET", "/api/config/status") => handle_config_status(state),
109        ("GET", "/api/processes") => handle_processes(state),
110        ("GET", "/api/process_events") => handle_process_events(state),
111        ("GET", "/api/stats/summary") => stats_api::handle_summary(req, state),
112        ("GET", "/api/stats/announces") => stats_api::handle_announces(req, state),
113        ("GET", "/api/stats/interfaces") => stats_api::handle_interfaces(req, state),
114        ("GET", "/api/stats/destinations") => stats_api::handle_destinations(req, state),
115        ("GET", "/api/stats/packets") => stats_api::handle_packets(req, state),
116        ("GET", "/api/stats/packets/series") => stats_api::handle_packet_series(req, state),
117        ("GET", "/api/stats/links") => stats_api::handle_links(req, state),
118        ("GET", "/api/stats/system") => stats_api::handle_system(req, state),
119        ("GET", path) if path.starts_with("/api/processes/") && path.ends_with("/logs") => {
120            handle_process_logs(path, req, state)
121        }
122        ("GET", "/api/info") => handle_info(node, state),
123        ("GET", "/api/interfaces") => handle_interfaces(node),
124        ("GET", "/api/destinations") => handle_destinations(node, state),
125        ("GET", "/api/paths") => handle_paths(req, node),
126        ("GET", "/api/links") => handle_links(node),
127        ("GET", "/api/resources") => handle_resources(node),
128        ("GET", "/api/announces") => handle_event_list(req, state, "announces"),
129        ("GET", "/api/packets") => handle_event_list(req, state, "packets"),
130        ("GET", "/api/proofs") => handle_event_list(req, state, "proofs"),
131        ("GET", "/api/link_events") => handle_event_list(req, state, "link_events"),
132        ("GET", "/api/resource_events") => handle_event_list(req, state, "resource_events"),
133
134        // Identity recall: /api/identity/<dest_hash>
135        ("GET", path) if path.starts_with("/api/identity/") => {
136            let hash_str = &path["/api/identity/".len()..];
137            handle_recall_identity(hash_str, node)
138        }
139
140        // Action endpoints
141        ("POST", "/api/destination") => handle_post_destination(req, node, state),
142        ("POST", "/api/announce") => handle_post_announce(req, node, state),
143        ("POST", "/api/send") => handle_post_send(req, node, state),
144        ("POST", "/api/config/validate") => handle_config_validate(req, state),
145        ("POST", "/api/config") => {
146            handle_config_mutation(req, state, crate::state::ServerConfigMutationMode::Save)
147        }
148        ("POST", "/api/config/apply") => {
149            handle_config_mutation(req, state, crate::state::ServerConfigMutationMode::Apply)
150        }
151        ("POST", "/api/link") => handle_post_link(req, node),
152        ("POST", "/api/link/send") => handle_post_link_send(req, node),
153        ("POST", "/api/link/close") => handle_post_link_close(req, node),
154        ("POST", "/api/channel") => handle_post_channel(req, node),
155        ("POST", "/api/resource") => handle_post_resource(req, node),
156        ("POST", "/api/path/request") => handle_post_path_request(req, node),
157        ("POST", "/api/direct_connect") => handle_post_direct_connect(req, node),
158        ("POST", "/api/announce_queues/clear") => handle_post_clear_announce_queues(node),
159        ("POST", path) if path.starts_with("/api/processes/") && path.ends_with("/restart") => {
160            handle_process_control(path, state, "restart")
161        }
162        ("POST", path) if path.starts_with("/api/processes/") && path.ends_with("/start") => {
163            handle_process_control(path, state, "start")
164        }
165        ("POST", path) if path.starts_with("/api/processes/") && path.ends_with("/stop") => {
166            handle_process_control(path, state, "stop")
167        }
168
169        // Backbone peer state
170        ("GET", "/api/backbone/peers") => handle_backbone_peers(req, node),
171        ("POST", "/api/backbone/blacklist") => handle_backbone_blacklist(req, node),
172
173        // Hook management
174        ("GET", "/api/hooks") => handle_list_hooks(node),
175        ("POST", "/api/hook/load") => handle_load_hook(req, node),
176        ("POST", "/api/hook/unload") => handle_unload_hook(req, node),
177        ("POST", "/api/hook/reload") => handle_reload_hook(req, node),
178        ("POST", "/api/hook/enable") => handle_set_hook_enabled(req, node, true),
179        ("POST", "/api/hook/disable") => handle_set_hook_enabled(req, node, false),
180        ("POST", "/api/hook/priority") => handle_set_hook_priority(req, node),
181
182        _ => HttpResponse::not_found(),
183    }
184}
185
186fn index_html(_config: &ControlPlaneConfigHandle) -> &'static str {
187    include_str!("../assets/index_auth.html")
188}
189
190// --- Read handlers ---
191
192fn handle_node(node: &NodeHandle, state: &SharedState) -> HttpResponse {
193    let (transport_id, drain_status) = {
194        let guard = lock_node_handle(node);
195        let Some(node) = guard.as_ref() else {
196            return HttpResponse::internal_error("Node is shutting down");
197        };
198        let transport_id = match node.query(QueryRequest::TransportIdentity) {
199            Ok(QueryResponse::TransportIdentity(id)) => id,
200            _ => None,
201        };
202        let drain_status = match node.query(QueryRequest::DrainStatus) {
203            Ok(QueryResponse::DrainStatus(status)) => Some(status),
204            _ => None,
205        };
206        (transport_id, drain_status)
207    };
208
209    let s = read_state(state);
210    HttpResponse::ok(json!({
211        "server_mode": s.server_mode,
212        "uptime_seconds": s.uptime_seconds(),
213        "transport_id": transport_id.map(|h| to_hex(&h)),
214        "identity_hash": s.identity_hash.map(|h| to_hex(&h)),
215        "process_count": s.processes.len(),
216        "processes_running": s.processes.values().filter(|p| p.status == "running").count(),
217        "processes_ready": s.processes.values().filter(|p| p.ready).count(),
218        "drain": drain_status.map(|status| json!({
219            "state": format!("{:?}", status.state).to_lowercase(),
220            "drain_age_seconds": status.drain_age_seconds,
221            "deadline_remaining_seconds": status.deadline_remaining_seconds,
222            "drain_complete": status.drain_complete,
223            "interface_writer_queued_frames": status.interface_writer_queued_frames,
224            "provider_backlog_events": status.provider_backlog_events,
225            "provider_consumer_queued_events": status.provider_consumer_queued_events,
226            "detail": status.detail,
227        })),
228    }))
229}
230
231fn handle_config(state: &SharedState) -> HttpResponse {
232    let s = read_state(state);
233    match &s.server_config {
234        Some(config) => HttpResponse::ok(json!({ "config": config })),
235        None => HttpResponse::ok(json!({ "config": null })),
236    }
237}
238
239fn handle_config_schema(state: &SharedState) -> HttpResponse {
240    let s = read_state(state);
241    match &s.server_config_schema {
242        Some(schema) => HttpResponse::ok(json!({ "schema": schema })),
243        None => HttpResponse::ok(json!({ "schema": null })),
244    }
245}
246
247fn handle_config_status(state: &SharedState) -> HttpResponse {
248    let s = read_state(state);
249    HttpResponse::ok(json!({
250        "status": s.server_config_status.snapshot(),
251    }))
252}
253
254fn handle_config_validate(req: &HttpRequest, state: &SharedState) -> HttpResponse {
255    let validator = {
256        let s = read_state(state);
257        s.server_config_validator.clone()
258    };
259
260    match validator {
261        Some(validator) => match validator(&req.body) {
262            Ok(result) => HttpResponse::ok(json!({ "result": result })),
263            Err(err) => HttpResponse::bad_request(&err),
264        },
265        None => HttpResponse::internal_error("Server config validation is not enabled"),
266    }
267}
268
269fn handle_config_mutation(
270    req: &HttpRequest,
271    state: &SharedState,
272    mode: crate::state::ServerConfigMutationMode,
273) -> HttpResponse {
274    let mutator = {
275        let s = read_state(state);
276        s.server_config_mutator.clone()
277    };
278
279    match mutator {
280        Some(mutator) => match mutator(mode, &req.body) {
281            Ok(result) => HttpResponse::ok(json!({ "result": result })),
282            Err(err) => HttpResponse::bad_request(&err),
283        },
284        None => HttpResponse::internal_error("Server config mutation is not enabled"),
285    }
286}
287
288fn handle_info(node: &NodeHandle, state: &SharedState) -> HttpResponse {
289    with_node(node, |n| {
290        let transport_id = match n.query(QueryRequest::TransportIdentity) {
291            Ok(QueryResponse::TransportIdentity(id)) => id,
292            _ => None,
293        };
294        let s = read_state(state);
295        HttpResponse::ok(json!({
296            "transport_id": transport_id.map(|h| to_hex(&h)),
297            "identity_hash": s.identity_hash.map(|h| to_hex(&h)),
298            "uptime_seconds": s.uptime_seconds(),
299        }))
300    })
301}
302
303fn handle_processes(state: &SharedState) -> HttpResponse {
304    let s = read_state(state);
305    let mut processes: Vec<&crate::state::ManagedProcessState> = s.processes.values().collect();
306    processes.sort_by(|a, b| a.name.cmp(&b.name));
307    HttpResponse::ok(json!({
308        "processes": processes
309            .into_iter()
310            .map(|p| json!({
311                "name": p.name,
312                "status": p.status,
313                "ready": p.ready,
314                "ready_state": p.ready_state,
315                "pid": p.pid,
316                "last_exit_code": p.last_exit_code,
317                "restart_count": p.restart_count,
318                "drain_ack_count": p.drain_ack_count,
319                "forced_kill_count": p.forced_kill_count,
320                "last_error": p.last_error,
321                "status_detail": p.status_detail,
322                "durable_log_path": p.durable_log_path,
323                "last_log_age_seconds": p.last_log_age_seconds(),
324                "recent_log_lines": p.recent_log_lines,
325                "uptime_seconds": p.uptime_seconds(),
326                "last_transition_seconds": p.last_transition_seconds(),
327            }))
328            .collect::<Vec<Value>>(),
329    }))
330}
331
332fn handle_process_events(state: &SharedState) -> HttpResponse {
333    let s = read_state(state);
334    let events: Vec<Value> = s
335        .process_events
336        .iter()
337        .rev()
338        .take(20)
339        .map(|event| {
340            json!({
341                "process": event.process,
342                "event": event.event,
343                "detail": event.detail,
344                "age_seconds": event.recorded_at.elapsed().as_secs_f64(),
345            })
346        })
347        .collect();
348    HttpResponse::ok(json!({ "events": events }))
349}
350
351fn handle_process_logs(path: &str, req: &HttpRequest, state: &SharedState) -> HttpResponse {
352    let Some(name) = path
353        .strip_prefix("/api/processes/")
354        .and_then(|rest| rest.strip_suffix("/logs"))
355    else {
356        return HttpResponse::bad_request("Invalid process logs path");
357    };
358
359    let limit = parse_query(&req.query)
360        .get("limit")
361        .and_then(|value| value.parse::<usize>().ok())
362        .map(|value| value.min(500))
363        .unwrap_or(200);
364
365    let s = read_state(state);
366    let Some(logs) = s.process_logs.get(name) else {
367        return HttpResponse::not_found();
368    };
369
370    let lines: Vec<Value> = logs
371        .iter()
372        .rev()
373        .take(limit)
374        .map(|entry| {
375            json!({
376                "process": entry.process,
377                "stream": entry.stream,
378                "line": entry.line,
379                "age_seconds": entry.recorded_at.elapsed().as_secs_f64(),
380            })
381        })
382        .collect();
383
384    HttpResponse::ok(json!({
385        "process": name,
386        "durable_log_path": s.processes.get(name).and_then(|p| p.durable_log_path.clone()),
387        "last_log_age_seconds": s.processes.get(name).and_then(|p| p.last_log_age_seconds()),
388        "recent_log_lines": s.processes.get(name).map(|p| p.recent_log_lines).unwrap_or(0),
389        "lines": lines,
390    }))
391}
392
393fn handle_process_control(path: &str, state: &SharedState, action: &str) -> HttpResponse {
394    let Some(name) = path.strip_prefix("/api/processes/").and_then(|rest| {
395        rest.strip_suffix("/restart")
396            .or_else(|| rest.strip_suffix("/start"))
397            .or_else(|| rest.strip_suffix("/stop"))
398    }) else {
399        return HttpResponse::bad_request("Invalid process control path");
400    };
401
402    let tx = {
403        let s = read_state(state);
404        s.control_tx.clone()
405    };
406
407    match tx {
408        Some(tx) => {
409            let process_name = name.to_string();
410            let command = match action {
411                "restart" => crate::state::ProcessControlCommand::Restart(process_name.clone()),
412                "start" => crate::state::ProcessControlCommand::Start(process_name.clone()),
413                "stop" => crate::state::ProcessControlCommand::Stop(process_name.clone()),
414                _ => return HttpResponse::bad_request("Unknown process action"),
415            };
416            match tx.send(command) {
417                Ok(()) => HttpResponse::ok(json!({
418                    "ok": true,
419                    "queued": true,
420                    "action": action,
421                    "process": process_name,
422                })),
423                Err(_) => HttpResponse::internal_error("Process control channel is unavailable"),
424            }
425        }
426        None => HttpResponse::internal_error("Process control is not enabled"),
427    }
428}
429
430fn handle_interfaces(node: &NodeHandle) -> HttpResponse {
431    with_node(node, |n| match n.query(QueryRequest::InterfaceStats) {
432        Ok(QueryResponse::InterfaceStats(stats)) => {
433            let ifaces: Vec<Value> = stats
434                .interfaces
435                .iter()
436                .map(|i| {
437                    json!({
438                        "id": i.id,
439                        "name": i.name,
440                        "status": if i.status { "up" } else { "down" },
441                        "mode": i.mode,
442                        "interface_type": i.interface_type,
443                        "rxb": i.rxb,
444                        "txb": i.txb,
445                        "rx_packets": i.rx_packets,
446                        "tx_packets": i.tx_packets,
447                        "bitrate": i.bitrate,
448                        "started": i.started,
449                        "ia_freq": i.ia_freq,
450                        "oa_freq": i.oa_freq,
451                    })
452                })
453                .collect();
454            let backbone_peer_pool = stats.backbone_peer_pool.as_ref().map(|pool| {
455                json!({
456                    "max_connected": pool.max_connected,
457                    "active_count": pool.active_count,
458                    "standby_count": pool.standby_count,
459                    "cooldown_count": pool.cooldown_count,
460                    "members": pool.members.iter().map(|member| {
461                        json!({
462                            "name": member.name,
463                            "remote": member.remote,
464                            "state": member.state,
465                            "interface_id": member.interface_id,
466                            "failure_count": member.failure_count,
467                            "last_error": member.last_error,
468                            "cooldown_remaining_seconds": member.cooldown_remaining_seconds,
469                        })
470                    }).collect::<Vec<_>>(),
471                })
472            });
473            HttpResponse::ok(json!({
474                "interfaces": ifaces,
475                "transport_enabled": stats.transport_enabled,
476                "transport_uptime": stats.transport_uptime,
477                "total_rxb": stats.total_rxb,
478                "total_txb": stats.total_txb,
479                "backbone_peer_pool": backbone_peer_pool,
480            }))
481        }
482        _ => HttpResponse::internal_error("Query failed"),
483    })
484}
485
486fn handle_destinations(node: &NodeHandle, state: &SharedState) -> HttpResponse {
487    with_node(node, |n| match n.query(QueryRequest::LocalDestinations) {
488        Ok(QueryResponse::LocalDestinations(dests)) => {
489            let s = read_state(state);
490            let list: Vec<Value> = dests
491                .iter()
492                .map(|d| {
493                    let name = s
494                        .destinations
495                        .get(&d.hash)
496                        .map(|e| e.full_name.as_str())
497                        .unwrap_or("");
498                    json!({
499                        "hash": to_hex(&d.hash),
500                        "type": d.dest_type,
501                        "name": name,
502                    })
503                })
504                .collect();
505            HttpResponse::ok(json!({"destinations": list}))
506        }
507        _ => HttpResponse::internal_error("Query failed"),
508    })
509}
510
511fn handle_paths(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
512    let params = parse_query(&req.query);
513    let filter_hash: Option<[u8; 16]> = params.get("dest_hash").and_then(|s| hex_to_array(s));
514
515    with_node(node, |n| {
516        match n.query(QueryRequest::PathTable { max_hops: None }) {
517            Ok(QueryResponse::PathTable(paths)) => {
518                let list: Vec<Value> = paths
519                    .iter()
520                    .filter(|p| filter_hash.is_none_or(|h| p.hash == h))
521                    .map(|p| {
522                        json!({
523                            "hash": to_hex(&p.hash),
524                            "via": to_hex(&p.via),
525                            "hops": p.hops,
526                            "expires": p.expires,
527                            "interface": p.interface_name,
528                            "timestamp": p.timestamp,
529                        })
530                    })
531                    .collect();
532                HttpResponse::ok(json!({"paths": list}))
533            }
534            _ => HttpResponse::internal_error("Query failed"),
535        }
536    })
537}
538
539fn handle_links(node: &NodeHandle) -> HttpResponse {
540    with_node(node, |n| match n.query(QueryRequest::Links) {
541        Ok(QueryResponse::Links(links)) => {
542            let list: Vec<Value> = links
543                .iter()
544                .map(|l| {
545                    json!({
546                        "link_id": to_hex(&l.link_id),
547                        "state": l.state,
548                        "is_initiator": l.is_initiator,
549                        "dest_hash": to_hex(&l.dest_hash),
550                        "remote_identity": l.remote_identity.map(|h| to_hex(&h)),
551                        "rtt": l.rtt,
552                        "channel_window": l.channel_window,
553                        "channel_outstanding": l.channel_outstanding,
554                        "pending_channel_packets": l.pending_channel_packets,
555                        "channel_send_ok": l.channel_send_ok,
556                        "channel_send_not_ready": l.channel_send_not_ready,
557                        "channel_send_too_big": l.channel_send_too_big,
558                        "channel_send_other_error": l.channel_send_other_error,
559                        "channel_messages_received": l.channel_messages_received,
560                        "channel_proofs_sent": l.channel_proofs_sent,
561                        "channel_proofs_received": l.channel_proofs_received,
562                    })
563                })
564                .collect();
565            HttpResponse::ok(json!({"links": list}))
566        }
567        _ => HttpResponse::internal_error("Query failed"),
568    })
569}
570
571fn handle_resources(node: &NodeHandle) -> HttpResponse {
572    with_node(node, |n| match n.query(QueryRequest::Resources) {
573        Ok(QueryResponse::Resources(resources)) => {
574            let list: Vec<Value> = resources
575                .iter()
576                .map(|r| {
577                    json!({
578                        "link_id": to_hex(&r.link_id),
579                        "direction": r.direction,
580                        "total_parts": r.total_parts,
581                        "transferred_parts": r.transferred_parts,
582                        "complete": r.complete,
583                    })
584                })
585                .collect();
586            HttpResponse::ok(json!({"resources": list}))
587        }
588        _ => HttpResponse::internal_error("Query failed"),
589    })
590}
591
592fn handle_event_list(req: &HttpRequest, state: &SharedState, kind: &str) -> HttpResponse {
593    let params = parse_query(&req.query);
594    let clear = params.get("clear").is_some_and(|v| v == "true");
595
596    let mut s = crate::state::write_state(state);
597    let items: Vec<Value> = match kind {
598        "announces" => {
599            let v: Vec<Value> = s
600                .announces
601                .iter()
602                .map(|r| serde_json::to_value(r).unwrap_or_default())
603                .collect();
604            if clear {
605                s.announces.clear();
606            }
607            v
608        }
609        "packets" => {
610            let v: Vec<Value> = s
611                .packets
612                .iter()
613                .map(|r| serde_json::to_value(r).unwrap_or_default())
614                .collect();
615            if clear {
616                s.packets.clear();
617            }
618            v
619        }
620        "proofs" => {
621            let v: Vec<Value> = s
622                .proofs
623                .iter()
624                .map(|r| serde_json::to_value(r).unwrap_or_default())
625                .collect();
626            if clear {
627                s.proofs.clear();
628            }
629            v
630        }
631        "link_events" => {
632            let v: Vec<Value> = s
633                .link_events
634                .iter()
635                .map(|r| serde_json::to_value(r).unwrap_or_default())
636                .collect();
637            if clear {
638                s.link_events.clear();
639            }
640            v
641        }
642        "resource_events" => {
643            let v: Vec<Value> = s
644                .resource_events
645                .iter()
646                .map(|r| serde_json::to_value(r).unwrap_or_default())
647                .collect();
648            if clear {
649                s.resource_events.clear();
650            }
651            v
652        }
653        _ => Vec::new(),
654    };
655
656    let mut obj = serde_json::Map::new();
657    obj.insert(kind.to_string(), Value::Array(items));
658    HttpResponse::ok(Value::Object(obj))
659}
660
661fn handle_recall_identity(hash_str: &str, node: &NodeHandle) -> HttpResponse {
662    let dest_hash: [u8; 16] = match hex_to_array(hash_str) {
663        Some(h) => h,
664        None => return HttpResponse::bad_request("Invalid dest_hash hex (expected 32 hex chars)"),
665    };
666
667    with_node(node, |n| match n.recall_identity(&DestHash(dest_hash)) {
668        Ok(Some(ai)) => HttpResponse::ok(json!({
669            "dest_hash": to_hex(&ai.dest_hash.0),
670            "identity_hash": to_hex(&ai.identity_hash.0),
671            "public_key": to_hex(&ai.public_key),
672            "app_data": ai.app_data.as_ref().map(|d| to_base64(d)),
673            "hops": ai.hops,
674            "received_at": ai.received_at,
675        })),
676        Ok(None) => HttpResponse::not_found(),
677        Err(_) => HttpResponse::internal_error("Query failed"),
678    })
679}
680
681// --- Action handlers ---
682
683fn parse_json_body(req: &HttpRequest) -> Result<Value, HttpResponse> {
684    serde_json::from_slice(&req.body)
685        .map_err(|e| HttpResponse::bad_request(&format!("Invalid JSON: {}", e)))
686}
687
688fn handle_post_destination(
689    req: &HttpRequest,
690    node: &NodeHandle,
691    state: &SharedState,
692) -> HttpResponse {
693    let body = match parse_json_body(req) {
694        Ok(v) => v,
695        Err(r) => return r,
696    };
697
698    let dest_type_str = body["type"].as_str().unwrap_or("");
699    let app_name = match body["app_name"].as_str() {
700        Some(s) => s,
701        None => return HttpResponse::bad_request("Missing app_name"),
702    };
703    let aspects: Vec<&str> = body["aspects"]
704        .as_array()
705        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
706        .unwrap_or_default();
707
708    let (identity_hash, identity_prv_key, identity_pub_key) = {
709        let s = read_state(state);
710        let ih = s.identity_hash;
711        let prv = s.identity.as_ref().and_then(|i| i.get_private_key());
712        let pubk = s.identity.as_ref().and_then(|i| i.get_public_key());
713        (ih, prv, pubk)
714    };
715
716    let (dest, signing_key) = match dest_type_str {
717        "single" => {
718            let direction = body["direction"].as_str().unwrap_or("in");
719            match direction {
720                "in" => {
721                    let ih = match identity_hash {
722                        Some(h) => IdentityHash(h),
723                        None => return HttpResponse::internal_error("No identity loaded"),
724                    };
725                    let dest = Destination::single_in(app_name, &aspects, ih)
726                        .set_proof_strategy(parse_proof_strategy(&body));
727                    (dest, identity_prv_key)
728                }
729                "out" => {
730                    let dh_str = match body["dest_hash"].as_str() {
731                        Some(s) => s,
732                        None => {
733                            return HttpResponse::bad_request(
734                                "OUT single requires dest_hash of remote",
735                            )
736                        }
737                    };
738                    let dh: [u8; 16] = match hex_to_array(dh_str) {
739                        Some(h) => h,
740                        None => return HttpResponse::bad_request("Invalid dest_hash"),
741                    };
742                    return with_node(node, |n| {
743                        match n.recall_identity(&DestHash(dh)) {
744                            Ok(Some(recalled)) => {
745                                let dest = Destination::single_out(app_name, &aspects, &recalled);
746                                // Register in state
747                                let full_name = format_dest_name(app_name, &aspects);
748                                let mut s = crate::state::write_state(state);
749                                s.destinations.insert(
750                                    dest.hash.0,
751                                    DestinationEntry {
752                                        destination: dest.clone(),
753                                        full_name: full_name.clone(),
754                                    },
755                                );
756                                HttpResponse::created(json!({
757                                    "dest_hash": to_hex(&dest.hash.0),
758                                    "name": full_name,
759                                    "type": "single",
760                                    "direction": "out",
761                                }))
762                            }
763                            Ok(None) => {
764                                HttpResponse::bad_request("No recalled identity for dest_hash")
765                            }
766                            Err(_) => HttpResponse::internal_error("Query failed"),
767                        }
768                    });
769                }
770                _ => return HttpResponse::bad_request("direction must be 'in' or 'out'"),
771            }
772        }
773        "plain" => {
774            let dest = Destination::plain(app_name, &aspects)
775                .set_proof_strategy(parse_proof_strategy(&body));
776            (dest, None)
777        }
778        "group" => {
779            let mut dest = Destination::group(app_name, &aspects)
780                .set_proof_strategy(parse_proof_strategy(&body));
781            if let Some(key_b64) = body["group_key"].as_str() {
782                match from_base64(key_b64) {
783                    Some(key) => {
784                        if let Err(e) = dest.load_private_key(key) {
785                            return HttpResponse::bad_request(&format!("Invalid group key: {}", e));
786                        }
787                    }
788                    None => return HttpResponse::bad_request("Invalid base64 group_key"),
789                }
790            } else {
791                dest.create_keys();
792            }
793            (dest, None)
794        }
795        _ => return HttpResponse::bad_request("type must be 'single', 'plain', or 'group'"),
796    };
797
798    with_node(node, |n| {
799        match n.register_destination_with_proof(&dest, signing_key) {
800            Ok(()) => {
801                // For inbound single dests, also register with link manager
802                // so incoming LINKREQUEST packets are accepted.
803                if dest_type_str == "single" && body["direction"].as_str().unwrap_or("in") == "in" {
804                    if let (Some(prv), Some(pubk)) = (identity_prv_key, identity_pub_key) {
805                        let mut sig_prv = [0u8; 32];
806                        sig_prv.copy_from_slice(&prv[32..64]);
807                        let mut sig_pub = [0u8; 32];
808                        sig_pub.copy_from_slice(&pubk[32..64]);
809                        let _ = n.register_link_destination(dest.hash.0, sig_prv, sig_pub, 0);
810                    }
811                }
812
813                let full_name = format_dest_name(app_name, &aspects);
814                let hash_hex = to_hex(&dest.hash.0);
815                let group_key_b64 = dest.get_private_key().map(to_base64);
816                let mut s = crate::state::write_state(state);
817                s.destinations.insert(
818                    dest.hash.0,
819                    DestinationEntry {
820                        destination: dest,
821                        full_name: full_name.clone(),
822                    },
823                );
824                let mut resp = json!({
825                    "dest_hash": hash_hex,
826                    "name": full_name,
827                    "type": dest_type_str,
828                });
829                if let Some(gk) = group_key_b64 {
830                    resp["group_key"] = Value::String(gk);
831                }
832                HttpResponse::created(resp)
833            }
834            Err(_) => HttpResponse::internal_error("Failed to register destination"),
835        }
836    })
837}
838
839fn handle_post_announce(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
840    let body = match parse_json_body(req) {
841        Ok(v) => v,
842        Err(r) => return r,
843    };
844
845    let dh_str = match body["dest_hash"].as_str() {
846        Some(s) => s,
847        None => return HttpResponse::bad_request("Missing dest_hash"),
848    };
849    let dh: [u8; 16] = match hex_to_array(dh_str) {
850        Some(h) => h,
851        None => return HttpResponse::bad_request("Invalid dest_hash"),
852    };
853
854    let app_data: Option<Vec<u8>> = body["app_data"].as_str().and_then(from_base64);
855
856    let (dest, identity) = {
857        let s = read_state(state);
858        let dest = match s.destinations.get(&dh) {
859            Some(entry) => entry.destination.clone(),
860            None => return HttpResponse::bad_request("Destination not registered via API"),
861        };
862        let identity = match s.identity.as_ref().and_then(|i| i.get_private_key()) {
863            Some(prv) => Identity::from_private_key(&prv),
864            None => return HttpResponse::internal_error("No identity loaded"),
865        };
866        (dest, identity)
867    };
868
869    with_active_node(node, |n| {
870        match n.announce(&dest, &identity, app_data.as_deref()) {
871            Ok(()) => HttpResponse::ok(json!({"status": "announced", "dest_hash": dh_str})),
872            Err(_) => HttpResponse::internal_error("Announce failed"),
873        }
874    })
875}
876
877fn handle_post_send(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
878    let body = match parse_json_body(req) {
879        Ok(v) => v,
880        Err(r) => return r,
881    };
882
883    let dh_str = match body["dest_hash"].as_str() {
884        Some(s) => s,
885        None => return HttpResponse::bad_request("Missing dest_hash"),
886    };
887    let dh: [u8; 16] = match hex_to_array(dh_str) {
888        Some(h) => h,
889        None => return HttpResponse::bad_request("Invalid dest_hash"),
890    };
891    let data = match body["data"].as_str().and_then(from_base64) {
892        Some(d) => d,
893        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
894    };
895
896    let s = read_state(state);
897    let dest = match s.destinations.get(&dh) {
898        Some(entry) => entry.destination.clone(),
899        None => return HttpResponse::bad_request("Destination not registered via API"),
900    };
901    drop(s);
902
903    let max_len = match dest.dest_type {
904        rns_core::types::DestinationType::Plain => rns_core::constants::PLAIN_MDU,
905        rns_core::types::DestinationType::Single | rns_core::types::DestinationType::Group => {
906            rns_core::constants::ENCRYPTED_MDU
907        }
908    };
909    if data.len() > max_len {
910        return HttpResponse::bad_request(&format!(
911            "Payload too large for single-packet send: {} bytes > {} byte limit",
912            data.len(),
913            max_len
914        ));
915    }
916
917    with_active_node(node, |n| match n.send_packet(&dest, &data) {
918        Ok(ph) => HttpResponse::ok(json!({
919            "status": "sent",
920            "packet_hash": to_hex(&ph.0),
921        })),
922        Err(_) => HttpResponse::internal_error("Send failed"),
923    })
924}
925
926fn handle_post_link(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
927    let body = match parse_json_body(req) {
928        Ok(v) => v,
929        Err(r) => return r,
930    };
931
932    let dh_str = match body["dest_hash"].as_str() {
933        Some(s) => s,
934        None => return HttpResponse::bad_request("Missing dest_hash"),
935    };
936    let dh: [u8; 16] = match hex_to_array(dh_str) {
937        Some(h) => h,
938        None => return HttpResponse::bad_request("Invalid dest_hash"),
939    };
940
941    with_active_node(node, |n| {
942        // Recall identity to get signing public key
943        let recalled = match n.recall_identity(&DestHash(dh)) {
944            Ok(Some(ai)) => ai,
945            Ok(None) => return HttpResponse::bad_request("No recalled identity for dest_hash"),
946            Err(_) => return HttpResponse::internal_error("Query failed"),
947        };
948        // Extract Ed25519 public key (second 32 bytes of public_key)
949        let mut sig_pub = [0u8; 32];
950        sig_pub.copy_from_slice(&recalled.public_key[32..64]);
951
952        match n.create_link(dh, sig_pub) {
953            Ok(link_id) => HttpResponse::created(json!({
954                "link_id": to_hex(&link_id),
955            })),
956            Err(_) => HttpResponse::internal_error("Create link failed"),
957        }
958    })
959}
960
961fn handle_post_link_send(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
962    let body = match parse_json_body(req) {
963        Ok(v) => v,
964        Err(r) => return r,
965    };
966
967    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
968        Some(h) => h,
969        None => return HttpResponse::bad_request("Missing or invalid link_id"),
970    };
971    let data = match body["data"].as_str().and_then(from_base64) {
972        Some(d) => d,
973        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
974    };
975    let context = body["context"].as_u64().unwrap_or(0) as u8;
976
977    with_active_node(node, |n| match n.send_on_link(link_id, data, context) {
978        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
979        Err(_) => HttpResponse::internal_error("Send on link failed"),
980    })
981}
982
983fn handle_post_link_close(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
984    let body = match parse_json_body(req) {
985        Ok(v) => v,
986        Err(r) => return r,
987    };
988
989    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
990        Some(h) => h,
991        None => return HttpResponse::bad_request("Missing or invalid link_id"),
992    };
993
994    with_node(node, |n| match n.teardown_link(link_id) {
995        Ok(()) => HttpResponse::ok(json!({"status": "closed"})),
996        Err(_) => HttpResponse::internal_error("Teardown link failed"),
997    })
998}
999
1000fn handle_post_channel(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1001    let body = match parse_json_body(req) {
1002        Ok(v) => v,
1003        Err(r) => return r,
1004    };
1005
1006    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
1007        Some(h) => h,
1008        None => return HttpResponse::bad_request("Missing or invalid link_id"),
1009    };
1010    let msgtype = body["msgtype"].as_u64().unwrap_or(0) as u16;
1011    let payload = match body["payload"].as_str().and_then(from_base64) {
1012        Some(d) => d,
1013        None => return HttpResponse::bad_request("Missing or invalid base64 payload"),
1014    };
1015
1016    with_active_node(node, |n| {
1017        match n.send_channel_message(link_id, msgtype, payload) {
1018            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
1019            Err(_) => HttpResponse::bad_request("Channel message failed"),
1020        }
1021    })
1022}
1023
1024fn handle_post_resource(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1025    let body = match parse_json_body(req) {
1026        Ok(v) => v,
1027        Err(r) => return r,
1028    };
1029
1030    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
1031        Some(h) => h,
1032        None => return HttpResponse::bad_request("Missing or invalid link_id"),
1033    };
1034    let data = match body["data"].as_str().and_then(from_base64) {
1035        Some(d) => d,
1036        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
1037    };
1038    let metadata = body["metadata"].as_str().and_then(from_base64);
1039
1040    with_active_node(node, |n| match n.send_resource(link_id, data, metadata) {
1041        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
1042        Err(_) => HttpResponse::internal_error("Resource send failed"),
1043    })
1044}
1045
1046fn handle_post_path_request(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1047    let body = match parse_json_body(req) {
1048        Ok(v) => v,
1049        Err(r) => return r,
1050    };
1051
1052    let dh_str = match body["dest_hash"].as_str() {
1053        Some(s) => s,
1054        None => return HttpResponse::bad_request("Missing dest_hash"),
1055    };
1056    let dh: [u8; 16] = match hex_to_array(dh_str) {
1057        Some(h) => h,
1058        None => return HttpResponse::bad_request("Invalid dest_hash"),
1059    };
1060
1061    with_active_node(node, |n| match n.request_path(&DestHash(dh)) {
1062        Ok(()) => HttpResponse::ok(json!({"status": "requested"})),
1063        Err(_) => HttpResponse::internal_error("Path request failed"),
1064    })
1065}
1066
1067fn handle_post_direct_connect(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1068    let body = match parse_json_body(req) {
1069        Ok(v) => v,
1070        Err(r) => return r,
1071    };
1072
1073    let lid_str = match body["link_id"].as_str() {
1074        Some(s) => s,
1075        None => return HttpResponse::bad_request("Missing link_id"),
1076    };
1077    let link_id: [u8; 16] = match hex_to_array(lid_str) {
1078        Some(h) => h,
1079        None => return HttpResponse::bad_request("Invalid link_id"),
1080    };
1081
1082    with_active_node(node, |n| match n.propose_direct_connect(link_id) {
1083        Ok(()) => HttpResponse::ok(json!({"status": "proposed"})),
1084        Err(_) => HttpResponse::internal_error("Direct connect proposal failed"),
1085    })
1086}
1087
1088fn handle_post_clear_announce_queues(node: &NodeHandle) -> HttpResponse {
1089    with_node(node, |n| match n.query(QueryRequest::DropAnnounceQueues) {
1090        Ok(QueryResponse::DropAnnounceQueues) => HttpResponse::ok(json!({"status": "ok"})),
1091        _ => HttpResponse::internal_error("Query failed"),
1092    })
1093}
1094
1095// --- Backbone peer state handlers ---
1096
1097fn handle_backbone_peers(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1098    let params = parse_query(&req.path);
1099    let interface_name = params.get("interface").map(|s| s.to_string());
1100    with_node(node, |n| {
1101        match n.query(QueryRequest::BackbonePeerState { interface_name }) {
1102            Ok(QueryResponse::BackbonePeerState(entries)) => {
1103                let peers: Vec<Value> = entries
1104                    .iter()
1105                    .map(|e| {
1106                        json!({
1107                            "interface": e.interface_name,
1108                            "ip": e.peer_ip.to_string(),
1109                            "connected_count": e.connected_count,
1110                            "blacklisted_remaining_secs": e.blacklisted_remaining_secs,
1111                            "blacklist_reason": e.blacklist_reason,
1112                            "reject_count": e.reject_count,
1113                        })
1114                    })
1115                    .collect();
1116                HttpResponse::ok(json!({ "peers": peers }))
1117            }
1118            _ => HttpResponse::internal_error("Query failed"),
1119        }
1120    })
1121}
1122
1123fn handle_backbone_blacklist(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1124    let body: Value = match serde_json::from_slice(&req.body) {
1125        Ok(v) => v,
1126        Err(_) => return HttpResponse::bad_request("Invalid JSON body"),
1127    };
1128    let interface_name = match body.get("interface").and_then(|v| v.as_str()) {
1129        Some(s) => s.to_string(),
1130        None => return HttpResponse::bad_request("Missing 'interface' field"),
1131    };
1132    let ip = match body.get("ip").and_then(|v| v.as_str()) {
1133        Some(s) => match s.parse::<std::net::IpAddr>() {
1134            Ok(addr) => addr,
1135            Err(_) => return HttpResponse::bad_request("Invalid IP address"),
1136        },
1137        None => return HttpResponse::bad_request("Missing 'ip' field"),
1138    };
1139    let duration_secs = match body.get("duration_secs").and_then(|v| v.as_u64()) {
1140        Some(d) => d,
1141        None => return HttpResponse::bad_request("Missing 'duration_secs' field"),
1142    };
1143    let reason = body
1144        .get("reason")
1145        .and_then(|v| v.as_str())
1146        .unwrap_or("sentinel blacklist")
1147        .to_string();
1148    let penalty_level = body
1149        .get("penalty_level")
1150        .and_then(|v| v.as_u64())
1151        .unwrap_or(0)
1152        .min(u8::MAX as u64) as u8;
1153    with_node(node, |n| {
1154        match n.query(QueryRequest::BlacklistBackbonePeer {
1155            interface_name,
1156            peer_ip: ip,
1157            duration: std::time::Duration::from_secs(duration_secs),
1158            reason,
1159            penalty_level,
1160        }) {
1161            Ok(QueryResponse::BlacklistBackbonePeer(true)) => {
1162                HttpResponse::ok(json!({"status": "ok"}))
1163            }
1164            Ok(QueryResponse::BlacklistBackbonePeer(false)) => HttpResponse::not_found(),
1165            _ => HttpResponse::internal_error("Query failed"),
1166        }
1167    })
1168}
1169
1170// --- Hook handlers ---
1171
1172fn handle_list_hooks(node: &NodeHandle) -> HttpResponse {
1173    with_node(node, |n| match n.list_hooks() {
1174        Ok(hooks) => {
1175            let list: Vec<Value> = hooks
1176                .iter()
1177                .map(|h| {
1178                    json!({
1179                        "name": h.name,
1180                        "attach_point": h.attach_point,
1181                        "type": h.hook_type,
1182                        "priority": h.priority,
1183                        "enabled": h.enabled,
1184                        "consecutive_traps": h.consecutive_traps,
1185                    })
1186                })
1187                .collect();
1188            HttpResponse::ok(json!({"hooks": list}))
1189        }
1190        Err(_) => HttpResponse::internal_error("Query failed"),
1191    })
1192}
1193
1194/// Load a hook from a filesystem path.
1195///
1196/// The `path` field in the JSON body refers to a file on the **server's** local
1197/// filesystem. This means the CLI and the HTTP server must have access to the
1198/// same filesystem for the path to resolve correctly.
1199fn handle_load_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1200    let body = match parse_json_body(req) {
1201        Ok(v) => v,
1202        Err(r) => return r,
1203    };
1204
1205    let name = match body["name"].as_str() {
1206        Some(s) => s.to_string(),
1207        None => return HttpResponse::bad_request("Missing name"),
1208    };
1209    let path = match body["path"].as_str() {
1210        Some(s) => s,
1211        None => return HttpResponse::bad_request("Missing path"),
1212    };
1213    let attach_point = match body["attach_point"].as_str() {
1214        Some(s) => s.to_string(),
1215        None => return HttpResponse::bad_request("Missing attach_point"),
1216    };
1217    let priority = body["priority"].as_i64().unwrap_or(0) as i32;
1218    let hook_type = body["type"]
1219        .as_str()
1220        .unwrap_or(DEFAULT_HOOK_TYPE)
1221        .to_string();
1222
1223    if hook_type == "wasm" {
1224        let wasm_bytes = match std::fs::read(path) {
1225            Ok(b) => b,
1226            Err(e) => {
1227                return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e));
1228            }
1229        };
1230
1231        with_node(node, |n| {
1232            match n.load_hook(name, wasm_bytes, attach_point, priority) {
1233                Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1234                Ok(Err(e)) => HttpResponse::bad_request(&e),
1235                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1236            }
1237        })
1238    } else if hook_type == "builtin" {
1239        let builtin_id = body["builtin_id"]
1240            .as_str()
1241            .or_else(|| body["id"].as_str())
1242            .unwrap_or(path)
1243            .to_string();
1244        with_node(node, |n| {
1245            match n.load_builtin_hook(name, builtin_id, attach_point, priority) {
1246                Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1247                Ok(Err(e)) => HttpResponse::bad_request(&e),
1248                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1249            }
1250        })
1251    } else {
1252        with_node(node, |n| {
1253            match n.load_hook_file(name, path.to_string(), hook_type, attach_point, priority) {
1254                Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1255                Ok(Err(e)) => HttpResponse::bad_request(&e),
1256                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1257            }
1258        })
1259    }
1260}
1261
1262fn handle_unload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1263    let body = match parse_json_body(req) {
1264        Ok(v) => v,
1265        Err(r) => return r,
1266    };
1267
1268    let name = match body["name"].as_str() {
1269        Some(s) => s.to_string(),
1270        None => return HttpResponse::bad_request("Missing name"),
1271    };
1272    let attach_point = match body["attach_point"].as_str() {
1273        Some(s) => s.to_string(),
1274        None => return HttpResponse::bad_request("Missing attach_point"),
1275    };
1276
1277    with_node(node, |n| match n.unload_hook(name, attach_point) {
1278        Ok(Ok(())) => HttpResponse::ok(json!({"status": "unloaded"})),
1279        Ok(Err(e)) => HttpResponse::bad_request(&e),
1280        Err(_) => HttpResponse::internal_error("Driver unavailable"),
1281    })
1282}
1283
1284fn handle_reload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1285    let body = match parse_json_body(req) {
1286        Ok(v) => v,
1287        Err(r) => return r,
1288    };
1289
1290    let name = match body["name"].as_str() {
1291        Some(s) => s.to_string(),
1292        None => return HttpResponse::bad_request("Missing name"),
1293    };
1294    let path = match body["path"].as_str() {
1295        Some(s) => s,
1296        None => return HttpResponse::bad_request("Missing path"),
1297    };
1298    let attach_point = match body["attach_point"].as_str() {
1299        Some(s) => s.to_string(),
1300        None => return HttpResponse::bad_request("Missing attach_point"),
1301    };
1302    let hook_type = body["type"]
1303        .as_str()
1304        .unwrap_or(DEFAULT_HOOK_TYPE)
1305        .to_string();
1306
1307    if hook_type == "wasm" {
1308        let wasm_bytes = match std::fs::read(path) {
1309            Ok(b) => b,
1310            Err(e) => {
1311                return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e));
1312            }
1313        };
1314
1315        with_node(node, |n| {
1316            match n.reload_hook(name, attach_point, wasm_bytes) {
1317                Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1318                Ok(Err(e)) => HttpResponse::bad_request(&e),
1319                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1320            }
1321        })
1322    } else if hook_type == "builtin" {
1323        let builtin_id = body["builtin_id"]
1324            .as_str()
1325            .or_else(|| body["id"].as_str())
1326            .unwrap_or(path)
1327            .to_string();
1328        with_node(node, |n| {
1329            match n.reload_builtin_hook(name, attach_point, builtin_id) {
1330                Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1331                Ok(Err(e)) => HttpResponse::bad_request(&e),
1332                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1333            }
1334        })
1335    } else {
1336        with_node(node, |n| {
1337            match n.reload_hook_file(name, attach_point, path.to_string(), hook_type) {
1338                Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1339                Ok(Err(e)) => HttpResponse::bad_request(&e),
1340                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1341            }
1342        })
1343    }
1344}
1345
1346fn handle_set_hook_enabled(req: &HttpRequest, node: &NodeHandle, enabled: bool) -> HttpResponse {
1347    let body = match parse_json_body(req) {
1348        Ok(v) => v,
1349        Err(r) => return r,
1350    };
1351
1352    let name = match body["name"].as_str() {
1353        Some(s) => s.to_string(),
1354        None => return HttpResponse::bad_request("Missing name"),
1355    };
1356    let attach_point = match body["attach_point"].as_str() {
1357        Some(s) => s.to_string(),
1358        None => return HttpResponse::bad_request("Missing attach_point"),
1359    };
1360
1361    with_node(node, |n| {
1362        match n.set_hook_enabled(name, attach_point, enabled) {
1363            Ok(Ok(())) => HttpResponse::ok(json!({
1364                "status": if enabled { "enabled" } else { "disabled" }
1365            })),
1366            Ok(Err(e)) => HttpResponse::bad_request(&e),
1367            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1368        }
1369    })
1370}
1371
1372fn handle_set_hook_priority(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1373    let body = match parse_json_body(req) {
1374        Ok(v) => v,
1375        Err(r) => return r,
1376    };
1377
1378    let name = match body["name"].as_str() {
1379        Some(s) => s.to_string(),
1380        None => return HttpResponse::bad_request("Missing name"),
1381    };
1382    let attach_point = match body["attach_point"].as_str() {
1383        Some(s) => s.to_string(),
1384        None => return HttpResponse::bad_request("Missing attach_point"),
1385    };
1386    let priority = match body["priority"].as_i64() {
1387        Some(v) => v as i32,
1388        None => return HttpResponse::bad_request("Missing priority"),
1389    };
1390
1391    with_node(node, |n| {
1392        match n.set_hook_priority(name, attach_point, priority) {
1393            Ok(Ok(())) => HttpResponse::ok(json!({"status": "priority_updated"})),
1394            Ok(Err(e)) => HttpResponse::bad_request(&e),
1395            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1396        }
1397    })
1398}
1399
1400// --- Helpers ---
1401
1402fn format_dest_name(app_name: &str, aspects: &[&str]) -> String {
1403    if aspects.is_empty() {
1404        app_name.to_string()
1405    } else {
1406        format!("{}.{}", app_name, aspects.join("."))
1407    }
1408}
1409
1410fn parse_proof_strategy(body: &Value) -> ProofStrategy {
1411    match body["proof_strategy"].as_str() {
1412        Some("all") => ProofStrategy::ProveAll,
1413        Some("app") => ProofStrategy::ProveApp,
1414        _ => ProofStrategy::ProveNone,
1415    }
1416}