Skip to main content

rns_ctl/
api.rs

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