Skip to main content

hatchery_node/
door.rs

1//! Local door for agents on this node: mail and task operations without ever touching a key.
2//!
3//! The node holds its identity key; agents (other users, other processes) do not. This loopback
4//! listener accepts `POST /door {"op","payload"}` from them, forwards the call to core over the
5//! node's restreamer uplink (Noise to core; C2 sees opaque bytes), which applies the status policy. A node that is neutral, suspended or
6//! revoked simply gets "refused" back. The surface is exactly the `mail.*` and `task.*` operations.
7
8use hatchery_build::http;
9use hatchery_enroll::call::capability_for;
10use serde_json::{json, Value};
11use tokio::net::TcpListener;
12
13/// Process-wide door listen address (for harness MCP env injection).
14static DOOR_LISTEN: std::sync::OnceLock<std::net::SocketAddr> = std::sync::OnceLock::new();
15
16/// Loopback door address if the node started `--door-listen`, else None.
17pub fn door_listen_addr() -> Option<std::net::SocketAddr> {
18    DOOR_LISTEN.get().copied()
19}
20
21
22/// `session.wake` (handled here, never forwarded): `{"token","nick","from","event_id","body"}`,
23/// the letter event m4a's `M4A_LETTER_HOOK` emits plus the node's wake token.
24pub async fn serve(listener: TcpListener, calls: crate::uplink_calls::NodeCalls, wake: Option<crate::server::node_wake::NodeWake>) {
25    if let Ok(addr) = listener.local_addr() {
26        let _ = DOOR_LISTEN.set(addr);
27    }
28    loop {
29        let Ok((mut stream, peer)) = listener.accept().await else { continue };
30        if !peer.ip().is_loopback() {
31            continue;
32        }
33        let (calls, wake) = (calls.clone(), wake.clone());
34        tokio::spawn(async move {
35            let Ok(Ok((method, path, _, body))) = tokio::time::timeout(std::time::Duration::from_secs(5), http::read_request(&mut stream, 64 * 1024)).await else {
36                let _ = http::respond(&mut stream, 400, br#"{"error":"bad request"}"#).await;
37                return;
38            };
39            if method != "POST" || path != "/door" {
40                let _ = http::respond(&mut stream, 404, br#"{"error":"not found"}"#).await;
41                return;
42            }
43            let Ok(request) = serde_json::from_slice::<Value>(&body) else {
44                let _ = http::respond(&mut stream, 400, br#"{"error":"malformed"}"#).await;
45                return;
46            };
47            let op = request.get("op").and_then(Value::as_str).unwrap_or_default();
48            if op.starts_with("local_server.") {
49                #[cfg(feature = "local-server")]
50                {
51                    let payload = request.get("payload").cloned().unwrap_or_else(|| json!({}));
52                    let (status, reply) = match crate::local_server::handle_op(op, &payload).await {
53                        Ok(v) => (200, v.to_string()),
54                        Err(e) => (400, json!({"error": e}).to_string()),
55                    };
56                    let _ = http::respond(&mut stream, status, reply.as_bytes()).await;
57                    return;
58                }
59                #[cfg(not(feature = "local-server"))]
60                {
61                    let _ = http::respond(&mut stream, 404, br#"{"error":"local-server feature off"}"#).await;
62                    return;
63                }
64            }
65            if op == "session.wake" {
66                let p = request.get("payload").cloned().unwrap_or_else(|| json!({}));
67                let field = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or_default().to_owned();
68                let (status, reply) = match &wake {
69                    None => (404, json!({"error":"letter wake is off on this node"})),
70                    Some(w) if !w.authorized(&field("token")) => (403, json!({"error":"refused"})),
71                    Some(w) => {
72                        let letter = crate::server::node_wake::NodeLetter { nick: field("nick"), from: field("from"), event_id: field("event_id"), body: field("body") };
73                        match w.wake(letter).await {
74                            Ok(done) => (200, json!({"ok": done})),
75                            Err(why) => (409, json!({"error": why})),
76                        }
77                    }
78                };
79                let _ = http::respond(&mut stream, status, reply.to_string().as_bytes()).await;
80                return;
81            }
82            if capability_for(op).is_none() {
83                let _ = http::respond(&mut stream, 404, br#"{"error":"unknown operation"}"#).await;
84                return;
85            }
86            let payload = request.get("payload").cloned().unwrap_or_else(|| json!({}));
87            let (status, reply) = match calls.call(op, &payload).await {
88                Ok(value) => (200, value.to_string()),
89                Err(crate::uplink_calls::CallError::Refused) => (403, json!({"error":"refused"}).to_string()),
90                Err(crate::uplink_calls::CallError::Unavailable(why)) => {
91                    tracing::warn!(%op, reason = %why, "door call unavailable");
92                    (502, json!({"error":"unavailable"}).to_string())
93                }
94            };
95            let _ = http::respond(&mut stream, status, reply.as_bytes()).await;
96        });
97    }
98}