Skip to main content

netget/client/bgp/
mod.rs

1//! BGP client implementation (query mode)
2//!
3//! This BGP client connects to BGP peers to query routing information. It completes the
4//! RFC 4271 session establishment (OPEN / KEEPALIVE) and then reports what the peer sends.
5//! It maintains no RIB and announces nothing on its own.
6//!
7//! # Wire format
8//!
9//! Every byte on this path is produced and consumed by [`crate::server::bgp::wire`], the same
10//! `netgauze-bgp-pkt`-backed codec the BGP *server* uses. Both halves live behind the same
11//! `bgp` Cargo feature, so there is nothing to gate and nothing to duplicate.
12//!
13//! This client used to hand-roll its own encoder and decoder and carried three defects the
14//! server half has since had fixed:
15//!
16//! * `&header_buf[0..16] != &BGP_MARKER` sliced a buffer before checking its length.
17//!   [`wire::parse_header`] takes a `[u8; 19]`, so the bound is in the type, and it rejects a
18//!   length outside `[19, 4096]` *before* a body buffer is allocated.
19//! * `local_as as u16` silently turned AS 4200000000 into AS 60416 — a different, entirely
20//!   valid-looking ASN, with no four-octet-AS capability and no diagnostic.
21//!   [`wire::build_open`] puts `AS_TRANS` (23456) in the two-octet field and the real ASN in
22//!   capability 65, per RFC 6793.
23//! * UPDATE bodies reached the model as `hex::encode(body)`, which no model can act on. They
24//!   are now decoded field by field by [`wire::update_to_json`].
25
26pub mod actions;
27pub use actions::BgpClientProtocol;
28
29use anyhow::{anyhow, bail, Context, Result};
30use std::net::{Ipv4Addr, SocketAddr};
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32use std::sync::Arc;
33use tokio::io::{AsyncReadExt, AsyncWriteExt};
34use tokio::net::TcpStream;
35use tokio::sync::{mpsc, Mutex, Notify};
36use tracing::{debug, error, info, trace, warn};
37
38use netgauze_bgp_pkt::{capabilities::BgpCapability, BgpMessage};
39
40use crate::client::bgp::actions::{
41    BGP_CLIENT_CONNECTED_EVENT, BGP_CLIENT_NOTIFICATION_RECEIVED_EVENT,
42    BGP_CLIENT_UPDATE_RECEIVED_EVENT,
43};
44use crate::client::llm_budget::call_llm_for_client;
45use crate::llm::actions::client_trait::{Client, ClientActionResult};
46use crate::llm::ollama_client::OllamaClient;
47use crate::llm::ClientLlmResult;
48use crate::protocol::{Event, StartupParams};
49use crate::server::bgp::wire;
50use crate::state::app_state::AppState;
51use crate::state::{ClientId, ClientStatus};
52
53const DEFAULT_HOLD_TIME: u16 = 180;
54/// Private ASN, safe to use for monitoring.
55const DEFAULT_LOCAL_AS: u32 = 65000;
56const DEFAULT_ROUTER_ID: &str = "192.168.1.100";
57/// RFC 4271 section 4.2: a non-zero hold time below three seconds is unacceptable.
58const MIN_HOLD_TIME: u16 = 3;
59
60/// RFC 4271 section 8.2.2: having sent our OPEN we are in OpenSent, where the Hold Timer is set
61/// to "a large value" — the RFC suggests 4 minutes — because the *negotiated* timer cannot start
62/// until the peer's OPEN has arrived.
63///
64/// Without it, a peer that accepted the TCP connection and then said nothing left this client
65/// parked in `read_exact` indefinitely: `spawn_timers` is only reached from the OPEN handler, so
66/// there was no timer of any kind covering the one phase the peer controls entirely.
67const OPEN_HOLD_TIME: std::time::Duration = std::time::Duration::from_secs(240);
68
69/// Where a write half lives once the read loop has been spawned.
70type SharedWriter = Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>;
71
72/// BGP session state, from the client's point of view.
73///
74/// The full RFC 4271 FSM has six states; this client never listens and never retries, so
75/// `Idle` and `Active` cannot occur.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum BgpState {
78    OpenSent,
79    OpenConfirm,
80    Established,
81}
82
83/// Per-client data for LLM handling.
84struct ClientData {
85    bgp_state: BgpState,
86    memory: String,
87    peer_as: Option<u32>,
88    peer_router_id: Option<String>,
89    /// Whether the peer advertised the four-octet-AS capability. Decides how AS_PATH in an
90    /// inbound UPDATE is read; getting it wrong yields a wrong AS path rather than an error.
91    peer_asn4: bool,
92    /// The hold time we proposed in our OPEN.
93    proposed_hold_time: u16,
94    /// `min(ours, theirs)`, valid once the peer's OPEN has been seen.
95    negotiated_hold_time: u16,
96}
97
98/// RFC 4271 section 4.2: the negotiated hold time is the smaller of the two proposals, and
99/// zero on either side turns the timers off for both.
100fn negotiate_hold(ours: u16, theirs: u16) -> u16 {
101    ours.min(theirs)
102}
103
104/// Session shutdown signal, mirroring the BGP server's (`src/server/bgp/mod.rs`).
105///
106/// The flag is the source of truth and the `Notify` only interrupts a parked read. `Notify`
107/// alone is not enough: `notify_waiters` wakes tasks that are *already* waiting, so a signal
108/// raised while the read loop is inside an LLM call would be dropped and the session would hang
109/// until the peer closed the socket.
110///
111/// This is what makes hold-timer enforcement possible at all on the client. The read loop parks
112/// in `read_exact`, which no ticker can interrupt on its own; the loop selects over this
113/// `Notify` and the read, so an expiry preempts a blocked read instead of waiting for TCP to
114/// notice a peer that may never come back.
115#[derive(Default)]
116struct Shutdown {
117    flag: AtomicBool,
118    notify: Notify,
119}
120
121impl Shutdown {
122    fn set(&self) {
123        self.flag.store(true, Ordering::SeqCst);
124        self.notify.notify_waiters();
125    }
126
127    fn is_set(&self) -> bool {
128        self.flag.load(Ordering::SeqCst)
129    }
130}
131
132/// [`with_deadline`] gave up before the future finished.
133struct DeadlineExpired;
134
135/// Await `fut`, abandoning it after `limit` when one is given.
136///
137/// A plain `tokio::time::timeout` cannot express "no limit", and only the pre-OPEN reads are
138/// bounded from the read loop: once the session is negotiated, `spawn_timers` owns the schedule
139/// and signals through `Shutdown` instead.
140async fn with_deadline<F: std::future::Future>(
141    limit: Option<std::time::Duration>,
142    fut: F,
143) -> std::result::Result<F::Output, DeadlineExpired> {
144    match limit {
145        Some(limit) => tokio::time::timeout(limit, fut)
146            .await
147            .map_err(|_| DeadlineExpired),
148        None => Ok(fut.await),
149    }
150}
151
152/// BGP client that connects to a BGP peer
153pub struct BgpClient;
154
155impl BgpClient {
156    /// Connect to a BGP peer with integrated LLM actions
157    pub async fn connect_with_llm_actions(
158        remote_addr: String,
159        llm_client: OllamaClient,
160        app_state: Arc<AppState>,
161        status_tx: mpsc::UnboundedSender<String>,
162        client_id: ClientId,
163        startup_params: Option<StartupParams>,
164    ) -> Result<SocketAddr> {
165        // Startup parameters are validated here rather than at first use, so a bad value fails
166        // the connect with a message naming the parameter instead of quietly becoming a
167        // different, valid-looking value on the wire.
168        let local_as = startup_params
169            .as_ref()
170            .map(|p| p.get_optional_u32("local_as"))
171            .transpose()?
172            .flatten()
173            .unwrap_or(DEFAULT_LOCAL_AS);
174        if local_as == 0 {
175            bail!("BGP client local_as must be 1-4294967295, got 0");
176        }
177
178        let router_id_str = startup_params
179            .as_ref()
180            .map(|p| p.get_optional_string("router_id"))
181            .transpose()?
182            .flatten()
183            .unwrap_or_else(|| DEFAULT_ROUTER_ID.to_string());
184        let router_id: Ipv4Addr = router_id_str.parse().with_context(|| {
185            format!("BGP client router_id {router_id_str:?} is not an IPv4 address")
186        })?;
187        if router_id.is_unspecified() {
188            bail!("BGP client router_id must not be 0.0.0.0 (RFC 4271 requires a valid BGP Identifier)");
189        }
190
191        let hold_time_raw = startup_params
192            .as_ref()
193            .map(|p| p.get_optional_u32("hold_time"))
194            .transpose()?
195            .flatten()
196            .unwrap_or(u32::from(DEFAULT_HOLD_TIME));
197        if hold_time_raw > u32::from(u16::MAX) {
198            bail!(
199                "BGP client hold_time must be 0-65535 seconds, got {}",
200                hold_time_raw
201            );
202        }
203        let hold_time = hold_time_raw as u16;
204        if hold_time != 0 && hold_time < MIN_HOLD_TIME {
205            bail!(
206                "BGP client hold_time must be 0 (timers disabled) or at least {} seconds, got {}",
207                MIN_HOLD_TIME,
208                hold_time
209            );
210        }
211
212        info!(
213            "BGP client {} connecting with AS={}, router_id={}, hold_time={}s",
214            client_id, local_as, router_id, hold_time
215        );
216
217        // Connect to BGP peer (typically port 179)
218        let stream = TcpStream::connect(&remote_addr)
219            .await
220            .with_context(|| format!("Failed to connect to BGP peer {remote_addr}"))?;
221
222        let local_addr = stream.local_addr()?;
223        let remote_sock_addr = stream.peer_addr()?;
224
225        info!(
226            "BGP client {} connected to {} (local: {})",
227            client_id, remote_sock_addr, local_addr
228        );
229
230        // Update client state
231        app_state
232            .update_client_status(client_id, ClientStatus::Connected)
233            .await;
234        let _ = status_tx.send(format!("[CLIENT] BGP client {client_id} connected"));
235        let _ = status_tx.send("__UPDATE_UI__".to_string());
236
237        // Split stream (never clone a TcpStream)
238        let (read_half, write_half) = tokio::io::split(stream);
239        let write_half_arc: SharedWriter = Arc::new(Mutex::new(write_half));
240
241        let client_data = Arc::new(Mutex::new(ClientData {
242            bgp_state: BgpState::OpenSent,
243            memory: String::new(),
244            peer_as: None,
245            peer_router_id: None,
246            peer_asn4: false,
247            proposed_hold_time: hold_time,
248            negotiated_hold_time: hold_time,
249        }));
250
251        // Send our OPEN immediately. `wire::build_open` advertises the four-octet-AS
252        // capability unconditionally and substitutes AS_TRANS in the two-octet field when the
253        // real ASN does not fit, so a 32-bit ASN survives the trip.
254        let open_msg = wire::encode(wire::build_open(local_as, hold_time, router_id))
255            .context("Failed to encode BGP OPEN")?;
256        write_half_arc
257            .lock()
258            .await
259            .write_all(&open_msg)
260            .await
261            .context("Failed to send BGP OPEN")?;
262
263        info!(
264            "BGP client {} sent OPEN: AS={}, hold_time={}s, router_id={}",
265            client_id, local_as, hold_time, router_id
266        );
267        let _ = status_tx.send(format!(
268            "[CLIENT] BGP OPEN sent: AS={local_as}, hold_time={hold_time}s"
269        ));
270
271        let shutdown = Arc::new(Shutdown::default());
272
273        // Command channel for injected actions (the dashboard's [ send_keepalive ] /
274        // [ send_notification ] / [ disconnect ] rows). Registered BEFORE the read loop — and
275        // so before the `bgp_connected` LLM call, which a manual `*` rule can park for minutes.
276        //
277        // `read_exact` is not cancellation-safe, so the commands are drained by their own task
278        // rather than a `select!` arm in the read loop; both share the write half. Every wire
279        // verb of this client yields `SendData` / `Disconnect`, so the generic arm covers the
280        // whole vocabulary. On an injected `disconnect` the Cease NOTIFICATION is already on
281        // the wire; the task raises `shutdown` so the read loop runs its normal teardown (which
282        // shuts the write half down) instead of waiting for the peer to notice.
283        let mut command_rx =
284            crate::client::command_support::register_command_channel(&app_state, client_id).await;
285        let cmd_state = app_state.clone();
286        let cmd_tx = status_tx.clone();
287        let cmd_write = write_half_arc.clone();
288        let cmd_shutdown = shutdown.clone();
289        let cmd_task = tokio::spawn(async move {
290            while let Some(cmd) = command_rx.recv().await {
291                let disconnect = crate::client::command_support::handle_stream_client_command(
292                    &BgpClientProtocol::new(),
293                    &cmd_write,
294                    cmd,
295                    client_id,
296                    &cmd_state,
297                    &cmd_tx,
298                )
299                .await;
300                if disconnect {
301                    cmd_shutdown.set();
302                    let _ = cmd_write.lock().await.shutdown().await;
303                    break;
304                }
305            }
306        });
307        // Aborting the read-loop task does not abort tasks it spawned, and this one is a
308        // sibling anyway: register it so stop/remove aborts it too.
309        app_state.register_client_task(client_id, cmd_task).await;
310
311        // Spawn read loop
312        let client_data_clone = client_data.clone();
313        let write_half_clone = write_half_arc.clone();
314        let llm_client_clone = llm_client.clone();
315        let app_state_clone = app_state.clone();
316        let status_tx_clone = status_tx.clone();
317
318        // Registered with AppState so stop_client can abort this task —
319        // dropping a JoinHandle only detaches it in Tokio.
320        let task_registrar = app_state.clone();
321        let task_handle = tokio::spawn(async move {
322            if let Err(e) = Self::read_loop(
323                read_half,
324                write_half_clone,
325                client_id,
326                llm_client_clone,
327                app_state_clone,
328                status_tx_clone,
329                client_data_clone,
330                shutdown,
331            )
332            .await
333            {
334                error!("BGP client {} read loop error: {}", client_id, e);
335            }
336        });
337        task_registrar
338            .register_client_task(client_id, task_handle)
339            .await;
340
341        Ok(local_addr)
342    }
343
344    /// Read loop for BGP messages
345    #[allow(clippy::too_many_arguments)]
346    async fn read_loop(
347        mut read_half: tokio::io::ReadHalf<TcpStream>,
348        write_half: SharedWriter,
349        client_id: ClientId,
350        llm_client: OllamaClient,
351        app_state: Arc<AppState>,
352        status_tx: mpsc::UnboundedSender<String>,
353        client_data: Arc<Mutex<ClientData>>,
354        shutdown: Arc<Shutdown>,
355    ) -> Result<()> {
356        let last_received = Arc::new(AtomicU64::new(0));
357        let started = crate::utils::clock::Instant::now();
358
359        let mut timer_task: Option<tokio::task::AbortHandle> = None;
360        let result = Self::read_loop_inner(
361            &mut read_half,
362            &write_half,
363            client_id,
364            &llm_client,
365            &app_state,
366            &status_tx,
367            &client_data,
368            &mut timer_task,
369            &shutdown,
370            &last_received,
371            started,
372        )
373        .await;
374
375        // Teardown, in this order: stop the timers first so nothing can write to a socket that
376        // is about to close, then shut the write half down explicitly. Relying on the `Arc`
377        // refcount to drop the write half would leave the peer waiting for a FIN that only
378        // arrives once every clone happens to go away.
379        if let Some(task) = timer_task {
380            task.abort();
381        }
382        let _ = write_half.lock().await.shutdown().await;
383
384        // Every exit path of the read loop lands here: drop the command handle so the dashboard
385        // stops offering [ send ] on a dead session (a late send then fails fast).
386        app_state.remove_client_handle(client_id).await;
387        app_state
388            .update_client_status(client_id, ClientStatus::Disconnected)
389            .await;
390        let _ = status_tx.send("__UPDATE_UI__".to_string());
391
392        result
393    }
394
395    #[allow(clippy::too_many_arguments)]
396    async fn read_loop_inner(
397        read_half: &mut tokio::io::ReadHalf<TcpStream>,
398        write_half: &SharedWriter,
399        client_id: ClientId,
400        llm_client: &OllamaClient,
401        app_state: &Arc<AppState>,
402        status_tx: &mpsc::UnboundedSender<String>,
403        client_data: &Arc<Mutex<ClientData>>,
404        timer_task: &mut Option<tokio::task::AbortHandle>,
405        shutdown: &Arc<Shutdown>,
406        last_received: &Arc<AtomicU64>,
407        started: crate::utils::clock::Instant,
408    ) -> Result<()> {
409        loop {
410            if shutdown.is_set() {
411                break;
412            }
413
414            // The negotiated four-octet-AS state decides how AS_PATH is read, and the session
415            // state decides whether the pre-OPEN deadline applies. Copy both out rather than
416            // holding the guard across the read.
417            let (peer_asn4, open_deadline) = {
418                let data = client_data.lock().await;
419                (
420                    data.peer_asn4,
421                    (data.bgp_state == BgpState::OpenSent).then_some(OPEN_HOLD_TIME),
422                )
423            };
424
425            let mut header = [0u8; wire::BGP_HEADER_LEN];
426            // A parked `read_exact` is exactly what a hold timer has to be able to interrupt:
427            // the peer that has to be dropped is by definition the one sending nothing. The
428            // timer task raises `shutdown` after writing its NOTIFICATION, and this select
429            // turns that into an immediate exit rather than a wait for TCP to give up. Before
430            // the peer's OPEN there is no timer task yet, so the deadline above stands in.
431            let read_result = tokio::select! {
432                biased;
433                _ = shutdown.notify.notified() => break,
434                r = with_deadline(open_deadline, read_half.read_exact(&mut header)) => match r {
435                    Ok(r) => r,
436                    Err(DeadlineExpired) => {
437                        error!(
438                            "BGP client {} received no OPEN within {}s, closing (RFC 4271 8.2.2)",
439                            client_id,
440                            OPEN_HOLD_TIME.as_secs()
441                        );
442                        let _ = status_tx.send(format!(
443                            "[CLIENT] BGP peer sent no OPEN within {}s, closing session",
444                            OPEN_HOLD_TIME.as_secs()
445                        ));
446                        Self::send_notification(write_half, wire::ERR_HOLD_TIMER_EXPIRED, 0, &[])
447                            .await;
448                        break;
449                    }
450                },
451            };
452
453            match read_result {
454                Ok(_) => {}
455                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
456                    info!("BGP client {} disconnected", client_id);
457                    app_state
458                        .update_client_status(client_id, ClientStatus::Disconnected)
459                        .await;
460                    let _ = status_tx.send(format!("[CLIENT] BGP client {client_id} disconnected"));
461                    let _ = status_tx.send("__UPDATE_UI__".to_string());
462                    break;
463                }
464                Err(e) => {
465                    error!("BGP client {} read error: {}", client_id, e);
466                    return Err(e.into());
467                }
468            }
469
470            // Marker, total length and per-type minimum are all validated before a single
471            // body byte is read, so `len - BGP_HEADER_LEN` cannot underflow and the peer
472            // cannot choose the buffer size.
473            let (msg_len, msg_type) = match wire::parse_header(&header) {
474                Ok(v) => v,
475                Err(e) => {
476                    let (code, subcode) = e.notify_code();
477                    error!(
478                        "BGP client {} rejected message header: {:?} ({}/{})",
479                        client_id, e, code, subcode
480                    );
481                    let _ = status_tx.send(format!(
482                        "[CLIENT] BGP header rejected: {} / {}",
483                        wire::error_name(code),
484                        wire::error_subcode_name(code, subcode)
485                    ));
486                    Self::send_notification(write_half, code, subcode, &e.notify_data()).await;
487                    return Err(anyhow!("Invalid BGP message header: {e:?}"));
488                }
489            };
490
491            let mut full = vec![0u8; msg_len];
492            full[..wire::BGP_HEADER_LEN].copy_from_slice(&header);
493            if msg_len > wire::BGP_HEADER_LEN {
494                // The body read has to be under the same select as the header read. It was not,
495                // and that left a hole the hold timer could not close: a peer that sent a valid
496                // 19-octet header announcing a 4096-octet message and then stopped parked this
497                // `read_exact` forever. `shutdown` was raised on expiry and nothing observed it,
498                // so the NOTIFICATION went out and the session stayed up regardless. Half a
499                // message is not worth preserving, so abandoning the read mid-way is fine —
500                // every path out of here ends the session.
501                let body = tokio::select! {
502                    biased;
503                    _ = shutdown.notify.notified() => break,
504                    r = with_deadline(
505                        open_deadline,
506                        read_half.read_exact(&mut full[wire::BGP_HEADER_LEN..]),
507                    ) => r,
508                };
509                match body {
510                    Ok(Ok(_)) => {}
511                    Ok(Err(e)) => return Err(e).context("BGP message body truncated"),
512                    Err(DeadlineExpired) => {
513                        error!(
514                            "BGP client {} stalled mid-message before the OPEN exchange, closing",
515                            client_id
516                        );
517                        Self::send_notification(write_half, wire::ERR_HOLD_TIMER_EXPIRED, 0, &[])
518                            .await;
519                        break;
520                    }
521                }
522            }
523
524            // RFC 4271 section 6.5 / section 10: the Hold Timer restarts on every KEEPALIVE,
525            // UPDATE or NOTIFICATION received. Recording it here, once the whole message is off
526            // the wire, covers all three without a per-type branch — and a peer that keeps
527            // talking is a peer that must not be dropped.
528            last_received.store(started.elapsed().as_secs(), Ordering::Relaxed);
529
530            trace!(
531                "BGP client {} received message type={} length={}",
532                client_id,
533                msg_type,
534                msg_len
535            );
536
537            // NOTIFICATION is handled from the raw octets rather than through netgauze, for
538            // the same reason the server encodes it by hand: netgauze models it as a closed
539            // enum of the (code, subcode) pairs it knows and cannot represent an arbitrary
540            // subcode, and a peer is free to send one. `parse_header` has already guaranteed
541            // at least 21 octets for this type, so both indexes are in range.
542            if msg_type == wire::MSG_NOTIFICATION {
543                let error_code = full[wire::BGP_HEADER_LEN];
544                let error_subcode = full[wire::BGP_HEADER_LEN + 1];
545                Self::handle_notification_message(
546                    error_code,
547                    error_subcode,
548                    client_id,
549                    llm_client,
550                    app_state,
551                    status_tx,
552                    client_data,
553                    shutdown,
554                )
555                .await;
556                // RFC 4271 section 4.5: a NOTIFICATION closes the connection and is never
557                // answered.
558                break;
559            }
560
561            let message = match wire::decode(&full, peer_asn4) {
562                Ok(m) => m,
563                Err(e) => {
564                    error!("BGP client {} could not parse message: {}", client_id, e);
565                    let _ = status_tx.send(format!("[CLIENT] BGP parse error: {e}"));
566                    let (code, subcode) = e.notify;
567                    Self::send_notification(write_half, code, subcode, &[]).await;
568                    return Err(anyhow!("Malformed BGP message: {e}"));
569                }
570            };
571
572            match message {
573                BgpMessage::Open(open) => {
574                    Self::handle_open_message(open, write_half, client_id, status_tx, client_data)
575                        .await?;
576
577                    // RFC 4271 section 8.2.2: the timers start at OpenConfirm, not at
578                    // Established. From the moment our KEEPALIVE went out the peer is entitled
579                    // to expect the negotiated cadence from us — and we are entitled to expect
580                    // one back, which is what the hold timer enforces. A second OPEN leaves
581                    // `timer_task` already set and is ignored here as it is there.
582                    if timer_task.is_none() {
583                        let hold = client_data.lock().await.negotiated_hold_time;
584                        if let Some(handle) = Self::spawn_timers(
585                            write_half.clone(),
586                            hold,
587                            client_id,
588                            last_received.clone(),
589                            started,
590                            shutdown.clone(),
591                            status_tx.clone(),
592                        ) {
593                            // The abort handle stays here for normal teardown; the `JoinHandle`
594                            // itself goes to `AppState` so `remove_client` aborts it too.
595                            // Aborting the read-loop task does NOT abort tasks it spawned, so an
596                            // unregistered timer task would outlive the client, holding the
597                            // write half — and with it the socket — open while it kept sending
598                            // keepalives to a peer nobody is reading.
599                            *timer_task = Some(handle.abort_handle());
600                            app_state.register_client_task(client_id, handle).await;
601                        }
602                    }
603                }
604                BgpMessage::KeepAlive => {
605                    Self::handle_keepalive_message(
606                        client_id,
607                        llm_client,
608                        app_state,
609                        status_tx,
610                        client_data,
611                        write_half,
612                        shutdown,
613                    )
614                    .await?;
615                }
616                BgpMessage::Update(update) => {
617                    Self::handle_update_message(
618                        &update,
619                        client_id,
620                        llm_client,
621                        app_state,
622                        status_tx,
623                        client_data,
624                        write_half,
625                        shutdown,
626                    )
627                    .await;
628                }
629                BgpMessage::RouteRefresh(_) => {
630                    // This client does not advertise the route-refresh capability, so a
631                    // conforming peer will not send one. Ignoring it beats tearing down a
632                    // healthy session over a message with no RIB behind it.
633                    warn!(
634                        "BGP client {} ignoring ROUTE-REFRESH: no RIB to re-advertise",
635                        client_id
636                    );
637                }
638                // Handled above from the raw octets; `decode` is never reached for it.
639                BgpMessage::Notification(_) => break,
640            }
641        }
642
643        Ok(())
644    }
645
646    /// Handle the peer's OPEN: validate, negotiate, and complete the handshake.
647    async fn handle_open_message(
648        open: netgauze_bgp_pkt::open::BgpOpenMessage,
649        write_half: &SharedWriter,
650        client_id: ClientId,
651        status_tx: &mpsc::UnboundedSender<String>,
652        client_data: &Arc<Mutex<ClientData>>,
653    ) -> Result<()> {
654        // Version, hold-time bounds and BGP Identifier validity are checked by the decoder;
655        // reaching here means they passed. What is left is what only we can judge.
656        //
657        // `my_asn4()` is the peer's *real* ASN: the four-octet-AS capability when it sent one,
658        // otherwise the two-octet field. The hand-rolled parser this replaced read the
659        // two-octet field only, so a four-octet peer was recorded as AS 23456 (AS_TRANS).
660        let peer_as = open.my_asn4();
661        if peer_as == 0 {
662            Self::send_notification(write_half, wire::ERR_OPEN, wire::SUB_BAD_PEER_AS, &[]).await;
663            bail!("BGP peer advertised AS 0");
664        }
665
666        let peer_hold = open.hold_time();
667        if peer_hold != 0 && peer_hold < MIN_HOLD_TIME {
668            Self::send_notification(
669                write_half,
670                wire::ERR_OPEN,
671                wire::SUB_UNACCEPTABLE_HOLD_TIME,
672                &[],
673            )
674            .await;
675            bail!("BGP peer proposed an unacceptable hold time of {peer_hold}s");
676        }
677
678        let peer_asn4 = open
679            .capabilities()
680            .into_iter()
681            .any(|c| matches!(c, BgpCapability::FourOctetAs(_)));
682        let peer_router_id = open.bgp_id().to_string();
683
684        let negotiated = {
685            let mut data = client_data.lock().await;
686            if data.bgp_state != BgpState::OpenSent {
687                warn!(
688                    "BGP client {} received a second OPEN in state {:?}, ignoring",
689                    client_id, data.bgp_state
690                );
691                return Ok(());
692            }
693            data.peer_as = Some(peer_as);
694            data.peer_router_id = Some(peer_router_id.clone());
695            data.peer_asn4 = peer_asn4;
696            data.negotiated_hold_time = negotiate_hold(data.proposed_hold_time, peer_hold);
697            data.bgp_state = BgpState::OpenConfirm;
698            data.negotiated_hold_time
699        };
700
701        info!(
702            "BGP client {} received OPEN: AS={}, hold_time={}s, router_id={}, asn4={}, negotiated hold {}s",
703            client_id, peer_as, peer_hold, peer_router_id, peer_asn4, negotiated
704        );
705        let _ = status_tx.send(format!(
706            "[CLIENT] BGP OPEN received: AS={peer_as}, hold_time={peer_hold}s, router_id={peer_router_id}"
707        ));
708
709        // RFC 4271 section 8.2.2: having sent our OPEN and received theirs, send a KEEPALIVE
710        // and enter OpenConfirm. Established is reached only when the peer's KEEPALIVE
711        // arrives; the previous implementation declared Established here, before the peer had
712        // confirmed anything.
713        write_half
714            .lock()
715            .await
716            .write_all(&wire::encode_keepalive())
717            .await
718            .context("Failed to send BGP KEEPALIVE")?;
719
720        info!(
721            "BGP client {} sent KEEPALIVE, entering OpenConfirm",
722            client_id
723        );
724        let _ = status_tx.send("[CLIENT] BGP KEEPALIVE sent".to_string());
725
726        Ok(())
727    }
728
729    /// Handle a KEEPALIVE. The hold timer has already been reset by the caller, which is the
730    /// entire semantics of a KEEPALIVE; the only other thing one can do is complete the
731    /// handshake the first time it arrives.
732    #[allow(clippy::too_many_arguments)]
733    async fn handle_keepalive_message(
734        client_id: ClientId,
735        llm_client: &OllamaClient,
736        app_state: &Arc<AppState>,
737        status_tx: &mpsc::UnboundedSender<String>,
738        client_data: &Arc<Mutex<ClientData>>,
739        write_half: &SharedWriter,
740        shutdown: &Arc<Shutdown>,
741    ) -> Result<()> {
742        debug!("BGP client {} received KEEPALIVE", client_id);
743
744        let (became_established, peer_as, peer_router_id, hold_time, peer_asn4) = {
745            let mut data = client_data.lock().await;
746            let became = data.bgp_state == BgpState::OpenConfirm;
747            if became {
748                data.bgp_state = BgpState::Established;
749            }
750            (
751                became,
752                data.peer_as,
753                data.peer_router_id.clone(),
754                data.negotiated_hold_time,
755                data.peer_asn4,
756            )
757        };
758
759        if !became_established {
760            let _ = status_tx.send("[CLIENT] BGP KEEPALIVE received".to_string());
761            return Ok(());
762        }
763
764        info!("BGP client {} session established", client_id);
765        let _ = status_tx.send("[CLIENT] BGP session established".to_string());
766
767        let remote_addr = app_state
768            .get_client(client_id)
769            .await
770            .map(|c| c.remote_addr)
771            .unwrap_or_default();
772
773        let event = Event::new(
774            &BGP_CLIENT_CONNECTED_EVENT,
775            serde_json::json!({
776                "remote_addr": remote_addr,
777                "peer_as": peer_as,
778                "peer_router_id": peer_router_id,
779                "hold_time": hold_time,
780                "peer_supports_four_octet_as": peer_asn4,
781            }),
782        );
783
784        Self::dispatch_event(
785            &event,
786            client_id,
787            llm_client,
788            app_state,
789            status_tx,
790            client_data,
791            Some(write_half),
792            shutdown,
793        )
794        .await;
795
796        Ok(())
797    }
798
799    /// Handle a peer UPDATE: report the decoded routes to the handler.
800    ///
801    /// The handler *does* get a reply path. It used to be passed `None`, on the reasoning that
802    /// "this client announces nothing, so an UPDATE handler cannot reply on the wire" — but that
803    /// conflates announcing routes with answering at all. This client's whole vocabulary is
804    /// `send_keepalive` / `send_notification` / `disconnect` / `wait_for_more`; none of them is
805    /// a route announcement, and a monitor that sees a prefix it must not accept wanting to tear
806    /// the session down is the obvious use for the event. So every action the model returned was
807    /// executed nowhere and logged as discarded, which is the "asks the model and throws the
808    /// answer away" defect the root CLAUDE.md describes, softened by a warning.
809    ///
810    /// `bgp_notification_received` keeps `None`, and that one is genuinely correct: RFC 4271
811    /// section 4.5 forbids answering a NOTIFICATION at all.
812    #[allow(clippy::too_many_arguments)]
813    async fn handle_update_message(
814        update: &netgauze_bgp_pkt::update::BgpUpdateMessage,
815        client_id: ClientId,
816        llm_client: &OllamaClient,
817        app_state: &Arc<AppState>,
818        status_tx: &mpsc::UnboundedSender<String>,
819        client_data: &Arc<Mutex<ClientData>>,
820        write_half: &SharedWriter,
821        shutdown: &Arc<Shutdown>,
822    ) {
823        info!(
824            "BGP client {} received UPDATE: {} withdrawn, {} announced",
825            client_id,
826            update.withdraw_routes().len(),
827            update.nlri().len()
828        );
829        let _ = status_tx.send(format!(
830            "[CLIENT] BGP UPDATE received: {} withdrawn, {} announced",
831            update.withdraw_routes().len(),
832            update.nlri().len()
833        ));
834
835        // Structured, one field per concept. This used to be `hex::encode(body)`, which the
836        // root CLAUDE.md forbids and which no model can act on.
837        let mut data = wire::update_to_json(update);
838        data["peer_as"] = serde_json::json!(client_data.lock().await.peer_as);
839
840        let event = Event::new(&BGP_CLIENT_UPDATE_RECEIVED_EVENT, data);
841
842        Self::dispatch_event(
843            &event,
844            client_id,
845            llm_client,
846            app_state,
847            status_tx,
848            client_data,
849            Some(write_half),
850            shutdown,
851        )
852        .await;
853    }
854
855    /// Handle a peer NOTIFICATION. The session is over; nothing is written back.
856    #[allow(clippy::too_many_arguments)]
857    async fn handle_notification_message(
858        error_code: u8,
859        error_subcode: u8,
860        client_id: ClientId,
861        llm_client: &OllamaClient,
862        app_state: &Arc<AppState>,
863        status_tx: &mpsc::UnboundedSender<String>,
864        client_data: &Arc<Mutex<ClientData>>,
865        shutdown: &Arc<Shutdown>,
866    ) {
867        let name = wire::error_name(error_code);
868        let subcode_name = wire::error_subcode_name(error_code, error_subcode);
869
870        error!(
871            "BGP client {} received NOTIFICATION: {} / {} (code={}, subcode={})",
872            client_id, name, subcode_name, error_code, error_subcode
873        );
874        let _ = status_tx.send(format!(
875            "[CLIENT] BGP NOTIFICATION: {name} / {subcode_name}"
876        ));
877
878        let event = Event::new(
879            &BGP_CLIENT_NOTIFICATION_RECEIVED_EVENT,
880            serde_json::json!({
881                "error_code": error_code,
882                "error_name": name,
883                "error_subcode": error_subcode,
884                "error_subcode_name": subcode_name,
885                "peer_as": client_data.lock().await.peer_as,
886            }),
887        );
888
889        Self::dispatch_event(
890            &event,
891            client_id,
892            llm_client,
893            app_state,
894            status_tx,
895            client_data,
896            None,
897            shutdown,
898        )
899        .await;
900    }
901
902    /// Run the handler for `event`, update memory, and execute whatever it returned.
903    ///
904    /// The memory string is copied out and the guard dropped *before* the LLM call. Holding it
905    /// across the await was a deadlock, not just a style violation: the success arm reacquired
906    /// the same non-reentrant `tokio::sync::Mutex` to store `memory_updates`, so any handler
907    /// that updated memory hung the client's read loop forever.
908    #[allow(clippy::too_many_arguments)]
909    async fn dispatch_event(
910        event: &Event,
911        client_id: ClientId,
912        llm_client: &OllamaClient,
913        app_state: &Arc<AppState>,
914        status_tx: &mpsc::UnboundedSender<String>,
915        client_data: &Arc<Mutex<ClientData>>,
916        write_half: Option<&SharedWriter>,
917        shutdown: &Arc<Shutdown>,
918    ) {
919        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
920            return;
921        };
922        let memory = client_data.lock().await.memory.clone();
923        let protocol = BgpClientProtocol::new();
924
925        match call_llm_for_client(
926            llm_client,
927            app_state,
928            client_id.to_string(),
929            &instruction,
930            &memory,
931            Some(event),
932            &protocol,
933            status_tx,
934        )
935        .await
936        {
937            Ok(ClientLlmResult {
938                actions,
939                memory_updates,
940            }) => {
941                if let Some(mem) = memory_updates {
942                    client_data.lock().await.memory = mem;
943                }
944                if let Some(write_half) = write_half {
945                    Self::execute_actions(actions, write_half, client_id, &protocol, shutdown)
946                        .await;
947                } else if !actions.is_empty() {
948                    // Silently dropping them would look like they had been sent.
949                    warn!(
950                        "BGP client {} discarded {} action(s) for event '{}': this event has no \
951                         reply path on the wire",
952                        client_id,
953                        actions.len(),
954                        event.id()
955                    );
956                }
957            }
958            Err(e) => {
959                error!("LLM error for BGP client {}: {}", client_id, e);
960            }
961        }
962    }
963
964    /// Send a NOTIFICATION, best-effort: the session is being torn down either way.
965    async fn send_notification(write_half: &SharedWriter, code: u8, subcode: u8, data: &[u8]) {
966        match wire::encode_notification(code, subcode, data) {
967            Ok(bytes) => {
968                let _ = write_half.lock().await.write_all(&bytes).await;
969            }
970            Err(e) => error!("BGP client could not encode NOTIFICATION {code}/{subcode}: {e}"),
971        }
972    }
973
974    /// Keepalive cadence and hold-timer enforcement, in one task.
975    ///
976    /// Two RFC 4271 obligations, both on the same clock:
977    ///
978    /// * section 10 — KeepaliveTime is a third of the negotiated HoldTime, at least one second.
979    ///   Without this the client sent a KEEPALIVE only in reply to one, so any peer enforcing
980    ///   its hold timer dropped the session after `hold_time` seconds of silence.
981    /// * sections 4.2 and 6.5 — when the Hold Timer expires, the speaker MUST send a
982    ///   NOTIFICATION with error code 4 (Hold Timer Expired) and close the session. Until this
983    ///   existed a silent peer was noticed only when TCP eventually noticed, which can be many
984    ///   minutes and, on a half-open connection with no traffic, never.
985    ///
986    /// It runs in its own task for the same reason the server's does: the read loop can be
987    /// parked in an LLM call, and a hold timer that only ticks between model calls is not a
988    /// timer. Expiry raises `shutdown`, which the read loop selects on, so the teardown happens
989    /// even mid-read.
990    ///
991    /// A zero hold time disables both timers (RFC 4271 section 4.2), so nothing is scheduled —
992    /// spinning a zero-second ticker would busy-loop and expire instantly.
993    #[allow(clippy::too_many_arguments)]
994    fn spawn_timers(
995        write_half: SharedWriter,
996        hold_time: u16,
997        client_id: ClientId,
998        last_received: Arc<AtomicU64>,
999        started: crate::utils::clock::Instant,
1000        shutdown: Arc<Shutdown>,
1001        status_tx: mpsc::UnboundedSender<String>,
1002    ) -> Option<tokio::task::JoinHandle<()>> {
1003        if hold_time == 0 {
1004            debug!(
1005                "BGP client {} negotiated hold time 0: keepalive and hold timers disabled",
1006                client_id
1007            );
1008            return None;
1009        }
1010        let interval = std::time::Duration::from_secs(u64::from(hold_time).div_ceil(3).max(1));
1011        Some(tokio::spawn(async move {
1012            let keepalive = wire::encode_keepalive();
1013            loop {
1014                tokio::time::sleep(interval).await;
1015
1016                let silent = started
1017                    .elapsed()
1018                    .as_secs()
1019                    .saturating_sub(last_received.load(Ordering::Relaxed));
1020                if silent >= u64::from(hold_time) {
1021                    error!(
1022                        "BGP client {} hold timer expired after {}s of silence (hold {}s)",
1023                        client_id, silent, hold_time
1024                    );
1025                    let _ = status_tx.send(format!(
1026                        "[CLIENT] BGP hold timer expired after {silent}s of silence, closing session"
1027                    ));
1028                    // Written from here rather than signalled to the read loop: the read loop
1029                    // may be inside an LLM call, and RFC 4271 wants the NOTIFICATION out before
1030                    // the connection goes away.
1031                    match wire::encode_notification(wire::ERR_HOLD_TIMER_EXPIRED, 0, &[]) {
1032                        Ok(bytes) => {
1033                            let _ = write_half.lock().await.write_all(&bytes).await;
1034                        }
1035                        Err(e) => {
1036                            error!("BGP client {client_id} could not encode NOTIFICATION 4/0: {e}")
1037                        }
1038                    }
1039                    shutdown.set();
1040                    return;
1041                }
1042
1043                if write_half.lock().await.write_all(&keepalive).await.is_err() {
1044                    debug!(
1045                        "BGP client {} keepalive task stopping: write failed",
1046                        client_id
1047                    );
1048                    return;
1049                }
1050                trace!("BGP client {} sent KEEPALIVE", client_id);
1051            }
1052        }))
1053    }
1054
1055    /// Execute actions from LLM
1056    async fn execute_actions(
1057        actions: Vec<serde_json::Value>,
1058        write_half: &SharedWriter,
1059        client_id: ClientId,
1060        protocol: &dyn Client,
1061        shutdown: &Arc<Shutdown>,
1062    ) {
1063        for action in actions {
1064            match protocol.execute_action(action) {
1065                Ok(result) => {
1066                    if Self::apply_result(result, write_half, client_id, shutdown).await {
1067                        break;
1068                    }
1069                }
1070                Err(e) => {
1071                    error!("BGP client {} action error: {}", client_id, e);
1072                }
1073            }
1074        }
1075    }
1076
1077    /// Apply one action result. Returns `true` when the session should stop processing.
1078    async fn apply_result(
1079        result: ClientActionResult,
1080        write_half: &SharedWriter,
1081        client_id: ClientId,
1082        shutdown: &Arc<Shutdown>,
1083    ) -> bool {
1084        match result {
1085            ClientActionResult::SendData(bytes) => {
1086                match write_half.lock().await.write_all(&bytes).await {
1087                    Ok(()) => trace!("BGP client {} sent {} bytes", client_id, bytes.len()),
1088                    Err(e) => error!("BGP client {} write failed: {}", client_id, e),
1089                }
1090                false
1091            }
1092            ClientActionResult::Disconnect => {
1093                info!("BGP client {} disconnecting", client_id);
1094                // Stopping the action loop is not stopping the session: without this the read
1095                // loop went straight back to `read_exact` and the `disconnect` action's own
1096                // description ("sends a Cease NOTIFICATION, then closes") was half true.
1097                shutdown.set();
1098                true
1099            }
1100            ClientActionResult::WaitForMore | ClientActionResult::NoAction => false,
1101            // `disconnect` is a Multiple: a Cease NOTIFICATION followed by the close. The
1102            // previous implementation logged this variant and discarded it, so the graceful
1103            // shutdown its own action description promised never reached the wire.
1104            ClientActionResult::Multiple(results) => {
1105                for inner in results {
1106                    if Box::pin(Self::apply_result(inner, write_half, client_id, shutdown)).await {
1107                        return true;
1108                    }
1109                }
1110                false
1111            }
1112            ClientActionResult::Custom { name, .. } => {
1113                debug!("BGP client {} custom action: {}", client_id, name);
1114                false
1115            }
1116        }
1117    }
1118}