Skip to main content

netget/server/devp2p/
mod.rs

1//! Ethereum devp2p node: RLPx over TCP, the base protocol, and `eth/68` / `eth/69`.
2//!
3//! NetGet owns the transport and the encoding — the ECIES handshake, frame encryption and MACs,
4//! Snappy, RLP, Hello, Ping/Pong — and computes every block hash. The model owns policy and
5//! content: which peers to admit and with what Status (`eth_status`), what to answer when a peer
6//! asks for headers, bodies or pooled transactions, and what to ask the peer for.
7//!
8//! A session runs: RLPx handshake and Hello (Rust, [`session::establish`]) → the peer's Status
9//! → `eth_status` to the model → this node's Status or a Disconnect → requests both ways until
10//! one side disconnects. The reader task answers the peer's Pings itself, so a slow model never
11//! makes go-ethereum's 30-second read deadline fire.
12//!
13//! ## Failing closed
14//!
15//! Disconnect is the only refusal devp2p has, and it is what every failure here sends:
16//!
17//! - `eth_status` with no usable answer (backend down, nothing, a refused Status) →
18//!   Disconnect `subprotocol_error`, `decision=fail_closed_*` / `model_silent`. An answer
19//!   without `eth_send_status` is a refusal: Disconnect `useless_peer`, `decision=model_reject`.
20//! - A peer request (headers, bodies, pooled transactions) still unanswered after the model's
21//!   turn → Disconnect `subprotocol_error`. NetGet never invents an answer: a header the model
22//!   did not write is never sent, and an empty answer is only ever the model's own.
23//! - A frame that fails its MAC, a message past the bounds, a malformed message →
24//!   Disconnect `breach_of_protocol`, `decision=protocol_error` /
25//!   `fail_closed_message_too_large`, before any model call.
26//!
27//! Every outcome writes one `decision=` line.
28pub mod actions;
29pub mod crypto;
30pub mod eth;
31pub mod rlp;
32pub mod rlpx;
33pub mod session;
34
35use crate::llm::action_helper::call_llm;
36use crate::llm::actions::protocol_trait::ActionResult;
37use crate::llm::ollama_client::OllamaClient;
38use crate::logging::emit::Log;
39use crate::protocol::{Event, EventType};
40use crate::server::connection::ConnectionId;
41use crate::state::app_state::AppState;
42use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
43use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
44use crate::state::ServerId;
45use actions::{CommonConfig, Devp2pProtocol};
46use anyhow::Result;
47use serde_json::{json, Value};
48use session::{Applied, Command, Inbound, PeerEvent, PeerEventKind, Session};
49use std::net::SocketAddr;
50use std::sync::Arc;
51use std::time::Duration;
52use tokio::sync::mpsc;
53
54/// How long a new connection has to send its first byte (the dialler speaks first in RLPx).
55const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(10);
56/// How long after Hello the peer has to send its Status.
57pub const STATUS_TIMEOUT: Duration = Duration::from_secs(10);
58/// Concurrent connections.
59const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
60/// How many model turns may follow from one another (a request whose answer prompts a request).
61pub const MAX_FOLLOWUP_DEPTH: u32 = 8;
62/// Decoded messages the reader may queue while a model call runs.
63const INBOUND_QUEUE: usize = 32;
64
65/// Disconnect reasons used here.
66const BREACH: u64 = 0x02;
67const USELESS_PEER: u64 = 0x03;
68const SUBPROTOCOL_ERROR: u64 = 0x10;
69
70pub struct Devp2pServer;
71
72struct Shared {
73    config: CommonConfig,
74    app_state: Arc<AppState>,
75    llm_client: OllamaClient,
76    status_tx: mpsc::UnboundedSender<String>,
77    server_id: ServerId,
78}
79
80/// The event type the model hears for a session event.
81pub fn event_type_for(kind: PeerEventKind) -> &'static EventType {
82    match kind {
83        PeerEventKind::HeadersRequested => &actions::HEADERS_REQUESTED_EVENT,
84        PeerEventKind::BodiesRequested => &actions::BODIES_REQUESTED_EVENT,
85        PeerEventKind::PooledRequested => &actions::POOLED_REQUESTED_EVENT,
86        PeerEventKind::Announced => &actions::ANNOUNCED_EVENT,
87        PeerEventKind::HeadersReceived => &actions::HEADERS_RECEIVED_EVENT,
88        PeerEventKind::BodiesReceived => &actions::BODIES_RECEIVED_EVENT,
89        PeerEventKind::PooledReceived => &actions::POOLED_RECEIVED_EVENT,
90        PeerEventKind::RequestTimedOut => &actions::REQUEST_TIMEOUT_EVENT,
91    }
92}
93
94impl Devp2pServer {
95    pub async fn spawn(
96        listen_addr: SocketAddr,
97        llm_client: OllamaClient,
98        app_state: Arc<AppState>,
99        status_tx: mpsc::UnboundedSender<String>,
100        server_id: ServerId,
101        config: CommonConfig,
102    ) -> Result<SocketAddr> {
103        let listener =
104            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
105        let local_addr = listener.local_addr()?;
106        let enode = eth::format_enode(&config.key.id(), local_addr);
107        Log::new(Some(&status_tx)).info(format!("devp2p node {enode}"));
108        // The E2E harness parses the port out of everything after the last "on ".
109        Log::new(Some(&status_tx)).info(format!("devp2p server listening on {local_addr}"));
110        let shared = Arc::new(Shared {
111            config,
112            app_state: app_state.clone(),
113            llm_client,
114            status_tx: status_tx.clone(),
115            server_id,
116        });
117        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
118        let registrar = app_state.clone();
119        let accept = tokio::spawn(async move {
120            loop {
121                // RLPx is dialler-speaks-first and nothing can be said before the handshake,
122                // so a peer over the cap is refused with a plain close.
123                match crate::server::accept_bounded::accept_bounded(
124                    &listener,
125                    &limiter,
126                    b"",
127                    "devp2p",
128                    Some(&status_tx),
129                )
130                .await
131                {
132                    Ok((stream, remote, permit)) => {
133                        let shared = shared.clone();
134                        let local = stream.local_addr().unwrap_or(local_addr);
135                        app_state
136                            .spawn_server_task(server_id, async move {
137                                let _permit = permit;
138                                Self::handle_connection(shared, stream, remote, local).await;
139                            })
140                            .await;
141                    }
142                    Err(e) => {
143                        Log::new(Some(&status_tx))
144                            .error(format!("devp2p accept failed, stopping loop: {e}"));
145                        break;
146                    }
147                }
148            }
149        });
150        registrar.register_server_task(server_id, accept).await;
151        Ok(local_addr)
152    }
153
154    async fn handle_connection(
155        shared: Arc<Shared>,
156        stream: tokio::net::TcpStream,
157        remote: SocketAddr,
158        local: SocketAddr,
159    ) {
160        let app_state = shared.app_state.clone();
161        let server_id = shared.server_id;
162        let log = Log::new(Some(&shared.status_tx));
163        let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
164        let _ = stream.set_nodelay(true);
165        let now = crate::utils::clock::Instant::now();
166        app_state
167            .add_connection_to_server(
168                server_id,
169                ConnectionState {
170                    id: connection_id,
171                    remote_addr: remote,
172                    local_addr: local,
173                    bytes_sent: 0,
174                    bytes_received: 0,
175                    packets_sent: 0,
176                    packets_received: 0,
177                    last_activity: now,
178                    status: ConnectionStatus::Active,
179                    status_changed_at: now,
180                    protocol_info: ProtocolConnectionInfo::empty(),
181                },
182            )
183            .await;
184        let _ = shared.status_tx.send("__UPDATE_UI__".to_string());
185
186        let (read_half, write_half) = tokio::io::split(stream);
187        let reader = crate::server::accept_bounded::IdleTimeoutReader::with_first(
188            read_half,
189            FIRST_BYTE_TIMEOUT,
190            session::IDLE_TIMEOUT,
191        );
192        match session::establish(
193            Box::new(reader),
194            Box::new(write_half),
195            &shared.config.key,
196            session::Role::Listen,
197            &shared.config.client_id,
198            local.port(),
199        )
200        .await
201        {
202            Ok(est) => {
203                log.info(format!(
204                    "devp2p {connection_id}: peer {} ({}) at {remote}, eth/{}",
205                    &hex::encode(est.remote_id)[..16],
206                    est.hello.client_id,
207                    est.eth_version
208                ));
209                let mut peer_rx = crate::server::peer_support::register_peer_channel(
210                    &app_state,
211                    server_id,
212                    connection_id.as_u32(),
213                )
214                .await;
215                let (tx, rx) = mpsc::channel(INBOUND_QUEUE);
216                let reader_task = app_state
217                    .spawn_server_task(
218                        server_id,
219                        session::read_loop(est.reader, est.read_half, est.writer.clone(), tx),
220                    )
221                    .await;
222                let writer = est.writer.clone();
223                let reason = run(
224                    &shared,
225                    Session::new(est.writer, est.eth_version),
226                    &est.hello,
227                    est.remote_id,
228                    remote,
229                    connection_id,
230                    rx,
231                    &mut peer_rx,
232                )
233                .await;
234                reader_task.abort();
235                session::close(&writer).await;
236                log.info(format!("devp2p {connection_id}: session ended: {reason}"));
237                app_state
238                    .remove_peer_handle(server_id, connection_id.as_u32())
239                    .await;
240            }
241            Err(session::EstablishError::Transport(e)) => {
242                log.warn(format!(
243                    "devp2p {connection_id} from {remote} refused decision=protocol_error: {e}"
244                ));
245            }
246            Err(session::EstablishError::PeerDisconnected(r)) => {
247                log.info(format!(
248                    "devp2p {connection_id} from {remote}: the peer disconnected during Hello ({})",
249                    eth::reason_name(r)
250                ));
251            }
252            Err(e @ session::EstablishError::Refused { .. }) => {
253                log.warn(format!(
254                    "devp2p {connection_id} from {remote} decision=protocol_reject: {e}"
255                ));
256            }
257        }
258        app_state
259            .remove_connection_from_server(server_id, connection_id)
260            .await;
261        let _ = shared.status_tx.send("__UPDATE_UI__".to_string());
262    }
263}
264
265/// The session after Hello: Status, then events until it ends. Returns why it ended.
266#[allow(clippy::too_many_arguments)]
267async fn run(
268    shared: &Shared,
269    mut s: Session,
270    hello: &eth::Hello,
271    remote_id: [u8; 64],
272    remote: SocketAddr,
273    id: ConnectionId,
274    mut rx: mpsc::Receiver<Inbound>,
275    peer_rx: &mut mpsc::Receiver<ClientCommand>,
276) -> String {
277    let log = Log::new(Some(&shared.status_tx));
278    // The peer's Status comes first: nothing else is valid before it.
279    let status = match tokio::time::timeout(STATUS_TIMEOUT, rx.recv()).await {
280        Err(_) => {
281            log.warn(format!(
282                "devp2p {id}: no Status within {}s decision=fail_closed_status_timeout",
283                STATUS_TIMEOUT.as_secs()
284            ));
285            session::disconnect(&s.writer, 0x0b).await;
286            return "no Status".into();
287        }
288        Ok(None) => return "the reader stopped".into(),
289        Ok(Some(Inbound::Ended(end))) => return ended(&log, id, end),
290        Ok(Some(Inbound::Message { code, payload })) => {
291            if code == eth::code::DISCONNECT {
292                return format!(
293                    "the peer disconnected: {}",
294                    eth::reason_name(eth::decode_disconnect(&payload))
295                );
296            }
297            match s.take_status(code, &payload) {
298                Ok(Some(status)) => status,
299                Ok(None) => {
300                    log.warn(format!(
301                        "devp2p {id}: message {code:#04x} before Status decision=protocol_error"
302                    ));
303                    session::disconnect(&s.writer, BREACH).await;
304                    return "no Status".into();
305                }
306                Err(e) => {
307                    log.warn(format!(
308                        "devp2p {id}: bad Status decision=protocol_error: {e:#}"
309                    ));
310                    session::disconnect(&s.writer, BREACH).await;
311                    return format!("bad Status: {e:#}");
312                }
313            }
314        }
315    };
316    let event = Event::new(
317        &actions::STATUS_EVENT,
318        json!({
319            "remote": remote.to_string(),
320            "client_id": hello.client_id,
321            "node_id": hex::encode(remote_id),
322            "capabilities": hello.caps_strings(),
323            "eth_version": s.eth_version,
324            "peer_status": status.to_json(),
325            "configured": shared.config.defaults.to_json(),
326        }),
327    );
328    let decision = consult(shared, id, &event).await;
329    if decision.no_answer {
330        session::disconnect(&s.writer, SUBPROTOCOL_ERROR).await;
331        return "no usable answer to eth_status".into();
332    }
333    let admits = decision
334        .commands
335        .iter()
336        .any(|c| matches!(c, Command::SendStatus(_)));
337    let refuses = decision
338        .commands
339        .iter()
340        .any(|c| matches!(c, Command::Disconnect(_)));
341    if !admits && !refuses {
342        log.info(format!(
343            "devp2p {id}: eth_status answered without eth_send_status decision=model_reject"
344        ));
345        session::disconnect(&s.writer, USELESS_PEER).await;
346        return "not admitted".into();
347    }
348    if let Some(reason) = carry_out(shared, &mut s, id, decision.commands, None, 0).await {
349        return reason;
350    }
351    if !s.ready() {
352        session::disconnect(&s.writer, SUBPROTOCOL_ERROR).await;
353        return "the Status could not be sent".into();
354    }
355    if let (Some(ours), Some(theirs)) = (&s.our_status, &s.peer_status) {
356        if let Err(why) = session::compatible(ours, theirs) {
357            log.warn(format!(
358                "devp2p {id}: this node's Status is not the peer's chain ({why}); the peer will likely disconnect"
359            ));
360        }
361    }
362
363    let mut ping = tokio::time::interval(session::PING_INTERVAL);
364    ping.tick().await;
365    let mut peer_open = true;
366    loop {
367        tokio::select! {
368            m = rx.recv() => match m {
369                None => return "the reader stopped".into(),
370                Some(Inbound::Ended(end)) => return ended(&log, id, end),
371                Some(Inbound::Message { code, payload }) => {
372                    shared.app_state
373                        .update_connection_stats(shared.server_id, id, Some(payload.len() as u64), None, Some(1), None)
374                        .await;
375                    if code == eth::code::DISCONNECT {
376                        return format!("the peer disconnected: {}", eth::reason_name(eth::decode_disconnect(&payload)));
377                    }
378                    match s.on_message(code, &payload).await {
379                        Ok(None) => {}
380                        Ok(Some(ev)) => {
381                            if let Some(reason) = handle(shared, &mut s, id, ev).await {
382                                return reason;
383                            }
384                        }
385                        Err(e) => {
386                            log.warn(format!("devp2p {id}: decision=protocol_error: {e:#}"));
387                            session::disconnect(&s.writer, BREACH).await;
388                            return format!("protocol error: {e:#}");
389                        }
390                    }
391                }
392            },
393            _ = ping.tick() => {
394                if session::send(&s.writer, eth::code::PING, &eth::empty_payload()).await.is_err() {
395                    return "the connection could not be written".into();
396                }
397                for ev in s.expire() {
398                    if let Some(reason) = handle(shared, &mut s, id, ev).await {
399                        return reason;
400                    }
401                }
402            }
403            c = peer_rx.recv(), if peer_open => {
404                // A closed channel would otherwise be ready forever and spin this loop.
405                let Some(c) = c else {
406                    peer_open = false;
407                    continue;
408                };
409                if let Some(reason) = inject(shared, &mut s, id, c).await {
410                    return reason;
411                }
412            }
413        }
414    }
415}
416
417fn ended(log: &Log, id: ConnectionId, end: session::End) -> String {
418    if let Some(tag) = end.decision {
419        log.warn(format!("devp2p {id}: {} decision={tag}", end.reason));
420    }
421    end.reason
422}
423
424/// Raise one session event and carry out the answer. Returns `Some(reason)` when the session
425/// is over.
426async fn handle(
427    shared: &Shared,
428    s: &mut Session,
429    id: ConnectionId,
430    ev: PeerEvent,
431) -> Option<String> {
432    let log = Log::new(Some(&shared.status_tx));
433    let event_type = event_type_for(ev.kind);
434    if ev.depth >= MAX_FOLLOWUP_DEPTH {
435        log.warn(format!(
436            "devp2p {id}: not asking the model about {}: {} turns deep decision=followup_depth",
437            event_type.id, ev.depth
438        ));
439        return None;
440    }
441    let event = Event::new(event_type, ev.data.clone());
442    let decision = consult(shared, id, &event).await;
443    if !decision.no_answer {
444        if let Some(reason) =
445            carry_out(shared, s, id, decision.commands, ev.request_id, ev.depth).await
446        {
447            return Some(reason);
448        }
449    }
450    if let Some(request_id) = ev.request_id {
451        if s.owes(request_id) {
452            // The peer asked and nothing true can be said: close rather than invent an answer.
453            log.warn(format!(
454                "devp2p {id}: {} {request_id} left unanswered decision=fail_closed_unanswered",
455                event_type.id
456            ));
457            session::disconnect(&s.writer, SUBPROTOCOL_ERROR).await;
458            return Some(format!("{} {request_id} unanswered", event_type.id));
459        }
460    }
461    None
462}
463
464/// Apply the model's commands in order. Returns `Some(reason)` when one ended the session.
465async fn carry_out(
466    shared: &Shared,
467    s: &mut Session,
468    id: ConnectionId,
469    commands: Vec<Command>,
470    answering: Option<u64>,
471    depth: u32,
472) -> Option<String> {
473    let log = Log::new(Some(&shared.status_tx));
474    for command in commands {
475        let before = s.bytes_sent;
476        match s
477            .apply(command, answering, depth, &shared.config.defaults)
478            .await
479        {
480            Ok(Applied::Disconnected(r)) => {
481                return Some(format!("disconnected ({})", eth::reason_name(r)))
482            }
483            Ok(Applied::StatusSent(st)) => {
484                log.info(format!("devp2p {id}: Status sent: {}", st.to_json()));
485            }
486            Ok(Applied::Sent(what)) => log.info(format!("devp2p {id}: {what}")),
487            Err(e) => {
488                log.error(format!(
489                    "devp2p {id}: action refused decision=fail_closed_bad_action: {e:#}"
490                ));
491            }
492        }
493        if s.bytes_sent > before {
494            shared
495                .app_state
496                .update_connection_stats(
497                    shared.server_id,
498                    id,
499                    None,
500                    Some(s.bytes_sent - before),
501                    None,
502                    Some(1),
503                )
504                .await;
505        }
506    }
507    None
508}
509
510/// An action injected for this peer from the dashboard or MCP.
511async fn inject(
512    shared: &Shared,
513    s: &mut Session,
514    id: ConnectionId,
515    command: ClientCommand,
516) -> Option<String> {
517    shared
518        .app_state
519        .record_access_log(
520            crate::state::AccessLogOwner::Server(shared.server_id.as_u32()),
521            "devp2p",
522            None,
523            "injected_action",
524            command.action.clone(),
525            vec![],
526        )
527        .await;
528    let (outcome, end) = match session::parse_command(&command.action) {
529        Err(e) => (
530            ClientSendOutcome::Rejected {
531                error: format!("{e:#}"),
532            },
533            None,
534        ),
535        Ok(cmd) => match s.apply(cmd, None, 0, &shared.config.defaults).await {
536            Ok(Applied::Disconnected(r)) => (
537                ClientSendOutcome::Disconnected,
538                Some(format!(
539                    "disconnected by the operator ({})",
540                    eth::reason_name(r)
541                )),
542            ),
543            Ok(Applied::StatusSent(st)) => (
544                ClientSendOutcome::Executed {
545                    detail: st.to_json().to_string(),
546                },
547                None,
548            ),
549            Ok(Applied::Sent(what)) => (ClientSendOutcome::Executed { detail: what }, None),
550            Err(e) => (
551                ClientSendOutcome::Rejected {
552                    error: format!("{e:#}"),
553                },
554                None,
555            ),
556        },
557    };
558    Log::new(Some(&shared.status_tx)).info(format!("devp2p {id}: injected action: {outcome:?}"));
559    let _ = command.reply_tx.send(Ok(outcome));
560    end
561}
562
563/// What the model said about one event.
564struct Decision {
565    commands: Vec<Command>,
566    /// No usable answer arrived; the reason is in the log.
567    no_answer: bool,
568}
569
570/// Ask the model and validate its answer. Writes the event's `decision=` line.
571async fn consult(shared: &Shared, id: ConnectionId, event: &Event) -> Decision {
572    let log = Log::new(Some(&shared.status_tx));
573    let name = &event.event_type.id;
574    let execution = match call_llm(
575        &shared.llm_client,
576        &shared.app_state,
577        shared.server_id,
578        Some(id),
579        event,
580        &Devp2pProtocol::new(),
581    )
582    .await
583    {
584        Ok(x) => x,
585        Err(e) => {
586            let tag = match crate::utils::wire_failure::WireFailure::classify(&e) {
587                crate::utils::wire_failure::WireFailure::Overloaded => "fail_closed_llm_overloaded",
588                crate::utils::wire_failure::WireFailure::Unavailable => "fail_closed_llm_error",
589            };
590            log.error(format!(
591                "devp2p {id}: {name} not answered decision={tag}: {e}"
592            ));
593            return Decision {
594                commands: Vec::new(),
595                no_answer: true,
596            };
597        }
598    };
599    for m in execution.messages {
600        let _ = shared.status_tx.send(m);
601    }
602    let mut actions: Vec<Value> = Vec::new();
603    let mut stack = execution.protocol_results;
604    stack.reverse();
605    while let Some(r) = stack.pop() {
606        match r {
607            ActionResult::Custom { data, .. } => actions.push(data),
608            ActionResult::Multiple(inner) => stack.extend(inner.into_iter().rev()),
609            _ => {}
610        }
611    }
612    let commands: Vec<Command> = actions
613        .iter()
614        .filter_map(|a| session::parse_command(a).ok())
615        .collect();
616    let refused: Vec<String> = execution
617        .failures
618        .iter()
619        .map(|f| format!("{}: {}", f.action, f.error))
620        .collect();
621    let no_answer = commands.is_empty();
622    if commands.is_empty() && refused.is_empty() {
623        log.warn(format!(
624            "devp2p {id}: {name} produced no action decision=model_silent"
625        ));
626    } else if commands.is_empty() {
627        log.error(format!(
628            "devp2p {id}: {name} produced no usable action decision=fail_closed_bad_action ({})",
629            refused.join("; ")
630        ));
631    } else if commands.iter().any(|c| matches!(c, Command::Disconnect(_))) {
632        log.info(format!("devp2p {id}: {name} decision=model_reject"));
633    } else {
634        if !refused.is_empty() {
635            log.warn(format!(
636                "devp2p {id}: {name}: some actions refused: {}",
637                refused.join("; ")
638            ));
639        }
640        log.info(format!("devp2p {id}: {name} decision=model_answer"));
641    }
642    Decision {
643        commands,
644        no_answer,
645    }
646}