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                            "source": member.source,
465                            "priority": member.priority,
466                            "state": member.state,
467                            "interface_id": member.interface_id,
468                            "failure_count": member.failure_count,
469                            "last_error": member.last_error,
470                            "cooldown_remaining_seconds": member.cooldown_remaining_seconds,
471                        })
472                    }).collect::<Vec<_>>(),
473                })
474            });
475            HttpResponse::ok(json!({
476                "interfaces": ifaces,
477                "transport_enabled": stats.transport_enabled,
478                "transport_uptime": stats.transport_uptime,
479                "total_rxb": stats.total_rxb,
480                "total_txb": stats.total_txb,
481                "backbone_peer_pool": backbone_peer_pool,
482            }))
483        }
484        _ => HttpResponse::internal_error("Query failed"),
485    })
486}
487
488fn handle_destinations(node: &NodeHandle, state: &SharedState) -> HttpResponse {
489    with_node(node, |n| match n.query(QueryRequest::LocalDestinations) {
490        Ok(QueryResponse::LocalDestinations(dests)) => {
491            let s = read_state(state);
492            let list: Vec<Value> = dests
493                .iter()
494                .map(|d| {
495                    let name = s
496                        .destinations
497                        .get(&d.hash)
498                        .map(|e| e.full_name.as_str())
499                        .unwrap_or("");
500                    json!({
501                        "hash": to_hex(&d.hash),
502                        "type": d.dest_type,
503                        "name": name,
504                    })
505                })
506                .collect();
507            HttpResponse::ok(json!({"destinations": list}))
508        }
509        _ => HttpResponse::internal_error("Query failed"),
510    })
511}
512
513fn handle_paths(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
514    let params = parse_query(&req.query);
515    let filter_hash: Option<[u8; 16]> = params.get("dest_hash").and_then(|s| hex_to_array(s));
516
517    with_node(node, |n| {
518        match n.query(QueryRequest::PathTable { max_hops: None }) {
519            Ok(QueryResponse::PathTable(paths)) => {
520                let list: Vec<Value> = paths
521                    .iter()
522                    .filter(|p| filter_hash.is_none_or(|h| p.hash == h))
523                    .map(|p| {
524                        json!({
525                            "hash": to_hex(&p.hash),
526                            "via": to_hex(&p.via),
527                            "hops": p.hops,
528                            "expires": p.expires,
529                            "interface": p.interface_name,
530                            "timestamp": p.timestamp,
531                        })
532                    })
533                    .collect();
534                HttpResponse::ok(json!({"paths": list}))
535            }
536            _ => HttpResponse::internal_error("Query failed"),
537        }
538    })
539}
540
541fn handle_links(node: &NodeHandle) -> HttpResponse {
542    with_node(node, |n| match n.query(QueryRequest::Links) {
543        Ok(QueryResponse::Links(links)) => {
544            let list: Vec<Value> = links
545                .iter()
546                .map(|l| {
547                    json!({
548                        "link_id": to_hex(&l.link_id),
549                        "state": l.state,
550                        "is_initiator": l.is_initiator,
551                        "dest_hash": to_hex(&l.dest_hash),
552                        "remote_identity": l.remote_identity.map(|h| to_hex(&h)),
553                        "rtt": l.rtt,
554                        "channel_window": l.channel_window,
555                        "channel_outstanding": l.channel_outstanding,
556                        "pending_channel_packets": l.pending_channel_packets,
557                        "channel_send_ok": l.channel_send_ok,
558                        "channel_send_not_ready": l.channel_send_not_ready,
559                        "channel_send_too_big": l.channel_send_too_big,
560                        "channel_send_other_error": l.channel_send_other_error,
561                        "channel_messages_received": l.channel_messages_received,
562                        "channel_proofs_sent": l.channel_proofs_sent,
563                        "channel_proofs_received": l.channel_proofs_received,
564                    })
565                })
566                .collect();
567            HttpResponse::ok(json!({"links": list}))
568        }
569        _ => HttpResponse::internal_error("Query failed"),
570    })
571}
572
573fn handle_resources(node: &NodeHandle) -> HttpResponse {
574    with_node(node, |n| match n.query(QueryRequest::Resources) {
575        Ok(QueryResponse::Resources(resources)) => {
576            let list: Vec<Value> = resources
577                .iter()
578                .map(|r| {
579                    json!({
580                        "link_id": to_hex(&r.link_id),
581                        "direction": r.direction,
582                        "total_parts": r.total_parts,
583                        "transferred_parts": r.transferred_parts,
584                        "complete": r.complete,
585                    })
586                })
587                .collect();
588            HttpResponse::ok(json!({"resources": list}))
589        }
590        _ => HttpResponse::internal_error("Query failed"),
591    })
592}
593
594fn handle_event_list(req: &HttpRequest, state: &SharedState, kind: &str) -> HttpResponse {
595    let params = parse_query(&req.query);
596    let clear = params.get("clear").is_some_and(|v| v == "true");
597
598    let mut s = crate::state::write_state(state);
599    let items: Vec<Value> = match kind {
600        "announces" => {
601            let v: Vec<Value> = s
602                .announces
603                .iter()
604                .map(|r| serde_json::to_value(r).unwrap_or_default())
605                .collect();
606            if clear {
607                s.announces.clear();
608            }
609            v
610        }
611        "packets" => {
612            let v: Vec<Value> = s
613                .packets
614                .iter()
615                .map(|r| serde_json::to_value(r).unwrap_or_default())
616                .collect();
617            if clear {
618                s.packets.clear();
619            }
620            v
621        }
622        "proofs" => {
623            let v: Vec<Value> = s
624                .proofs
625                .iter()
626                .map(|r| serde_json::to_value(r).unwrap_or_default())
627                .collect();
628            if clear {
629                s.proofs.clear();
630            }
631            v
632        }
633        "link_events" => {
634            let v: Vec<Value> = s
635                .link_events
636                .iter()
637                .map(|r| serde_json::to_value(r).unwrap_or_default())
638                .collect();
639            if clear {
640                s.link_events.clear();
641            }
642            v
643        }
644        "resource_events" => {
645            let v: Vec<Value> = s
646                .resource_events
647                .iter()
648                .map(|r| serde_json::to_value(r).unwrap_or_default())
649                .collect();
650            if clear {
651                s.resource_events.clear();
652            }
653            v
654        }
655        _ => Vec::new(),
656    };
657
658    let mut obj = serde_json::Map::new();
659    obj.insert(kind.to_string(), Value::Array(items));
660    HttpResponse::ok(Value::Object(obj))
661}
662
663fn handle_recall_identity(hash_str: &str, node: &NodeHandle) -> HttpResponse {
664    let dest_hash: [u8; 16] = match hex_to_array(hash_str) {
665        Some(h) => h,
666        None => return HttpResponse::bad_request("Invalid dest_hash hex (expected 32 hex chars)"),
667    };
668
669    with_node(node, |n| match n.recall_identity(&DestHash(dest_hash)) {
670        Ok(Some(ai)) => HttpResponse::ok(json!({
671            "dest_hash": to_hex(&ai.dest_hash.0),
672            "identity_hash": to_hex(&ai.identity_hash.0),
673            "public_key": to_hex(&ai.public_key),
674            "app_data": ai.app_data.as_ref().map(|d| to_base64(d)),
675            "hops": ai.hops,
676            "received_at": ai.received_at,
677        })),
678        Ok(None) => HttpResponse::not_found(),
679        Err(_) => HttpResponse::internal_error("Query failed"),
680    })
681}
682
683// --- Action handlers ---
684
685fn parse_json_body(req: &HttpRequest) -> Result<Value, HttpResponse> {
686    serde_json::from_slice(&req.body)
687        .map_err(|e| HttpResponse::bad_request(&format!("Invalid JSON: {}", e)))
688}
689
690fn handle_post_destination(
691    req: &HttpRequest,
692    node: &NodeHandle,
693    state: &SharedState,
694) -> HttpResponse {
695    let body = match parse_json_body(req) {
696        Ok(v) => v,
697        Err(r) => return r,
698    };
699
700    let dest_type_str = body["type"].as_str().unwrap_or("");
701    let app_name = match body["app_name"].as_str() {
702        Some(s) => s,
703        None => return HttpResponse::bad_request("Missing app_name"),
704    };
705    let aspects: Vec<&str> = body["aspects"]
706        .as_array()
707        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
708        .unwrap_or_default();
709
710    let (identity_hash, identity_prv_key, identity_pub_key) = {
711        let s = read_state(state);
712        let ih = s.identity_hash;
713        let prv = s.identity.as_ref().and_then(|i| i.get_private_key());
714        let pubk = s.identity.as_ref().and_then(|i| i.get_public_key());
715        (ih, prv, pubk)
716    };
717
718    let (dest, signing_key) = match dest_type_str {
719        "single" => {
720            let direction = body["direction"].as_str().unwrap_or("in");
721            match direction {
722                "in" => {
723                    let ih = match identity_hash {
724                        Some(h) => IdentityHash(h),
725                        None => return HttpResponse::internal_error("No identity loaded"),
726                    };
727                    let dest = Destination::single_in(app_name, &aspects, ih)
728                        .set_proof_strategy(parse_proof_strategy(&body));
729                    (dest, identity_prv_key)
730                }
731                "out" => {
732                    let dh_str = match body["dest_hash"].as_str() {
733                        Some(s) => s,
734                        None => {
735                            return HttpResponse::bad_request(
736                                "OUT single requires dest_hash of remote",
737                            )
738                        }
739                    };
740                    let dh: [u8; 16] = match hex_to_array(dh_str) {
741                        Some(h) => h,
742                        None => return HttpResponse::bad_request("Invalid dest_hash"),
743                    };
744                    return with_node(node, |n| {
745                        match n.recall_identity(&DestHash(dh)) {
746                            Ok(Some(recalled)) => {
747                                let dest = Destination::single_out(app_name, &aspects, &recalled);
748                                // Register in state
749                                let full_name = format_dest_name(app_name, &aspects);
750                                let mut s = crate::state::write_state(state);
751                                s.destinations.insert(
752                                    dest.hash.0,
753                                    DestinationEntry {
754                                        destination: dest.clone(),
755                                        full_name: full_name.clone(),
756                                    },
757                                );
758                                HttpResponse::created(json!({
759                                    "dest_hash": to_hex(&dest.hash.0),
760                                    "name": full_name,
761                                    "type": "single",
762                                    "direction": "out",
763                                }))
764                            }
765                            Ok(None) => {
766                                HttpResponse::bad_request("No recalled identity for dest_hash")
767                            }
768                            Err(_) => HttpResponse::internal_error("Query failed"),
769                        }
770                    });
771                }
772                _ => return HttpResponse::bad_request("direction must be 'in' or 'out'"),
773            }
774        }
775        "plain" => {
776            let dest = Destination::plain(app_name, &aspects)
777                .set_proof_strategy(parse_proof_strategy(&body));
778            (dest, None)
779        }
780        "group" => {
781            let mut dest = Destination::group(app_name, &aspects)
782                .set_proof_strategy(parse_proof_strategy(&body));
783            if let Some(key_b64) = body["group_key"].as_str() {
784                match from_base64(key_b64) {
785                    Some(key) => {
786                        if let Err(e) = dest.load_private_key(key) {
787                            return HttpResponse::bad_request(&format!("Invalid group key: {}", e));
788                        }
789                    }
790                    None => return HttpResponse::bad_request("Invalid base64 group_key"),
791                }
792            } else {
793                dest.create_keys();
794            }
795            (dest, None)
796        }
797        _ => return HttpResponse::bad_request("type must be 'single', 'plain', or 'group'"),
798    };
799
800    with_node(node, |n| {
801        match n.register_destination_with_proof(&dest, signing_key) {
802            Ok(()) => {
803                // For inbound single dests, also register with link manager
804                // so incoming LINKREQUEST packets are accepted.
805                if dest_type_str == "single" && body["direction"].as_str().unwrap_or("in") == "in" {
806                    if let (Some(prv), Some(pubk)) = (identity_prv_key, identity_pub_key) {
807                        let mut sig_prv = [0u8; 32];
808                        sig_prv.copy_from_slice(&prv[32..64]);
809                        let mut sig_pub = [0u8; 32];
810                        sig_pub.copy_from_slice(&pubk[32..64]);
811                        let _ = n.register_link_destination(dest.hash.0, sig_prv, sig_pub, 0);
812                    }
813                }
814
815                let full_name = format_dest_name(app_name, &aspects);
816                let hash_hex = to_hex(&dest.hash.0);
817                let group_key_b64 = dest.get_private_key().map(to_base64);
818                let mut s = crate::state::write_state(state);
819                s.destinations.insert(
820                    dest.hash.0,
821                    DestinationEntry {
822                        destination: dest,
823                        full_name: full_name.clone(),
824                    },
825                );
826                let mut resp = json!({
827                    "dest_hash": hash_hex,
828                    "name": full_name,
829                    "type": dest_type_str,
830                });
831                if let Some(gk) = group_key_b64 {
832                    resp["group_key"] = Value::String(gk);
833                }
834                HttpResponse::created(resp)
835            }
836            Err(_) => HttpResponse::internal_error("Failed to register destination"),
837        }
838    })
839}
840
841fn handle_post_announce(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
842    let body = match parse_json_body(req) {
843        Ok(v) => v,
844        Err(r) => return r,
845    };
846
847    let dh_str = match body["dest_hash"].as_str() {
848        Some(s) => s,
849        None => return HttpResponse::bad_request("Missing dest_hash"),
850    };
851    let dh: [u8; 16] = match hex_to_array(dh_str) {
852        Some(h) => h,
853        None => return HttpResponse::bad_request("Invalid dest_hash"),
854    };
855
856    let app_data: Option<Vec<u8>> = body["app_data"].as_str().and_then(from_base64);
857
858    let (dest, identity) = {
859        let s = read_state(state);
860        let dest = match s.destinations.get(&dh) {
861            Some(entry) => entry.destination.clone(),
862            None => return HttpResponse::bad_request("Destination not registered via API"),
863        };
864        let identity = match s.identity.as_ref().and_then(|i| i.get_private_key()) {
865            Some(prv) => Identity::from_private_key(&prv),
866            None => return HttpResponse::internal_error("No identity loaded"),
867        };
868        (dest, identity)
869    };
870
871    with_active_node(node, |n| {
872        match n.announce(&dest, &identity, app_data.as_deref()) {
873            Ok(()) => HttpResponse::ok(json!({"status": "announced", "dest_hash": dh_str})),
874            Err(_) => HttpResponse::internal_error("Announce failed"),
875        }
876    })
877}
878
879fn handle_post_send(req: &HttpRequest, node: &NodeHandle, state: &SharedState) -> HttpResponse {
880    let body = match parse_json_body(req) {
881        Ok(v) => v,
882        Err(r) => return r,
883    };
884
885    let dh_str = match body["dest_hash"].as_str() {
886        Some(s) => s,
887        None => return HttpResponse::bad_request("Missing dest_hash"),
888    };
889    let dh: [u8; 16] = match hex_to_array(dh_str) {
890        Some(h) => h,
891        None => return HttpResponse::bad_request("Invalid dest_hash"),
892    };
893    let data = match body["data"].as_str().and_then(from_base64) {
894        Some(d) => d,
895        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
896    };
897
898    let s = read_state(state);
899    let dest = match s.destinations.get(&dh) {
900        Some(entry) => entry.destination.clone(),
901        None => return HttpResponse::bad_request("Destination not registered via API"),
902    };
903    drop(s);
904
905    let max_len = match dest.dest_type {
906        rns_core::types::DestinationType::Plain => rns_core::constants::PLAIN_MDU,
907        rns_core::types::DestinationType::Single | rns_core::types::DestinationType::Group => {
908            rns_core::constants::ENCRYPTED_MDU
909        }
910    };
911    if data.len() > max_len {
912        return HttpResponse::bad_request(&format!(
913            "Payload too large for single-packet send: {} bytes > {} byte limit",
914            data.len(),
915            max_len
916        ));
917    }
918
919    with_active_node(node, |n| match n.send_packet(&dest, &data) {
920        Ok(ph) => HttpResponse::ok(json!({
921            "status": "sent",
922            "packet_hash": to_hex(&ph.0),
923        })),
924        Err(_) => HttpResponse::internal_error("Send failed"),
925    })
926}
927
928fn handle_post_link(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
929    let body = match parse_json_body(req) {
930        Ok(v) => v,
931        Err(r) => return r,
932    };
933
934    let dh_str = match body["dest_hash"].as_str() {
935        Some(s) => s,
936        None => return HttpResponse::bad_request("Missing dest_hash"),
937    };
938    let dh: [u8; 16] = match hex_to_array(dh_str) {
939        Some(h) => h,
940        None => return HttpResponse::bad_request("Invalid dest_hash"),
941    };
942
943    with_active_node(node, |n| {
944        // Recall identity to get signing public key
945        let recalled = match n.recall_identity(&DestHash(dh)) {
946            Ok(Some(ai)) => ai,
947            Ok(None) => return HttpResponse::bad_request("No recalled identity for dest_hash"),
948            Err(_) => return HttpResponse::internal_error("Query failed"),
949        };
950        // Extract Ed25519 public key (second 32 bytes of public_key)
951        let mut sig_pub = [0u8; 32];
952        sig_pub.copy_from_slice(&recalled.public_key[32..64]);
953
954        match n.create_link(dh, sig_pub) {
955            Ok(link_id) => HttpResponse::created(json!({
956                "link_id": to_hex(&link_id),
957            })),
958            Err(_) => HttpResponse::internal_error("Create link failed"),
959        }
960    })
961}
962
963fn handle_post_link_send(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
964    let body = match parse_json_body(req) {
965        Ok(v) => v,
966        Err(r) => return r,
967    };
968
969    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
970        Some(h) => h,
971        None => return HttpResponse::bad_request("Missing or invalid link_id"),
972    };
973    let data = match body["data"].as_str().and_then(from_base64) {
974        Some(d) => d,
975        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
976    };
977    let context = body["context"].as_u64().unwrap_or(0) as u8;
978
979    with_active_node(node, |n| match n.send_on_link(link_id, data, context) {
980        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
981        Err(_) => HttpResponse::internal_error("Send on link failed"),
982    })
983}
984
985fn handle_post_link_close(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
986    let body = match parse_json_body(req) {
987        Ok(v) => v,
988        Err(r) => return r,
989    };
990
991    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
992        Some(h) => h,
993        None => return HttpResponse::bad_request("Missing or invalid link_id"),
994    };
995
996    with_node(node, |n| match n.teardown_link(link_id) {
997        Ok(()) => HttpResponse::ok(json!({"status": "closed"})),
998        Err(_) => HttpResponse::internal_error("Teardown link failed"),
999    })
1000}
1001
1002fn handle_post_channel(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1003    let body = match parse_json_body(req) {
1004        Ok(v) => v,
1005        Err(r) => return r,
1006    };
1007
1008    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
1009        Some(h) => h,
1010        None => return HttpResponse::bad_request("Missing or invalid link_id"),
1011    };
1012    let msgtype = body["msgtype"].as_u64().unwrap_or(0) as u16;
1013    let payload = match body["payload"].as_str().and_then(from_base64) {
1014        Some(d) => d,
1015        None => return HttpResponse::bad_request("Missing or invalid base64 payload"),
1016    };
1017
1018    with_active_node(node, |n| {
1019        match n.send_channel_message(link_id, msgtype, payload) {
1020            Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
1021            Err(_) => HttpResponse::bad_request("Channel message failed"),
1022        }
1023    })
1024}
1025
1026fn handle_post_resource(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1027    let body = match parse_json_body(req) {
1028        Ok(v) => v,
1029        Err(r) => return r,
1030    };
1031
1032    let link_id: [u8; 16] = match body["link_id"].as_str().and_then(|s| hex_to_array(s)) {
1033        Some(h) => h,
1034        None => return HttpResponse::bad_request("Missing or invalid link_id"),
1035    };
1036    let data = match body["data"].as_str().and_then(from_base64) {
1037        Some(d) => d,
1038        None => return HttpResponse::bad_request("Missing or invalid base64 data"),
1039    };
1040    let metadata = body["metadata"].as_str().and_then(from_base64);
1041
1042    with_active_node(node, |n| match n.send_resource(link_id, data, metadata) {
1043        Ok(()) => HttpResponse::ok(json!({"status": "sent"})),
1044        Err(_) => HttpResponse::internal_error("Resource send failed"),
1045    })
1046}
1047
1048fn handle_post_path_request(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1049    let body = match parse_json_body(req) {
1050        Ok(v) => v,
1051        Err(r) => return r,
1052    };
1053
1054    let dh_str = match body["dest_hash"].as_str() {
1055        Some(s) => s,
1056        None => return HttpResponse::bad_request("Missing dest_hash"),
1057    };
1058    let dh: [u8; 16] = match hex_to_array(dh_str) {
1059        Some(h) => h,
1060        None => return HttpResponse::bad_request("Invalid dest_hash"),
1061    };
1062
1063    with_active_node(node, |n| match n.request_path(&DestHash(dh)) {
1064        Ok(()) => HttpResponse::ok(json!({"status": "requested"})),
1065        Err(_) => HttpResponse::internal_error("Path request failed"),
1066    })
1067}
1068
1069fn handle_post_direct_connect(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1070    let body = match parse_json_body(req) {
1071        Ok(v) => v,
1072        Err(r) => return r,
1073    };
1074
1075    let lid_str = match body["link_id"].as_str() {
1076        Some(s) => s,
1077        None => return HttpResponse::bad_request("Missing link_id"),
1078    };
1079    let link_id: [u8; 16] = match hex_to_array(lid_str) {
1080        Some(h) => h,
1081        None => return HttpResponse::bad_request("Invalid link_id"),
1082    };
1083
1084    with_active_node(node, |n| match n.propose_direct_connect(link_id) {
1085        Ok(()) => HttpResponse::ok(json!({"status": "proposed"})),
1086        Err(_) => HttpResponse::internal_error("Direct connect proposal failed"),
1087    })
1088}
1089
1090fn handle_post_clear_announce_queues(node: &NodeHandle) -> HttpResponse {
1091    with_node(node, |n| match n.query(QueryRequest::DropAnnounceQueues) {
1092        Ok(QueryResponse::DropAnnounceQueues) => HttpResponse::ok(json!({"status": "ok"})),
1093        _ => HttpResponse::internal_error("Query failed"),
1094    })
1095}
1096
1097// --- Backbone peer state handlers ---
1098
1099fn handle_backbone_peers(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1100    let params = parse_query(&req.path);
1101    let interface_name = params.get("interface").map(|s| s.to_string());
1102    with_node(node, |n| {
1103        match n.query(QueryRequest::BackbonePeerState { interface_name }) {
1104            Ok(QueryResponse::BackbonePeerState(entries)) => {
1105                let peers: Vec<Value> = entries
1106                    .iter()
1107                    .map(|e| {
1108                        json!({
1109                            "interface": e.interface_name,
1110                            "ip": e.peer_ip.to_string(),
1111                            "connected_count": e.connected_count,
1112                            "blacklisted_remaining_secs": e.blacklisted_remaining_secs,
1113                            "blacklist_reason": e.blacklist_reason,
1114                            "reject_count": e.reject_count,
1115                        })
1116                    })
1117                    .collect();
1118                HttpResponse::ok(json!({ "peers": peers }))
1119            }
1120            _ => HttpResponse::internal_error("Query failed"),
1121        }
1122    })
1123}
1124
1125fn handle_backbone_blacklist(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1126    let body: Value = match serde_json::from_slice(&req.body) {
1127        Ok(v) => v,
1128        Err(_) => return HttpResponse::bad_request("Invalid JSON body"),
1129    };
1130    let interface_name = match body.get("interface").and_then(|v| v.as_str()) {
1131        Some(s) => s.to_string(),
1132        None => return HttpResponse::bad_request("Missing 'interface' field"),
1133    };
1134    let ip = match body.get("ip").and_then(|v| v.as_str()) {
1135        Some(s) => match s.parse::<std::net::IpAddr>() {
1136            Ok(addr) => addr,
1137            Err(_) => return HttpResponse::bad_request("Invalid IP address"),
1138        },
1139        None => return HttpResponse::bad_request("Missing 'ip' field"),
1140    };
1141    let duration_secs = match body.get("duration_secs").and_then(|v| v.as_u64()) {
1142        Some(d) => d,
1143        None => return HttpResponse::bad_request("Missing 'duration_secs' field"),
1144    };
1145    let reason = body
1146        .get("reason")
1147        .and_then(|v| v.as_str())
1148        .unwrap_or("sentinel blacklist")
1149        .to_string();
1150    let penalty_level = body
1151        .get("penalty_level")
1152        .and_then(|v| v.as_u64())
1153        .unwrap_or(0)
1154        .min(u8::MAX as u64) as u8;
1155    with_node(node, |n| {
1156        match n.query(QueryRequest::BlacklistBackbonePeer {
1157            interface_name,
1158            peer_ip: ip,
1159            duration: std::time::Duration::from_secs(duration_secs),
1160            reason,
1161            penalty_level,
1162        }) {
1163            Ok(QueryResponse::BlacklistBackbonePeer(true)) => {
1164                HttpResponse::ok(json!({"status": "ok"}))
1165            }
1166            Ok(QueryResponse::BlacklistBackbonePeer(false)) => HttpResponse::not_found(),
1167            _ => HttpResponse::internal_error("Query failed"),
1168        }
1169    })
1170}
1171
1172// --- Hook handlers ---
1173
1174fn handle_list_hooks(node: &NodeHandle) -> HttpResponse {
1175    with_node(node, |n| match n.list_hooks() {
1176        Ok(hooks) => {
1177            let list: Vec<Value> = hooks
1178                .iter()
1179                .map(|h| {
1180                    json!({
1181                        "name": h.name,
1182                        "attach_point": h.attach_point,
1183                        "type": h.hook_type,
1184                        "priority": h.priority,
1185                        "enabled": h.enabled,
1186                        "consecutive_traps": h.consecutive_traps,
1187                    })
1188                })
1189                .collect();
1190            HttpResponse::ok(json!({"hooks": list}))
1191        }
1192        Err(_) => HttpResponse::internal_error("Query failed"),
1193    })
1194}
1195
1196/// Load a hook from a filesystem path.
1197///
1198/// The `path` field in the JSON body refers to a file on the **server's** local
1199/// filesystem. This means the CLI and the HTTP server must have access to the
1200/// same filesystem for the path to resolve correctly.
1201fn handle_load_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1202    let body = match parse_json_body(req) {
1203        Ok(v) => v,
1204        Err(r) => return r,
1205    };
1206
1207    let name = match body["name"].as_str() {
1208        Some(s) => s.to_string(),
1209        None => return HttpResponse::bad_request("Missing name"),
1210    };
1211    let path = match body["path"].as_str() {
1212        Some(s) => s,
1213        None => return HttpResponse::bad_request("Missing path"),
1214    };
1215    let attach_point = match body["attach_point"].as_str() {
1216        Some(s) => s.to_string(),
1217        None => return HttpResponse::bad_request("Missing attach_point"),
1218    };
1219    let priority = body["priority"].as_i64().unwrap_or(0) as i32;
1220    let hook_type = body["type"]
1221        .as_str()
1222        .unwrap_or(DEFAULT_HOOK_TYPE)
1223        .to_string();
1224
1225    if hook_type == "wasm" {
1226        let wasm_bytes = match std::fs::read(path) {
1227            Ok(b) => b,
1228            Err(e) => {
1229                return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e));
1230            }
1231        };
1232
1233        with_node(node, |n| {
1234            match n.load_hook(name, wasm_bytes, attach_point, priority) {
1235                Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1236                Ok(Err(e)) => HttpResponse::bad_request(&e),
1237                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1238            }
1239        })
1240    } else if hook_type == "builtin" {
1241        let builtin_id = body["builtin_id"]
1242            .as_str()
1243            .or_else(|| body["id"].as_str())
1244            .unwrap_or(path)
1245            .to_string();
1246        with_node(node, |n| {
1247            match n.load_builtin_hook(name, builtin_id, attach_point, priority) {
1248                Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1249                Ok(Err(e)) => HttpResponse::bad_request(&e),
1250                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1251            }
1252        })
1253    } else {
1254        with_node(node, |n| {
1255            match n.load_hook_file(name, path.to_string(), hook_type, attach_point, priority) {
1256                Ok(Ok(())) => HttpResponse::ok(json!({"status": "loaded"})),
1257                Ok(Err(e)) => HttpResponse::bad_request(&e),
1258                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1259            }
1260        })
1261    }
1262}
1263
1264fn handle_unload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1265    let body = match parse_json_body(req) {
1266        Ok(v) => v,
1267        Err(r) => return r,
1268    };
1269
1270    let name = match body["name"].as_str() {
1271        Some(s) => s.to_string(),
1272        None => return HttpResponse::bad_request("Missing name"),
1273    };
1274    let attach_point = match body["attach_point"].as_str() {
1275        Some(s) => s.to_string(),
1276        None => return HttpResponse::bad_request("Missing attach_point"),
1277    };
1278
1279    with_node(node, |n| match n.unload_hook(name, attach_point) {
1280        Ok(Ok(())) => HttpResponse::ok(json!({"status": "unloaded"})),
1281        Ok(Err(e)) => HttpResponse::bad_request(&e),
1282        Err(_) => HttpResponse::internal_error("Driver unavailable"),
1283    })
1284}
1285
1286fn handle_reload_hook(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1287    let body = match parse_json_body(req) {
1288        Ok(v) => v,
1289        Err(r) => return r,
1290    };
1291
1292    let name = match body["name"].as_str() {
1293        Some(s) => s.to_string(),
1294        None => return HttpResponse::bad_request("Missing name"),
1295    };
1296    let path = match body["path"].as_str() {
1297        Some(s) => s,
1298        None => return HttpResponse::bad_request("Missing path"),
1299    };
1300    let attach_point = match body["attach_point"].as_str() {
1301        Some(s) => s.to_string(),
1302        None => return HttpResponse::bad_request("Missing attach_point"),
1303    };
1304    let hook_type = body["type"]
1305        .as_str()
1306        .unwrap_or(DEFAULT_HOOK_TYPE)
1307        .to_string();
1308
1309    if hook_type == "wasm" {
1310        let wasm_bytes = match std::fs::read(path) {
1311            Ok(b) => b,
1312            Err(e) => {
1313                return HttpResponse::bad_request(&format!("Failed to read WASM file: {}", e));
1314            }
1315        };
1316
1317        with_node(node, |n| {
1318            match n.reload_hook(name, attach_point, wasm_bytes) {
1319                Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1320                Ok(Err(e)) => HttpResponse::bad_request(&e),
1321                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1322            }
1323        })
1324    } else if hook_type == "builtin" {
1325        let builtin_id = body["builtin_id"]
1326            .as_str()
1327            .or_else(|| body["id"].as_str())
1328            .unwrap_or(path)
1329            .to_string();
1330        with_node(node, |n| {
1331            match n.reload_builtin_hook(name, attach_point, builtin_id) {
1332                Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1333                Ok(Err(e)) => HttpResponse::bad_request(&e),
1334                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1335            }
1336        })
1337    } else {
1338        with_node(node, |n| {
1339            match n.reload_hook_file(name, attach_point, path.to_string(), hook_type) {
1340                Ok(Ok(())) => HttpResponse::ok(json!({"status": "reloaded"})),
1341                Ok(Err(e)) => HttpResponse::bad_request(&e),
1342                Err(_) => HttpResponse::internal_error("Driver unavailable"),
1343            }
1344        })
1345    }
1346}
1347
1348fn handle_set_hook_enabled(req: &HttpRequest, node: &NodeHandle, enabled: bool) -> HttpResponse {
1349    let body = match parse_json_body(req) {
1350        Ok(v) => v,
1351        Err(r) => return r,
1352    };
1353
1354    let name = match body["name"].as_str() {
1355        Some(s) => s.to_string(),
1356        None => return HttpResponse::bad_request("Missing name"),
1357    };
1358    let attach_point = match body["attach_point"].as_str() {
1359        Some(s) => s.to_string(),
1360        None => return HttpResponse::bad_request("Missing attach_point"),
1361    };
1362
1363    with_node(node, |n| {
1364        match n.set_hook_enabled(name, attach_point, enabled) {
1365            Ok(Ok(())) => HttpResponse::ok(json!({
1366                "status": if enabled { "enabled" } else { "disabled" }
1367            })),
1368            Ok(Err(e)) => HttpResponse::bad_request(&e),
1369            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1370        }
1371    })
1372}
1373
1374fn handle_set_hook_priority(req: &HttpRequest, node: &NodeHandle) -> HttpResponse {
1375    let body = match parse_json_body(req) {
1376        Ok(v) => v,
1377        Err(r) => return r,
1378    };
1379
1380    let name = match body["name"].as_str() {
1381        Some(s) => s.to_string(),
1382        None => return HttpResponse::bad_request("Missing name"),
1383    };
1384    let attach_point = match body["attach_point"].as_str() {
1385        Some(s) => s.to_string(),
1386        None => return HttpResponse::bad_request("Missing attach_point"),
1387    };
1388    let priority = match body["priority"].as_i64() {
1389        Some(v) => v as i32,
1390        None => return HttpResponse::bad_request("Missing priority"),
1391    };
1392
1393    with_node(node, |n| {
1394        match n.set_hook_priority(name, attach_point, priority) {
1395            Ok(Ok(())) => HttpResponse::ok(json!({"status": "priority_updated"})),
1396            Ok(Err(e)) => HttpResponse::bad_request(&e),
1397            Err(_) => HttpResponse::internal_error("Driver unavailable"),
1398        }
1399    })
1400}
1401
1402// --- Helpers ---
1403
1404fn format_dest_name(app_name: &str, aspects: &[&str]) -> String {
1405    if aspects.is_empty() {
1406        app_name.to_string()
1407    } else {
1408        format!("{}.{}", app_name, aspects.join("."))
1409    }
1410}
1411
1412fn parse_proof_strategy(body: &Value) -> ProofStrategy {
1413    match body["proof_strategy"].as_str() {
1414        Some("all") => ProofStrategy::ProveAll,
1415        Some("app") => ProofStrategy::ProveApp,
1416        _ => ProofStrategy::ProveNone,
1417    }
1418}