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}