Skip to main content

netget/server/bgp/
mod.rs

1//! BGP-4 server (RFC 4271).
2//!
3//! # Division of labour
4//!
5//! Rust owns the *session*: TCP framing, OPEN validation, capability and hold-time
6//! negotiation, the KEEPALIVE cadence, hold-timer expiry, and NOTIFICATION on error. None of
7//! that requires reasoning and all of it has to be right on a millisecond budget, so none of it
8//! is delegated to the model.
9//!
10//! The LLM owns the *content*: whether to peer with this neighbour at all, and which routes to
11//! announce or withdraw. That is why there is no RIB here and why one is not planned — the root
12//! CLAUDE.md forbids protocols from implementing storage, and a RIB is storage. Route state, if
13//! a deployment wants any, belongs in the generic SQLite facility or in server memory, reached
14//! through actions.
15//!
16//! # Session flow
17//!
18//! ```text
19//! TCP accept                        -> Connect
20//! peer OPEN, validated + negotiated -> bgp_open event -> our OPEN + KEEPALIVE -> OpenConfirm
21//! peer KEEPALIVE                    -> Established -> bgp_established event
22//! peer UPDATE                       -> bgp_update event
23//! hold timer expires                -> NOTIFICATION 4/0, close
24//! ```
25//!
26//! Sending a KEEPALIVE immediately after our OPEN is the step RFC 4271 section 8.2.2 requires
27//! to reach OpenConfirm, and it was missing: the old implementation only ever sent a KEEPALIVE
28//! in reply to one, so a peer that waits for ours before sending its own would deadlock.
29//!
30//! # Concurrency
31//!
32//! The write half is owned by a dedicated task fed by an unbounded channel, so the keepalive
33//! ticker keeps the session alive while the read loop is blocked in an LLM call. Sharing a
34//! `TcpStream` behind a lock would have meant holding that lock across the LLM await.
35
36pub mod actions;
37pub mod wire;
38
39use anyhow::{anyhow, Result};
40use std::net::{Ipv4Addr, SocketAddr};
41use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
42use std::sync::Arc;
43use tokio::io::{AsyncReadExt, AsyncWriteExt};
44use tokio::sync::{mpsc, Notify};
45use tracing::{debug, error, info, trace, warn};
46
47use crate::llm::action_helper::call_llm;
48use crate::llm::actions::protocol_trait::ActionResult;
49use crate::llm::ollama_client::OllamaClient;
50use crate::logging::emit::Log;
51use crate::protocol::Event;
52use crate::server::BgpProtocol;
53use crate::state::app_state::AppState;
54use crate::state::server::BgpSessionState;
55use crate::utils::WireFailure;
56use actions::{
57    BGP_ESTABLISHED_EVENT, BGP_MESSAGE_INTENT, BGP_NOTIFICATION_EVENT, BGP_OPEN_EVENT,
58    BGP_UPDATE_EVENT,
59};
60use netgauze_bgp_pkt::{capabilities::BgpCapability, BgpMessage};
61
62/// Hold time used unless the peer proposes something shorter, or startup overrides it.
63/// RFC 4271 section 4.2 suggests 90; 180 is the near-universal vendor default.
64const DEFAULT_HOLD_TIME: u16 = 180;
65
66/// Below this the hold time is unusable (RFC 4271 section 6.2: 1 and 2 are rejected outright).
67const MIN_HOLD_TIME: u16 = 3;
68
69/// RFC 4271 section 8.2.2: on entering OpenSent the speaker sets its Hold Timer to "a large
70/// value", for which the RFC suggests 4 minutes. It exists precisely because the *negotiated*
71/// hold timer cannot start until an OPEN has been exchanged, so until then nothing bounds how
72/// long a peer may stay silent.
73///
74/// Without it a peer that completed the TCP handshake and then sent nothing — or sent a valid
75/// header and then stopped — parked `read_exact` forever, holding the connection, its task and
76/// its state entry for the life of the process. The negotiated timers were already enforced;
77/// this is the window before them.
78const OPEN_HOLD_TIME: std::time::Duration = std::time::Duration::from_secs(240);
79
80/// Concurrent sessions this speaker admits before it starts refusing.
81///
82/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`OPEN_HOLD_TIME`] bounds how
83/// long *one* silent peer holds a slot and it is four minutes by RFC 4271 §8.2.2's own
84/// recommendation, so without a cap a stranger connecting once a second pins two hundred and
85/// forty sockets before the first becomes eligible to close — and every one of those costs a
86/// session task, a writer task, a keepalive timer and an `AppState` row, all of it before a
87/// single OPEN has been validated.
88///
89/// The house default is *generous* for BGP rather than tight, and that is deliberate. A real
90/// speaker peers with tens of neighbours, not hundreds, so a number like 64 would fit the
91/// traffic — and would hand an attacker a cheap way to lock the configured neighbours out by
92/// filling the table with half-open connections. A cap's job is to bound what a stranger can
93/// pin, not to be the smallest number that works, so it sits far above any plausible neighbour
94/// count while still being finite.
95const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
96
97/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: NOTIFICATION
98/// 6/8, Cease / **Out of Resources**.
99///
100/// **BGP is the rare protocol here whose refusal needs nothing from the peer.** A NOTIFICATION
101/// is a complete message on its own — marker, length, type, error code, subcode — with no
102/// field echoing anything the sender has received, so unlike `ipp`'s `server-error-busy` or a
103/// MongoDB reply there is nothing to fabricate. RFC 4271 §6 also allows it in *any* state: a
104/// speaker in Connect or Active that decides not to proceed sends one and closes, which is
105/// precisely this moment. So there is no reason to fall back to silence here.
106///
107/// **6/8 rather than 6/5, and the difference is what the peer does next.** RFC 4486 §4's
108/// `Connection Rejected` reads as the closer match — it is written about a speaker that
109/// disallows a connection *after* accepting the transport connection — but it means a
110/// permanent, policy refusal ("the peer is not configured locally"), and a conforming
111/// implementation treats it as such. A full connection table is transient, and §8's
112/// `Out of Resources` is the subcode whose defined handling is damping and retry. It is also
113/// what this session already sends when the model backend is saturated
114/// ([`SendOutcome::cease_subcode`] maps `WireFailure::Overloaded` to it), so a resource
115/// condition speaks with one voice whichever end of the connection it is discovered at.
116///
117/// Encoded through [`wire::encode_notification`] rather than written as 21 literal bytes, so
118/// the marker, length and type cannot drift from what the decoder on the other side of this
119/// module expects.
120static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
121    wire::encode_notification(wire::ERR_CEASE, wire::SUB_CEASE_OUT_OF_RESOURCES, &[])
122        .expect("a NOTIFICATION with no data is 21 bytes and cannot exceed the BGP maximum")
123});
124
125/// BGP server that handles routing protocol sessions under LLM policy control.
126pub struct BgpServer;
127
128impl BgpServer {
129    /// Spawn the BGP listener.
130    ///
131    /// Returns once the socket is bound, so `server_startup` can report a real failure rather
132    /// than parking the server in `Running` having bound nothing.
133    pub async fn spawn_with_llm_actions(
134        listen_addr: SocketAddr,
135        llm_client: OllamaClient,
136        app_state: Arc<AppState>,
137        status_tx: mpsc::UnboundedSender<String>,
138        server_id: crate::state::ServerId,
139        startup_params: Option<crate::protocol::StartupParams>,
140    ) -> Result<SocketAddr> {
141        let listener =
142            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
143        let local_addr = listener.local_addr()?;
144
145        let config = BgpConfig::from_params(startup_params.as_ref())?;
146        Log::new(Some(&status_tx)).info(format!(
147            "BGP server listening on {} as AS{} router-id {} hold {}s",
148            local_addr, config.local_as, config.router_id, config.hold_time
149        ));
150
151        let protocol = Arc::new(BgpProtocol::new());
152
153        let task_registrar = app_state.clone();
154        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
155        let accept_handle = tokio::spawn(async move {
156            loop {
157                match crate::server::accept_bounded::accept_bounded(
158                    &listener,
159                    &limiter,
160                    &CONNECTION_CAP_REFUSAL,
161                    "BGP",
162                    Some(&status_tx),
163                )
164                .await
165                {
166                    Ok((stream, remote_addr, permit)) => {
167                        let connection_id = crate::server::connection::ConnectionId::new(
168                            app_state.get_next_unified_id().await,
169                        );
170                        Log::new(Some(&status_tx)).info(format!(
171                            "BGP connection {} from {}",
172                            connection_id, remote_addr
173                        ));
174
175                        register_connection(&app_state, server_id, connection_id, remote_addr)
176                            .await;
177                        let _ = status_tx.send("__UPDATE_UI__".to_string());
178
179                        let llm_clone = llm_client.clone();
180                        let state_clone = app_state.clone();
181                        let status_clone = status_tx.clone();
182                        let protocol_clone = protocol.clone();
183                        let config_clone = config.clone();
184
185                        let session_registrar = app_state.clone();
186                        let session_handle = tokio::spawn(async move {
187                            // Released when this task ends, and this task is the whole of the
188                            // session even though `run_session` spawns a writer and a
189                            // keepalive timer: its only exit path aborts the timer, drops the
190                            // last sender and then `.await`s the writer, so nothing outlives
191                            // this future and `MAX_CONNECTIONS` caps live sessions rather than
192                            // accepts.
193                            let _permit = permit;
194                            if let Err(e) = run_session(
195                                stream,
196                                connection_id,
197                                server_id,
198                                remote_addr,
199                                llm_clone,
200                                state_clone.clone(),
201                                status_clone.clone(),
202                                protocol_clone,
203                                config_clone,
204                            )
205                            .await
206                            {
207                                Log::new(Some(&status_clone))
208                                    .error(format!("BGP session error: {}", e));
209                            }
210
211                            state_clone
212                                .close_connection_on_server(server_id, connection_id)
213                                .await;
214                            Log::new(Some(&status_clone))
215                                .info(format!("BGP connection {} closed", connection_id));
216                            let _ = status_clone.send("__UPDATE_UI__".to_string());
217                        });
218                        // Aborting the accept loop closes the listener but does nothing to
219                        // sessions already running: each owns its own socket and keeps sending
220                        // keepalives (and spending LLM budget) after `stop_server` reported the
221                        // server gone. Registering the session handle is what makes the stop
222                        // actually stop. `register_server_task` prunes finished handles on every
223                        // call, so a long-lived listener does not accumulate them.
224                        session_registrar
225                            .register_server_task(server_id, session_handle)
226                            .await;
227                    }
228                    Err(e) => {
229                        Log::new(Some(&status_tx))
230                            .error(format!("Failed to accept BGP connection: {}", e));
231                        break;
232                    }
233                }
234            }
235        });
236
237        // Register the accept loop so stop_server can abort it and release the port.
238        task_registrar
239            .register_server_task(server_id, accept_handle)
240            .await;
241
242        Ok(local_addr)
243    }
244}
245
246/// Operator-supplied identity for this speaker.
247#[derive(Debug, Clone)]
248pub struct BgpConfig {
249    pub local_as: u32,
250    pub router_id: Ipv4Addr,
251    pub hold_time: u16,
252}
253
254impl BgpConfig {
255    /// Validate startup parameters up front.
256    ///
257    /// Every value here ends up on the wire, so a bad one is rejected at startup with a message
258    /// naming the parameter rather than silently becoming a different, valid-looking value. The
259    /// previous code accepted any `as_number` and then truncated it to 16 bits when writing the
260    /// OPEN, so AS 4200000000 became AS 60416 with no diagnostic.
261    fn from_params(params: Option<&crate::protocol::StartupParams>) -> Result<Self> {
262        let Some(params) = params else {
263            return Ok(Self {
264                local_as: 65000,
265                router_id: Ipv4Addr::new(192, 168, 1, 1),
266                hold_time: DEFAULT_HOLD_TIME,
267            });
268        };
269
270        let local_as = params.get_optional_u32("as_number")?.unwrap_or(65000);
271        if local_as == 0 {
272            return Err(anyhow!("as_number must be between 1 and 4294967295"));
273        }
274
275        let router_id = match params.get_optional_string("router_id")? {
276            Some(s) => s.parse::<Ipv4Addr>().map_err(|_| {
277                anyhow!("router_id must be an IPv4 address in dotted-quad form, got {s:?}")
278            })?,
279            None => Ipv4Addr::new(192, 168, 1, 1),
280        };
281        if router_id.is_unspecified() {
282            return Err(anyhow!(
283                "router_id must not be 0.0.0.0 (RFC 4271 requires a valid BGP Identifier)"
284            ));
285        }
286
287        let hold_time = match params.get_optional_u64("hold_time")? {
288            Some(v) => {
289                let v = u16::try_from(v)
290                    .map_err(|_| anyhow!("hold_time must be between 0 and 65535, got {v}"))?;
291                if v != 0 && v < MIN_HOLD_TIME {
292                    return Err(anyhow!(
293                        "hold_time must be 0 (timers disabled) or at least {MIN_HOLD_TIME} seconds"
294                    ));
295                }
296                v
297            }
298            None => DEFAULT_HOLD_TIME,
299        };
300
301        Ok(Self {
302            local_as,
303            router_id,
304            hold_time,
305        })
306    }
307}
308
309async fn register_connection(
310    app_state: &AppState,
311    server_id: crate::state::ServerId,
312    connection_id: crate::server::connection::ConnectionId,
313    remote_addr: SocketAddr,
314) {
315    use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
316    let now = crate::utils::clock::Instant::now();
317    app_state
318        .add_connection_to_server(
319            server_id,
320            ConnectionState {
321                id: connection_id,
322                remote_addr,
323                local_addr: remote_addr,
324                bytes_sent: 0,
325                bytes_received: 0,
326                packets_sent: 0,
327                packets_received: 0,
328                last_activity: now,
329                status: ConnectionStatus::Active,
330                status_changed_at: now,
331                protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
332                    "bgp_state": "Connect",
333                })),
334            },
335        )
336        .await;
337}
338
339/// One BGP peering session.
340struct BgpSession {
341    connection_id: crate::server::connection::ConnectionId,
342    server_id: crate::state::ServerId,
343    remote_addr: SocketAddr,
344    llm_client: OllamaClient,
345    app_state: Arc<AppState>,
346    status_tx: mpsc::UnboundedSender<String>,
347    protocol: Arc<BgpProtocol>,
348    config: BgpConfig,
349
350    state: BgpSessionState,
351    /// The peer's real ASN: its four-octet-AS capability if it sent one, else the OPEN field.
352    peer_as: Option<u32>,
353    peer_router_id: Option<Ipv4Addr>,
354    /// Whether four-octet AS was negotiated. Decides how inbound AS_PATH is read and how
355    /// outbound AS_PATH must be written.
356    peer_asn4: bool,
357    /// Negotiated hold time: min(ours, theirs). Zero disables both timers.
358    hold_time: u16,
359
360    out_tx: mpsc::UnboundedSender<Vec<u8>>,
361    /// `peer_asn4`, mirrored for the peer command task so an injected UPDATE is encoded at the
362    /// width this session negotiated.
363    peer_asn4_shared: Arc<AtomicBool>,
364    /// Seconds since session start at which the last message arrived, for hold-timer expiry.
365    last_received: Arc<AtomicU64>,
366    started: crate::utils::clock::Instant,
367}
368
369/// Outcome of reading one framed message off the wire.
370enum Incoming {
371    Message(BgpMessage),
372    /// Header was structurally invalid; the session must send this NOTIFICATION and close.
373    HeaderError(wire::HeaderError),
374    /// Body did not parse. The header's type octet travels with it because RFC 4271 forbids
375    /// answering a NOTIFICATION with another NOTIFICATION, even a malformed one.
376    DecodeError(wire::DecodeError, u8),
377    Eof,
378}
379
380#[allow(clippy::too_many_arguments)]
381async fn run_session(
382    stream: tokio::net::TcpStream,
383    connection_id: crate::server::connection::ConnectionId,
384    server_id: crate::state::ServerId,
385    remote_addr: SocketAddr,
386    llm_client: OllamaClient,
387    app_state: Arc<AppState>,
388    status_tx: mpsc::UnboundedSender<String>,
389    protocol: Arc<BgpProtocol>,
390    config: BgpConfig,
391) -> Result<()> {
392    // Never clone a TcpStream: split it, give the write half to one task, and let everything
393    // that needs to send push bytes through a channel.
394    let (mut reader, mut writer) = tokio::io::split(stream);
395    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
396
397    // Every byte that reaches the wire passes through here, so this is the one place the
398    // outbound counters (and `last_activity`) are kept — keepalives, NOTIFICATIONs and injected
399    // messages included.
400    let stats_state = app_state.clone();
401    let writer_task = tokio::spawn(async move {
402        while let Some(bytes) = out_rx.recv().await {
403            if writer.write_all(&bytes).await.is_err() {
404                break;
405            }
406            if writer.flush().await.is_err() {
407                break;
408            }
409            stats_state
410                .update_connection_stats(
411                    server_id,
412                    connection_id,
413                    None,
414                    Some(bytes.len() as u64),
415                    None,
416                    Some(1),
417                )
418                .await;
419        }
420        let _ = writer.shutdown().await;
421    });
422
423    let shutdown = Arc::new(Shutdown::default());
424    let peer_asn4_shared = Arc::new(AtomicBool::new(false));
425
426    // Dashboard injection ("message this peer" / "disconnect this peer"). Registered before
427    // the first read so the operator can reach the session even while a manual rule parks the
428    // `bgp_open` event. BGP's wire verbs yield `ActionResult::Custom` intents rather than
429    // bytes, so the generic `peer_support::spawn_peer_command_task` cannot encode them; this
430    // task runs the same central executor and then the same `wire::encode_intent` the session
431    // uses, with the negotiated AS width, and pushes the result down the same write channel.
432    let peer_rx = crate::server::peer_support::register_peer_channel(
433        &app_state,
434        server_id,
435        connection_id.as_u32(),
436    )
437    .await;
438    let peer_task = spawn_peer_command_task(
439        peer_rx,
440        protocol.clone(),
441        app_state.clone(),
442        server_id,
443        connection_id,
444        out_tx.clone(),
445        peer_asn4_shared.clone(),
446        shutdown.clone(),
447        status_tx.clone(),
448    )
449    .await;
450
451    let mut session = BgpSession {
452        connection_id,
453        server_id,
454        remote_addr,
455        llm_client,
456        app_state,
457        status_tx,
458        protocol,
459        config,
460        state: BgpSessionState::Connect,
461        peer_as: None,
462        peer_router_id: None,
463        peer_asn4: false,
464        hold_time: 0,
465        out_tx,
466        peer_asn4_shared,
467        last_received: Arc::new(AtomicU64::new(0)),
468        started: crate::utils::clock::Instant::now(),
469    };
470
471    let mut timer_task: Option<tokio::task::AbortHandle> = None;
472    let result = session.run(&mut reader, &shutdown, &mut timer_task).await;
473
474    // Every exit path of the session passes through here, so the peer handle is removed exactly
475    // once. The peer task holds a write-channel sender, so it must go before the writer task is
476    // awaited below.
477    session
478        .app_state
479        .remove_peer_handle(server_id, connection_id.as_u32())
480        .await;
481    peer_task.abort();
482
483    // Drop the session (and with it the last non-timer sender) so the writer task drains what
484    // is queued — in particular a final NOTIFICATION — and then closes the socket.
485    if let Some(handle) = timer_task {
486        handle.abort();
487    }
488    drop(session);
489    let _ = writer_task.await;
490
491    result
492}
493
494impl BgpSession {
495    async fn run(
496        &mut self,
497        reader: &mut tokio::io::ReadHalf<tokio::net::TcpStream>,
498        shutdown: &Arc<Shutdown>,
499        timer_task: &mut Option<tokio::task::AbortHandle>,
500    ) -> Result<()> {
501        debug!("BGP session {} in Connect state", self.connection_id);
502
503        loop {
504            if shutdown.is_set() {
505                break;
506            }
507            // Before the OPEN exchange there is no negotiated hold timer, so this deadline is
508            // the only thing bounding a silent peer (RFC 4271 section 8.2.2). Afterwards
509            // `spawn_timers` owns the schedule and signals expiry through `shutdown`, which the
510            // first select arm turns into an exit even from inside a parked read.
511            let open_deadline =
512                matches!(self.state, BgpSessionState::Connect).then_some(OPEN_HOLD_TIME);
513
514            let (incoming, bytes_in) = tokio::select! {
515                biased;
516                _ = shutdown.notify.notified() => break,
517                result = with_deadline(open_deadline, read_message(reader, self.peer_asn4)) => {
518                    match result {
519                        Ok(incoming) => incoming,
520                        Err(DeadlineExpired) => {
521                            Log::new(Some(&self.status_tx)).warn(format!(
522                                "BGP peer {} sent no OPEN within {}s, closing (RFC 4271 8.2.2)",
523                                self.remote_addr,
524                                OPEN_HOLD_TIME.as_secs()
525                            ));
526                            self.notify(wire::ERR_HOLD_TIMER_EXPIRED, 0, &[]);
527                            break;
528                        }
529                    }
530                }
531            };
532            if bytes_in > 0 {
533                self.app_state
534                    .update_connection_stats(
535                        self.server_id,
536                        self.connection_id,
537                        Some(bytes_in as u64),
538                        None,
539                        Some(1),
540                        None,
541                    )
542                    .await;
543            }
544
545            match incoming {
546                Incoming::Eof => {
547                    debug!("BGP connection {} closed by peer", self.connection_id);
548                    break;
549                }
550                Incoming::HeaderError(e) => {
551                    Log::new(Some(&self.status_tx)).warn(format!(
552                        "BGP header rejected from {}: {:?}",
553                        self.remote_addr, e
554                    ));
555                    let (code, sub) = e.notify_code();
556                    self.notify(code, sub, &e.notify_data());
557                    break;
558                }
559                Incoming::DecodeError(e, msg_type) => {
560                    Log::new(Some(&self.status_tx)).warn(format!(
561                        "BGP message from {} failed to parse: {}",
562                        self.remote_addr, e
563                    ));
564                    if msg_type != wire::MSG_NOTIFICATION {
565                        let (code, sub) = e.notify;
566                        self.notify(code, sub, &[]);
567                    }
568                    break;
569                }
570                Incoming::Message(msg) => {
571                    self.last_received
572                        .store(self.started.elapsed().as_secs(), Ordering::Relaxed);
573                    if !self.dispatch(msg, shutdown, timer_task).await? {
574                        break;
575                    }
576                }
577            }
578        }
579
580        Ok(())
581    }
582
583    /// Handle one decoded message. Returns `false` when the session must close.
584    async fn dispatch(
585        &mut self,
586        msg: BgpMessage,
587        shutdown: &Arc<Shutdown>,
588        timer_task: &mut Option<tokio::task::AbortHandle>,
589    ) -> Result<bool> {
590        match (self.state.clone(), msg) {
591            (BgpSessionState::Connect, BgpMessage::Open(open)) => {
592                self.on_open(open, shutdown, timer_task).await
593            }
594            // A NOTIFICATION is never answered, in any state (RFC 4271 section 4.5).
595            (BgpSessionState::Connect, BgpMessage::Notification(n)) => {
596                self.on_notification(n).await;
597                Ok(false)
598            }
599            // RFC 4271 section 6.6: anything else while waiting for an OPEN is an FSM error.
600            // Closing silently, as the old code did for unknown types, leaves the peer guessing.
601            (BgpSessionState::Connect, _) => {
602                self.notify(wire::ERR_FSM, wire::SUB_FSM_OPENSENT, &[]);
603                Ok(false)
604            }
605
606            (BgpSessionState::OpenConfirm, BgpMessage::KeepAlive) => {
607                self.on_established().await?;
608                Ok(true)
609            }
610            (BgpSessionState::OpenConfirm, BgpMessage::Notification(n)) => {
611                self.on_notification(n).await;
612                Ok(false)
613            }
614            (BgpSessionState::OpenConfirm, _) => {
615                self.notify(wire::ERR_FSM, wire::SUB_FSM_OPENCONFIRM, &[]);
616                Ok(false)
617            }
618
619            (BgpSessionState::Established, BgpMessage::KeepAlive) => {
620                // Deliberately no LLM call. A keepalive arrives every hold/3 seconds for the
621                // life of the session; consulting a model each time would spend a request every
622                // minute per peer to decide nothing. The hold timer has already been reset by
623                // the caller, which is the entire semantics of a KEEPALIVE.
624                trace!("BGP KEEPALIVE from {}", self.remote_addr);
625                Ok(true)
626            }
627            (BgpSessionState::Established, BgpMessage::Update(update)) => {
628                self.on_update(update).await?;
629                Ok(true)
630            }
631            (BgpSessionState::Established, BgpMessage::Notification(n)) => {
632                self.on_notification(n).await;
633                Ok(false)
634            }
635            (BgpSessionState::Established, BgpMessage::RouteRefresh(_)) => {
636                // NetGet does not advertise the route-refresh capability, so a conforming peer
637                // will not send this. If one does anyway, ignoring it is correct: there is no
638                // RIB to re-advertise from. Answering with a Bad Message Type NOTIFICATION, as
639                // the old code did for every type it did not recognise, would tear down a
640                // perfectly healthy session.
641                warn!(
642                    "BGP ROUTE-REFRESH from {} ignored: no RIB to re-advertise",
643                    self.remote_addr
644                );
645                Ok(true)
646            }
647            (BgpSessionState::Established, BgpMessage::Open(_)) => {
648                self.notify(wire::ERR_FSM, wire::SUB_FSM_ESTABLISHED, &[]);
649                Ok(false)
650            }
651
652            (_, BgpMessage::Notification(n)) => {
653                self.on_notification(n).await;
654                Ok(false)
655            }
656            (state, msg) => {
657                warn!(
658                    "BGP {:?} message in unexpected state {:?}",
659                    msg.get_type(),
660                    state
661                );
662                self.notify(wire::ERR_FSM, 0, &[]);
663                Ok(false)
664            }
665        }
666    }
667
668    /// Validate the peer's OPEN, negotiate, ask the model for a peering decision, reply.
669    async fn on_open(
670        &mut self,
671        open: netgauze_bgp_pkt::open::BgpOpenMessage,
672        shutdown: &Arc<Shutdown>,
673        timer_task: &mut Option<tokio::task::AbortHandle>,
674    ) -> Result<bool> {
675        // Version, hold time bounds and BGP Identifier validity are checked by the decoder;
676        // reaching here means they passed. What is left is what only we can judge.
677        let peer_as = open.my_asn4();
678        if peer_as == 0 {
679            Log::new(Some(&self.status_tx)).warn("BGP peer advertised AS 0, rejecting");
680            self.notify(wire::ERR_OPEN, wire::SUB_BAD_PEER_AS, &[]);
681            return Ok(false);
682        }
683        let peer_hold = open.hold_time();
684        if peer_hold != 0 && peer_hold < MIN_HOLD_TIME {
685            self.notify(wire::ERR_OPEN, wire::SUB_UNACCEPTABLE_HOLD_TIME, &[]);
686            return Ok(false);
687        }
688
689        self.peer_as = Some(peer_as);
690        self.peer_router_id = Some(open.bgp_id());
691        self.peer_asn4 = open
692            .capabilities()
693            .into_iter()
694            .any(|c| matches!(c, BgpCapability::FourOctetAs(_)));
695        self.peer_asn4_shared
696            .store(self.peer_asn4, Ordering::Relaxed);
697
698        // Provisional negotiation against our configured proposal, so the model can see what
699        // the session would settle on. It is recomputed below from the hold time actually put
700        // in our OPEN, which a handler is free to override.
701        self.hold_time = negotiate_hold(self.config.hold_time, peer_hold);
702
703        Log::new(Some(&self.status_tx)).info(format!(
704            "BGP OPEN from AS{} router-id {} hold {}s (asn4={}), negotiated hold {}s",
705            peer_as,
706            open.bgp_id(),
707            peer_hold,
708            self.peer_asn4,
709            self.hold_time
710        ));
711
712        let event = Event {
713            event_type: &BGP_OPEN_EVENT,
714            data: serde_json::json!({
715                "connection_id": self.connection_id.to_string(),
716                "peer_as": peer_as,
717                "peer_router_id": open.bgp_id().to_string(),
718                "peer_hold_time": peer_hold,
719                "peer_supports_four_octet_as": self.peer_asn4,
720                "peer_capabilities": wire::capabilities_to_json(&open),
721                "negotiated_hold_time": self.hold_time,
722                "local_as": self.config.local_as,
723                "local_router_id": self.config.router_id.to_string(),
724                "remote_addr": self.remote_addr.to_string(),
725            }),
726        };
727
728        let outcome = self.call_llm_and_send(&event).await;
729
730        match outcome {
731            // The model explicitly refused. RFC 4271 wants a NOTIFICATION, which it already
732            // sent, and the session ends. This path is structurally distinct from "the model
733            // said nothing" on purpose: silence must not be readable as refusal, and refusal
734            // must not be readable as silence.
735            SendOutcome::Refused => {
736                info!(
737                    "BGP bgp_open decision=model_reject peer={} AS{}",
738                    self.remote_addr, peer_as
739                );
740                Ok(false)
741            }
742            // The operator opted into a dynamic peering policy (that is the only way an LLM call
743            // happens here at all) and it could not be evaluated. Sending the configured OPEN
744            // would admit a neighbour the policy might have refused, which is the fail-open
745            // pattern the root CLAUDE.md calls the most dangerous in this codebase: a backend
746            // outage would silently become "peer with anyone". So refuse — and refuse with the
747            // subcode that tells the peer whether to come back.
748            SendOutcome::Failed(failure) => {
749                Log::new(Some(&self.status_tx)).warn(format!(
750                    "BGP refusing to peer with {} AS{}: peering policy could not be evaluated \
751                     (decision=fail_closed_llm_error, {})",
752                    self.remote_addr,
753                    peer_as,
754                    if failure.is_overloaded() {
755                        "resource"
756                    } else {
757                        "unavailable"
758                    }
759                ));
760                self.notify(wire::ERR_CEASE, SendOutcome::cease_subcode(failure), &[]);
761                Ok(false)
762            }
763            SendOutcome::SentOpen { hold_time } => {
764                // RFC 4271 section 4.2: the negotiated hold time is the smaller of the two
765                // *proposals*. Ours is whatever went into the OPEN we just sent, which is the
766                // handler's value and not necessarily the configured one — deriving the timers
767                // from the config instead would make NetGet keep a schedule its peer never
768                // agreed to.
769                self.hold_time = negotiate_hold(hold_time, peer_hold);
770                self.after_open_sent(shutdown, timer_task).await;
771                Ok(true)
772            }
773            SendOutcome::Nothing => {
774                // The policy ran and produced no OPEN — a `wait_for_more`, or a handler that
775                // returned nothing. This is *not* the backend-failure case, which is handled
776                // above; the distinction matters because peering is not an authorisation
777                // decision when the policy was actually consulted: the operator
778                // opened this port with this ASN, and a peer that already completed a TCP
779                // handshake gets the configured OPEN. Refusing here would mean an LLM outage
780                // silently drops every session it did not explicitly bless.
781                Log::new(Some(&self.status_tx)).warn(format!(
782                    "BGP no OPEN from handler for {} (decision=model_silent), sending \
783                     configured OPEN AS{}",
784                    self.remote_addr, self.config.local_as
785                ));
786                let bytes = wire::encode(wire::build_open(
787                    self.config.local_as,
788                    self.config.hold_time,
789                    self.config.router_id,
790                ))?;
791                self.send(bytes);
792                self.hold_time = negotiate_hold(self.config.hold_time, peer_hold);
793                self.after_open_sent(shutdown, timer_task).await;
794                Ok(true)
795            }
796        }
797    }
798
799    /// RFC 4271 section 8.2.2: having sent OPEN and received the peer's, send a KEEPALIVE and
800    /// enter OpenConfirm. Start the timers here, because from this point the peer is entitled
801    /// to expect keepalives at the negotiated cadence.
802    async fn after_open_sent(
803        &mut self,
804        shutdown: &Arc<Shutdown>,
805        timer_task: &mut Option<tokio::task::AbortHandle>,
806    ) {
807        self.send(wire::encode_keepalive());
808        self.state = BgpSessionState::OpenConfirm;
809        debug!("BGP session {} -> OpenConfirm", self.connection_id);
810
811        if self.hold_time > 0 && timer_task.is_none() {
812            *timer_task = Some(
813                spawn_timers(
814                    self.hold_time,
815                    self.out_tx.clone(),
816                    self.last_received.clone(),
817                    self.started,
818                    shutdown.clone(),
819                    self.status_tx.clone(),
820                    self.connection_id,
821                    self.app_state.clone(),
822                    self.server_id,
823                )
824                .await,
825            );
826        }
827    }
828
829    async fn on_established(&mut self) -> Result<()> {
830        self.state = BgpSessionState::Established;
831        let peer_as = self.peer_as.unwrap_or(0);
832        Log::new(Some(&self.status_tx)).info(format!(
833            "BGP session {} established with AS{}",
834            self.connection_id, peer_as
835        ));
836        self.set_connection_state("Established").await;
837
838        // The one moment worth a model call on the session path: the peer is up and will accept
839        // UPDATEs. Everything the model needs to answer with `send_bgp_update` is here.
840        let event = Event {
841            event_type: &BGP_ESTABLISHED_EVENT,
842            data: serde_json::json!({
843                "connection_id": self.connection_id.to_string(),
844                "peer_as": peer_as,
845                "peer_router_id": self.peer_router_id.map(|r| r.to_string()),
846                "peer_supports_four_octet_as": self.peer_asn4,
847                "negotiated_hold_time": self.hold_time,
848                "local_as": self.config.local_as,
849                "local_router_id": self.config.router_id.to_string(),
850                "remote_addr": self.remote_addr.to_string(),
851            }),
852        };
853        let outcome = self.call_llm_and_send(&event).await;
854        self.log_advertisement_outcome("bgp_established", outcome);
855        Ok(())
856    }
857
858    /// Log what an advertisement-shaped event decided, keeping the three cases apart.
859    ///
860    /// Nothing is written to the peer on any of them, and that is protocol-correct: RFC 4271
861    /// defines no reply to a session coming up or to an inbound UPDATE, and advertising *no*
862    /// routes already is the fail-closed answer — a backend outage must not leak a route, and
863    /// tearing an Established session down with a NOTIFICATION because one model call hiccuped
864    /// would be a worse answer than advertising nothing. The keepalive ticker runs in its own
865    /// task, so the peer is not left hanging either way. What must not happen is the three
866    /// cases becoming indistinguishable to the operator, so they are tagged here.
867    fn log_advertisement_outcome(&self, event_id: &str, outcome: SendOutcome) {
868        match outcome {
869            SendOutcome::Failed(failure) => {
870                // The error itself was already logged in full by `call_llm_and_send`.
871                Log::new(Some(&self.status_tx)).warn(format!(
872                    "BGP {} advertised nothing to {} (decision=fail_closed_llm_error, {})",
873                    event_id,
874                    self.remote_addr,
875                    if failure.is_overloaded() {
876                        "resource"
877                    } else {
878                        "unavailable"
879                    }
880                ));
881            }
882            SendOutcome::Nothing => debug!(
883                "BGP {} decision=model_silent peer={}: nothing advertised",
884                event_id, self.remote_addr
885            ),
886            SendOutcome::Refused => info!(
887                "BGP {} decision=model_reject peer={}: NOTIFICATION sent",
888                event_id, self.remote_addr
889            ),
890            SendOutcome::SentOpen { .. } => debug!(
891                "BGP {} peer={}: handler answered",
892                event_id, self.remote_addr
893            ),
894        }
895    }
896
897    async fn on_update(
898        &mut self,
899        update: netgauze_bgp_pkt::update::BgpUpdateMessage,
900    ) -> Result<()> {
901        let parsed = wire::update_to_json(&update);
902        info!(
903            "BGP UPDATE from AS{:?}: {} withdrawn, {} announced",
904            self.peer_as,
905            update.withdraw_routes().len(),
906            update.nlri().len()
907        );
908
909        let mut data = parsed;
910        data["connection_id"] = serde_json::json!(self.connection_id.to_string());
911        data["peer_as"] = serde_json::json!(self.peer_as);
912
913        let event = Event {
914            event_type: &BGP_UPDATE_EVENT,
915            data,
916        };
917        let outcome = self.call_llm_and_send(&event).await;
918        self.log_advertisement_outcome("bgp_update", outcome);
919        Ok(())
920    }
921
922    async fn on_notification(&mut self, n: netgauze_bgp_pkt::notification::BgpNotificationMessage) {
923        let (code, subcode) = notification_codes(&n);
924        Log::new(Some(&self.status_tx)).warn(format!(
925            "BGP NOTIFICATION from {}: {} / {}",
926            self.remote_addr,
927            wire::error_name(code),
928            wire::error_subcode_name(code, subcode)
929        ));
930
931        let event = Event {
932            event_type: &BGP_NOTIFICATION_EVENT,
933            data: serde_json::json!({
934                "connection_id": self.connection_id.to_string(),
935                "peer_as": self.peer_as,
936                "error_code": code,
937                "error_name": wire::error_name(code),
938                "error_subcode": subcode,
939                "error_subcode_name": wire::error_subcode_name(code, subcode),
940            }),
941        };
942        // RFC 4271: a NOTIFICATION is never answered with another message, so anything the model
943        // returns here is informational only and is not written to the socket. That makes the
944        // call pure observation, so skip it entirely unless the operator opted into LLM handling
945        // (a server instruction or a per-event handler) — otherwise it is a wasted round-trip
946        // that can change nothing.
947        if operator_wants_dynamic(&self.app_state, self.server_id, &event.event_type.id).await {
948            // Purely observational: the session is already over and RFC 4271 forbids answering a
949            // NOTIFICATION, so a backend failure here changes nothing on the wire. It is still
950            // logged with the same tag so an operator sees the outage in one grep.
951            if let Err(e) = call_llm(
952                &self.llm_client,
953                &self.app_state,
954                self.server_id,
955                Some(self.connection_id),
956                &event,
957                &*self.protocol,
958            )
959            .await
960            {
961                error!(
962                    "BGP bgp_notification decision=fail_closed_llm_error category={:?} peer={}: \
963                     {:#}",
964                    WireFailure::classify(&e),
965                    self.remote_addr,
966                    e
967                );
968            }
969        }
970    }
971
972    /// Run the handler for `event` and write whatever BGP messages it produced.
973    async fn call_llm_and_send(&mut self, event: &Event) -> SendOutcome {
974        // The BGP OPEN handshake is mechanical: given the configured ASN/router-id/hold-time and
975        // a peer that passed validation, the OPEN we send is fully determined — which is exactly
976        // why the SendOutcome::Nothing path below already answers a silent handler with the
977        // configured OPEN. Advertising routes (established/update) is by contrast a policy
978        // decision. So with no operator policy — no server instruction and no per-event handler —
979        // we return Nothing WITHOUT an LLM round-trip: bgp_open falls through to the configured
980        // OPEN (the correct static handshake), and established/update advertise nothing (correct,
981        // there is no policy to apply). The model is consulted only when the operator opts in.
982        if !operator_wants_dynamic(&self.app_state, self.server_id, &event.event_type.id).await {
983            debug!(
984                "BGP {} handled statically (no operator policy configured): no LLM call",
985                event.event_type.id
986            );
987            return SendOutcome::Nothing;
988        }
989
990        let result = match call_llm(
991            &self.llm_client,
992            &self.app_state,
993            self.server_id,
994            Some(self.connection_id),
995            event,
996            &*self.protocol,
997        )
998        .await
999        {
1000            Ok(result) => result,
1001            Err(e) => {
1002                // The full error goes to the log and the operator's status stream. Nothing
1003                // derived from it reaches the peer — a BGP NOTIFICATION has no free-text field
1004                // at all, so the category is carried by the Cease subcode and nothing else.
1005                let failure = WireFailure::classify(&e);
1006                error!(
1007                    "BGP {} decision=fail_closed_llm_error category={:?} peer={}: {:#}",
1008                    event.event_type.id, failure, self.remote_addr, e
1009                );
1010                Log::new(Some(&self.status_tx)).warn(format!(
1011                    "BGP handler for {} could not be run (decision=fail_closed_llm_error): {}",
1012                    event.event_type.id, e
1013                ));
1014                return SendOutcome::Failed(failure);
1015            }
1016        };
1017
1018        let mut outcome = SendOutcome::Nothing;
1019        let mut queue: Vec<ActionResult> = result.protocol_results;
1020        while let Some(item) = queue.pop() {
1021            match item {
1022                ActionResult::Multiple(inner) => queue.extend(inner),
1023                ActionResult::Custom { name, data } if name == BGP_MESSAGE_INTENT => {
1024                    let kind = data.get("kind").and_then(|v| v.as_str()).unwrap_or("");
1025                    match wire::encode_intent(&data, self.peer_asn4) {
1026                        Ok(bytes) => {
1027                            self.send(bytes);
1028                            outcome = match kind {
1029                                "open" => SendOutcome::SentOpen {
1030                                    hold_time: data
1031                                        .get("hold_time")
1032                                        .and_then(|v| v.as_u64())
1033                                        .unwrap_or(0)
1034                                        as u16,
1035                                },
1036                                "notification" => SendOutcome::Refused,
1037                                _ => match outcome {
1038                                    SendOutcome::Nothing => SendOutcome::Nothing,
1039                                    other => other,
1040                                },
1041                            };
1042                        }
1043                        Err(e) => {
1044                            // Encoding failed, so nothing goes on the wire. Emitting a
1045                            // half-formed message would be worse than emitting none.
1046                            Log::new(Some(&self.status_tx))
1047                                .warn(format!("BGP refused to encode {} action: {}", kind, e));
1048                        }
1049                    }
1050                }
1051                // Raw bytes are still honoured for any handler that produces them, but no BGP
1052                // action returns Output any more.
1053                ActionResult::Output(bytes) => self.send(bytes),
1054                _ => {}
1055            }
1056        }
1057        outcome
1058    }
1059
1060    fn send(&self, bytes: Vec<u8>) {
1061        if self.out_tx.send(bytes).is_err() {
1062            debug!("BGP write channel closed on {}", self.connection_id);
1063        }
1064    }
1065
1066    fn notify(&self, code: u8, subcode: u8, data: &[u8]) {
1067        match wire::encode_notification(code, subcode, data) {
1068            Ok(bytes) => {
1069                Log::new(Some(&self.status_tx)).warn(format!(
1070                    "BGP NOTIFICATION {} / {} to {}",
1071                    wire::error_name(code),
1072                    wire::error_subcode_name(code, subcode),
1073                    self.remote_addr
1074                ));
1075                self.send(bytes);
1076            }
1077            Err(e) => error!("BGP could not encode NOTIFICATION {code}/{subcode}: {e}"),
1078        }
1079    }
1080
1081    async fn set_connection_state(&self, state: &str) {
1082        let server_id = self.server_id;
1083        let conn_id = self.connection_id;
1084        let value = serde_json::json!({ "bgp_state": state });
1085        self.app_state
1086            .with_server_mut(server_id, |server| {
1087                if let Some(conn) = server.connections.get_mut(&conn_id) {
1088                    conn.protocol_info =
1089                        crate::state::server::ProtocolConnectionInfo::new(value.clone());
1090                }
1091            })
1092            .await;
1093    }
1094}
1095
1096/// Session shutdown signal.
1097///
1098/// The flag is the source of truth and the `Notify` only interrupts a parked read. `Notify`
1099/// alone is not enough: `notify_waiters` wakes tasks that are *already* waiting, so a signal
1100/// raised while the read loop is inside an LLM call would be dropped and the session would hang
1101/// until the peer closed the socket.
1102#[derive(Default)]
1103struct Shutdown {
1104    flag: AtomicBool,
1105    notify: Notify,
1106}
1107
1108impl Shutdown {
1109    fn set(&self) {
1110        self.flag.store(true, Ordering::SeqCst);
1111        self.notify.notify_waiters();
1112    }
1113
1114    fn is_set(&self) -> bool {
1115        self.flag.load(Ordering::SeqCst)
1116    }
1117}
1118
1119/// Returns true if the operator opted into dynamic (LLM- or handler-driven) responses for this
1120/// server: either a non-empty server instruction was given, or an event handler is configured
1121/// for `event_id`. When false the session applies its static default and never consults the
1122/// model — for BGP that means the configured OPEN on the handshake and no route advertisement.
1123async fn operator_wants_dynamic(
1124    state: &AppState,
1125    server_id: crate::state::ServerId,
1126    event_id: &str,
1127) -> bool {
1128    state
1129        .with_server_mut(server_id, |server| {
1130            let has_instruction = !server.instruction.trim().is_empty();
1131            let has_handler = server
1132                .event_handler_config
1133                .as_ref()
1134                .map(|c| c.find_handler(event_id).is_some())
1135                .unwrap_or(false);
1136            has_instruction || has_handler
1137        })
1138        .await
1139        .unwrap_or(false)
1140}
1141
1142/// RFC 4271 section 4.2: the session runs on the smaller of the two proposed hold times, and a
1143/// zero from either side turns the timers off entirely.
1144fn negotiate_hold(ours: u16, theirs: u16) -> u16 {
1145    if ours == 0 || theirs == 0 {
1146        0
1147    } else {
1148        ours.min(theirs)
1149    }
1150}
1151
1152/// What a handler's actions amounted to on the OPEN path.
1153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1154enum SendOutcome {
1155    /// An OPEN was written, proposing this hold time; carry on to OpenConfirm.
1156    SentOpen { hold_time: u16 },
1157    /// A NOTIFICATION was written; the model refused to peer.
1158    Refused,
1159    /// Nothing usable was produced: `wait_for_more`, an empty handler, or a model that answered
1160    /// with no BGP action. The operator's policy was *evaluated* and simply said nothing.
1161    Nothing,
1162    /// The handler could not be run at all — the backend errored, timed out, or was saturated.
1163    /// Distinct from [`SendOutcome::Nothing`] on purpose: the policy was never evaluated, so a
1164    /// caller that treats silence as consent must not treat this the same way.
1165    Failed(WireFailure),
1166}
1167
1168impl SendOutcome {
1169    /// The Cease subcode a failed handler earns. RFC 4486 separates a resource condition, which
1170    /// a peer damps and retries, from an outright rejection, which it does not — so a saturated
1171    /// backend must not be reported as a permanent refusal to peer.
1172    fn cease_subcode(failure: WireFailure) -> u8 {
1173        match failure {
1174            WireFailure::Overloaded => wire::SUB_CEASE_OUT_OF_RESOURCES,
1175            WireFailure::Unavailable => wire::SUB_CEASE_CONNECTION_REJECTED,
1176        }
1177    }
1178}
1179
1180/// Keepalive cadence and hold-timer enforcement.
1181///
1182/// Runs in its own task so keepalives continue while the read loop is inside an LLM call. Both
1183/// were previously absent: nothing was sent unless the peer spoke first, and a peer that went
1184/// silent held the socket forever.
1185#[allow(clippy::too_many_arguments)]
1186async fn spawn_timers(
1187    hold_time: u16,
1188    out_tx: mpsc::UnboundedSender<Vec<u8>>,
1189    last_received: Arc<AtomicU64>,
1190    started: crate::utils::clock::Instant,
1191    shutdown: Arc<Shutdown>,
1192    status_tx: mpsc::UnboundedSender<String>,
1193    connection_id: crate::server::connection::ConnectionId,
1194    app_state: Arc<AppState>,
1195    server_id: crate::state::ServerId,
1196) -> tokio::task::AbortHandle {
1197    // RFC 4271 section 10: KeepaliveTime is a third of the negotiated HoldTime.
1198    let interval = std::time::Duration::from_secs(u64::from(hold_time).div_ceil(3).max(1));
1199    // Tracked, not detached. `CLAUDE.md` records this exact task as the reason
1200    // "register every task you spawn, not just the top one" is a rule: aborting the read
1201    // loop does not abort what it spawned, so the keepalive timer kept writing to the
1202    // socket — and kept it open — after the server was stopped.
1203    let task_owner = app_state.clone();
1204    task_owner
1205        .spawn_server_task(server_id, async move {
1206            let keepalive = wire::encode_keepalive();
1207            loop {
1208                tokio::time::sleep(interval).await;
1209
1210                let elapsed = started.elapsed().as_secs();
1211                let last = last_received.load(Ordering::Relaxed);
1212                if elapsed.saturating_sub(last) >= u64::from(hold_time) {
1213                    Log::new(Some(&status_tx)).warn(format!(
1214                        "BGP hold timer expired on {} after {}s of silence",
1215                        connection_id,
1216                        elapsed.saturating_sub(last)
1217                    ));
1218                    if let Ok(bytes) =
1219                        wire::encode_notification(wire::ERR_HOLD_TIMER_EXPIRED, 0, &[])
1220                    {
1221                        let _ = out_tx.send(bytes);
1222                    }
1223                    shutdown.set();
1224                    return;
1225                }
1226
1227                if out_tx.send(keepalive.clone()).is_err() {
1228                    return;
1229                }
1230                trace!("BGP KEEPALIVE sent on {}", connection_id);
1231            }
1232        })
1233        .await
1234}
1235
1236/// `with_deadline` gave up before `fut` finished.
1237struct DeadlineExpired;
1238
1239/// Await `fut`, abandoning it after `limit` when one is given.
1240///
1241/// A plain `tokio::time::timeout` cannot express "no limit", and the two cases differ per
1242/// message here: only the pre-OPEN read is bounded from this loop, because after that the
1243/// negotiated hold timer runs in its own task.
1244async fn with_deadline<F: std::future::Future>(
1245    limit: Option<std::time::Duration>,
1246    fut: F,
1247) -> std::result::Result<F::Output, DeadlineExpired> {
1248    match limit {
1249        Some(limit) => tokio::time::timeout(limit, fut)
1250            .await
1251            .map_err(|_| DeadlineExpired),
1252        None => Ok(fut.await),
1253    }
1254}
1255
1256/// Read one BGP message: 19-byte header, bounds check, then exactly the declared body.
1257///
1258/// Nothing is allocated before the length has been validated against \[19, 4096\], so a peer
1259/// cannot make the server reserve an arbitrary buffer, and `len - 19` cannot underflow.
1260///
1261/// The second element is the number of octets consumed, for the connection's inbound counters.
1262async fn read_message(
1263    reader: &mut tokio::io::ReadHalf<tokio::net::TcpStream>,
1264    asn4: bool,
1265) -> (Incoming, usize) {
1266    let mut header = [0u8; wire::BGP_HEADER_LEN];
1267    match reader.read_exact(&mut header).await {
1268        Ok(_) => {}
1269        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return (Incoming::Eof, 0),
1270        Err(e) => {
1271            debug!("BGP read error: {e}");
1272            return (Incoming::Eof, 0);
1273        }
1274    }
1275
1276    let (len, msg_type) = match wire::parse_header(&header) {
1277        Ok(v) => v,
1278        Err(e) => return (Incoming::HeaderError(e), wire::BGP_HEADER_LEN),
1279    };
1280
1281    let mut full = vec![0u8; len];
1282    full[..wire::BGP_HEADER_LEN].copy_from_slice(&header);
1283    if len > wire::BGP_HEADER_LEN {
1284        if let Err(e) = reader.read_exact(&mut full[wire::BGP_HEADER_LEN..]).await {
1285            debug!("BGP truncated message body: {e}");
1286            return (Incoming::Eof, wire::BGP_HEADER_LEN);
1287        }
1288    }
1289
1290    let incoming = match wire::decode(&full, asn4) {
1291        Ok(msg) => Incoming::Message(msg),
1292        Err(e) => Incoming::DecodeError(e, msg_type),
1293    };
1294    (incoming, len)
1295}
1296
1297/// Drive one connection's injected-action channel (the dashboard's "message this peer" and
1298/// "disconnect this peer") until the handle is removed or the session ends.
1299///
1300/// Mirrors `peer_support::handle_peer_command` — same central executor, same access-log entry,
1301/// same reply — but encodes BGP's `Custom` message intents through `wire::encode_intent` at the
1302/// negotiated AS width and pushes bytes down the session's write channel, so nothing is written
1303/// that the model's own actions could not have produced. `close_connection` (not offered to the
1304/// model) executes to `ActionResult::CloseConnection`, which here means what RFC 4271 says a
1305/// deliberate teardown means: NOTIFICATION 6/2 (Cease / Administrative Shutdown) followed by the
1306/// close, raised through the session's `Shutdown` so the read loop exits even if the peer never
1307/// speaks again.
1308#[allow(clippy::too_many_arguments)]
1309async fn spawn_peer_command_task(
1310    mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
1311    protocol: Arc<BgpProtocol>,
1312    app_state: Arc<AppState>,
1313    server_id: crate::state::ServerId,
1314    connection_id: crate::server::connection::ConnectionId,
1315    out_tx: mpsc::UnboundedSender<Vec<u8>>,
1316    peer_asn4: Arc<AtomicBool>,
1317    shutdown: Arc<Shutdown>,
1318    status_tx: mpsc::UnboundedSender<String>,
1319) -> tokio::task::AbortHandle {
1320    use crate::llm::actions::protocol_trait::Protocol;
1321    use crate::state::client_handles::ClientSendOutcome;
1322    use crate::state::AccessLogOwner;
1323
1324    // Tracked, not detached: stop_server must abort this task too.
1325    let task_owner = app_state.clone();
1326    task_owner
1327        .spawn_server_task(server_id, async move {
1328            while let Some(command) = command_rx.recv().await {
1329                let action = command.action.clone();
1330                let outcome = execute_injected_action(
1331                    &action,
1332                    protocol.as_ref(),
1333                    &app_state,
1334                    server_id,
1335                    &out_tx,
1336                    peer_asn4.load(Ordering::Relaxed),
1337                    &shutdown,
1338                )
1339                .await;
1340
1341                let outcome_json = match &outcome {
1342                    Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1343                    Err(e) => serde_json::json!({"error": e.to_string()}),
1344                };
1345                app_state
1346                    .record_access_log(
1347                        AccessLogOwner::Server(server_id.as_u32()),
1348                        protocol.protocol_name(),
1349                        Some(connection_id.as_u32()),
1350                        "injected_action",
1351                        action,
1352                        vec![outcome_json],
1353                    )
1354                    .await;
1355
1356                match &outcome {
1357                    Err(e) => warn!(
1358                        "BGP injected action on server #{} connection {} failed: {}",
1359                        server_id.as_u32(),
1360                        connection_id,
1361                        e
1362                    ),
1363                    Ok(ClientSendOutcome::Disconnected) => {
1364                        app_state
1365                            .remove_peer_handle(server_id, connection_id.as_u32())
1366                            .await;
1367                        app_state
1368                            .close_connection_on_server(server_id, connection_id)
1369                            .await;
1370                    }
1371                    Ok(_) => {}
1372                }
1373                let _ = status_tx.send("__UPDATE_UI__".to_string());
1374                let _ = command.reply_tx.send(outcome);
1375            }
1376            debug!(
1377                "BGP peer command task for server #{} connection {} ended",
1378                server_id.as_u32(),
1379                connection_id
1380            );
1381        })
1382        .await
1383}
1384
1385/// Execute one injected action and queue whatever it encodes to.
1386async fn execute_injected_action(
1387    action: &serde_json::Value,
1388    protocol: &BgpProtocol,
1389    app_state: &AppState,
1390    server_id: crate::state::ServerId,
1391    out_tx: &mpsc::UnboundedSender<Vec<u8>>,
1392    peer_asn4: bool,
1393    shutdown: &Arc<Shutdown>,
1394) -> Result<crate::state::client_handles::ClientSendOutcome> {
1395    use crate::state::client_handles::ClientSendOutcome;
1396
1397    let cease = |out_tx: &mpsc::UnboundedSender<Vec<u8>>| -> Result<()> {
1398        let bytes =
1399            wire::encode_notification(wire::ERR_CEASE, wire::SUB_CEASE_ADMIN_SHUTDOWN, &[])?;
1400        out_tx
1401            .send(bytes)
1402            .map_err(|_| anyhow!("BGP session already closed"))?;
1403        shutdown.set();
1404        Ok(())
1405    };
1406
1407    let result = crate::llm::actions::executor::execute_actions(
1408        vec![action.clone()],
1409        app_state,
1410        Some(protocol),
1411        Some(server_id),
1412        None,
1413    )
1414    .await?;
1415
1416    // The executor records a protocol rejection (unknown verb, bad prefix, AS 0 ...) as a
1417    // failure and carries on; surface it as such instead of "executed (nothing to write)".
1418    if let Some(failure) = result.failures.first() {
1419        return Ok(ClientSendOutcome::Rejected {
1420            error: failure.error.clone(),
1421        });
1422    }
1423
1424    let mut bytes_sent = 0usize;
1425    let mut closed = false;
1426    let mut details: Vec<String> = Vec::new();
1427    let mut queue: Vec<ActionResult> = result.protocol_results;
1428    queue.reverse();
1429    while let Some(item) = queue.pop() {
1430        match item {
1431            ActionResult::Multiple(inner) => {
1432                for item in inner.into_iter().rev() {
1433                    queue.push(item);
1434                }
1435            }
1436            ActionResult::Custom { name, data } if name == BGP_MESSAGE_INTENT => {
1437                let bytes = wire::encode_intent(&data, peer_asn4)?;
1438                bytes_sent += bytes.len();
1439                out_tx
1440                    .send(bytes)
1441                    .map_err(|_| anyhow!("BGP session already closed"))?;
1442            }
1443            ActionResult::Output(bytes) => {
1444                bytes_sent += bytes.len();
1445                out_tx
1446                    .send(bytes)
1447                    .map_err(|_| anyhow!("BGP session already closed"))?;
1448            }
1449            ActionResult::CloseConnection => {
1450                cease(out_tx)?;
1451                closed = true;
1452            }
1453            ActionResult::WaitForMore | ActionResult::NoAction => {}
1454            other => details.push(format!("{other:?}")),
1455        }
1456    }
1457
1458    Ok(if closed {
1459        ClientSendOutcome::Disconnected
1460    } else if bytes_sent > 0 {
1461        ClientSendOutcome::Sent { bytes_sent }
1462    } else if details.is_empty() {
1463        ClientSendOutcome::Executed {
1464            detail: "executed (nothing to write)".to_string(),
1465        }
1466    } else {
1467        ClientSendOutcome::Executed {
1468            detail: crate::utils::truncate_for_log(&details.join("; "), 160),
1469        }
1470    })
1471}
1472
1473/// Recover the (code, subcode) pair from netgauze's typed NOTIFICATION so it can be reported
1474/// numerically, the way RFC 4271 section 6 names it.
1475fn notification_codes(n: &netgauze_bgp_pkt::notification::BgpNotificationMessage) -> (u8, u8) {
1476    use netgauze_bgp_pkt::notification::*;
1477    match n {
1478        BgpNotificationMessage::MessageHeaderError(e) => (
1479            wire::ERR_HEADER,
1480            match e {
1481                MessageHeaderError::Unspecific { .. } => 0,
1482                MessageHeaderError::ConnectionNotSynchronized { .. } => 1,
1483                MessageHeaderError::BadMessageLength { .. } => 2,
1484                MessageHeaderError::BadMessageType { .. } => 3,
1485            },
1486        ),
1487        BgpNotificationMessage::OpenMessageError(e) => (
1488            wire::ERR_OPEN,
1489            match e {
1490                OpenMessageError::Unspecific { .. } => 0,
1491                OpenMessageError::UnsupportedVersionNumber { .. } => 1,
1492                OpenMessageError::BadPeerAs { .. } => 2,
1493                OpenMessageError::BadBgpIdentifier { .. } => 3,
1494                OpenMessageError::UnsupportedOptionalParameter { .. } => 4,
1495                OpenMessageError::UnacceptableHoldTime { .. } => 6,
1496                OpenMessageError::UnsupportedCapability { .. } => 7,
1497                OpenMessageError::RoleMismatch { .. } => 11,
1498            },
1499        ),
1500        BgpNotificationMessage::UpdateMessageError(e) => (
1501            wire::ERR_UPDATE,
1502            match e {
1503                UpdateMessageError::Unspecific { .. } => 0,
1504                UpdateMessageError::MalformedAttributeList { .. } => 1,
1505                UpdateMessageError::UnrecognizedWellKnownAttribute { .. } => 2,
1506                UpdateMessageError::MissingWellKnownAttribute { .. } => 3,
1507                UpdateMessageError::AttributeFlagsError { .. } => 4,
1508                UpdateMessageError::AttributeLengthError { .. } => 5,
1509                UpdateMessageError::InvalidOriginAttribute { .. } => 6,
1510                UpdateMessageError::InvalidNextHopAttribute { .. } => 8,
1511                UpdateMessageError::OptionalAttributeError { .. } => 9,
1512                UpdateMessageError::InvalidNetworkField { .. } => 10,
1513                UpdateMessageError::MalformedAsPath { .. } => 11,
1514            },
1515        ),
1516        BgpNotificationMessage::HoldTimerExpiredError(e) => (
1517            wire::ERR_HOLD_TIMER_EXPIRED,
1518            match e {
1519                HoldTimerExpiredError::Unspecific { sub_code, .. } => *sub_code,
1520            },
1521        ),
1522        BgpNotificationMessage::FiniteStateMachineError(e) => (
1523            wire::ERR_FSM,
1524            match e {
1525                FiniteStateMachineError::Unspecific { .. } => 0,
1526                FiniteStateMachineError::ReceiveUnexpectedMessageInOpenSentState { .. } => 1,
1527                FiniteStateMachineError::ReceiveUnexpectedMessageInOpenConfirmState { .. } => 2,
1528                FiniteStateMachineError::ReceiveUnexpectedMessageInEstablishedState { .. } => 3,
1529            },
1530        ),
1531        BgpNotificationMessage::CeaseError(e) => (
1532            wire::ERR_CEASE,
1533            match e {
1534                CeaseError::MaximumNumberOfPrefixesReached { .. } => 1,
1535                CeaseError::AdministrativeShutdown { .. } => 2,
1536                CeaseError::PeerDeConfigured { .. } => 3,
1537                CeaseError::AdministrativeReset { .. } => 4,
1538                CeaseError::ConnectionRejected { .. } => 5,
1539                CeaseError::OtherConfigurationChange { .. } => 6,
1540                CeaseError::ConnectionCollisionResolution { .. } => 7,
1541                CeaseError::OutOfResources { .. } => 8,
1542                CeaseError::HardReset { .. } => 9,
1543                CeaseError::BfdDown { .. } => 10,
1544            },
1545        ),
1546        BgpNotificationMessage::RouteRefreshError(e) => (
1547            7,
1548            match e {
1549                RouteRefreshError::InvalidMessageLength { .. } => 1,
1550            },
1551        ),
1552    }
1553}