1pub mod actions;
5pub mod wire;
6
7use crate::llm::action_helper::call_llm;
8use crate::llm::actions::protocol_trait::ActionResult;
9use crate::logging::emit::Log;
10use crate::protocol::{Event, SpawnContext};
11use crate::server::connection::ConnectionId;
12use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
13use actions::Service;
14use anyhow::{Context, Result};
15use serde_json::{json, Value};
16use std::{net::SocketAddr, sync::Arc, time::Duration};
17use tokio::{
18 io::{AsyncReadExt, AsyncWriteExt},
19 net::{TcpListener, TcpStream, UdpSocket},
20};
21
22pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
24pub const MAX_IDLE_TIMEOUT_SECS: u64 = 3600;
25pub const DEFAULT_TRANSPORT: &str = "both";
26pub const DEFAULT_CHARGEN_LINE: usize = 72;
28pub const MAX_UDP_IN_FLIGHT: usize = 64;
30const BOTH_BIND_ATTEMPTS: usize = 16;
32
33#[derive(Clone, Copy, PartialEq)]
34enum Transport {
35 Tcp,
36 Udp,
37 Both,
38}
39
40fn config(ctx: &SpawnContext, service: Service) -> Result<(Transport, Duration)> {
41 let params = ctx.startup_params.as_ref();
42 let transport = match params
43 .map(|p| p.get_optional_string("transport"))
44 .transpose()?
45 .flatten()
46 .as_deref()
47 .unwrap_or(DEFAULT_TRANSPORT)
48 {
49 "tcp" => Transport::Tcp,
50 "udp" => Transport::Udp,
51 "both" => Transport::Both,
52 other => anyhow::bail!("transport must be tcp, udp or both, not {other}"),
53 };
54 let idle = if service.reads() {
55 let secs = params
56 .map(|p| p.get_optional_u64("idle_timeout_secs"))
57 .transpose()?
58 .flatten()
59 .unwrap_or(IDLE_TIMEOUT.as_secs());
60 anyhow::ensure!(
61 (1..=MAX_IDLE_TIMEOUT_SECS).contains(&secs),
62 "idle_timeout_secs must be between 1 and {MAX_IDLE_TIMEOUT_SECS}"
63 );
64 Duration::from_secs(secs)
65 } else {
66 IDLE_TIMEOUT
67 };
68 Ok((transport, idle))
69}
70
71async fn bind(
72 requested: SocketAddr,
73 transport: Transport,
74) -> Result<(Option<TcpListener>, Option<UdpSocket>)> {
75 match transport {
76 Transport::Tcp => Ok((Some(TcpListener::bind(requested).await?), None)),
77 Transport::Udp => Ok((None, Some(UdpSocket::bind(requested).await?))),
78 Transport::Both => {
79 for _ in 0..BOTH_BIND_ATTEMPTS {
80 let tcp = TcpListener::bind(requested).await?;
81 let port = tcp.local_addr()?.port();
82 match UdpSocket::bind(SocketAddr::new(requested.ip(), port)).await {
83 Ok(udp) => return Ok((Some(tcp), Some(udp))),
84 Err(e) if requested.port() != 0 => return Err(e.into()),
86 Err(_) => continue,
87 }
88 }
89 anyhow::bail!(
90 "no port number was free on both TCP and UDP after {BOTH_BIND_ATTEMPTS} attempts"
91 )
92 }
93 }
94}
95
96pub async fn spawn(ctx: SpawnContext, service: Service) -> Result<SocketAddr> {
97 let (transport, idle) = config(&ctx, service)?;
98 let (tcp, udp) = bind(ctx.legacy_listen_addr(), transport).await?;
99 let addr = match (&tcp, &udp) {
100 (Some(tcp), _) => tcp.local_addr()?,
101 (None, Some(udp)) => udp.local_addr()?,
102 _ => unreachable!("bind returns at least one socket"),
103 };
104 Log::new(Some(&ctx.status_tx)).info(format!(
105 "{} listening on {addr} ({})",
106 service.name(),
107 match transport {
108 Transport::Tcp => "tcp",
109 Transport::Udp => "udp",
110 Transport::Both => "tcp and udp",
111 }
112 ));
113 if let Some(listener) = tcp {
114 let child = ctx.clone();
115 let accept = tokio::spawn(accept_loop(child, service, listener, addr, idle));
116 ctx.state.register_server_task(ctx.server_id, accept).await;
117 }
118 if let Some(socket) = udp {
119 let child = ctx.clone();
120 let datagrams = tokio::spawn(udp_loop(child, service, Arc::new(socket)));
121 ctx.state
122 .register_server_task(ctx.server_id, datagrams)
123 .await;
124 }
125 Ok(addr)
126}
127
128async fn accept_loop(
129 ctx: SpawnContext,
130 service: Service,
131 listener: TcpListener,
132 addr: SocketAddr,
133 idle: Duration,
134) {
135 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(
136 crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS,
137 );
138 let server_id = ctx.server_id;
139 loop {
140 let (socket, peer, permit) = match crate::server::accept_bounded::accept_bounded(
141 &listener,
142 &limiter,
143 b"",
144 service.name(),
145 Some(&ctx.status_tx),
146 )
147 .await
148 {
149 Ok(v) => v,
150 Err(_) => break,
151 };
152 let id = ConnectionId::new(ctx.state.get_next_unified_id().await);
153 let now = crate::utils::clock::Instant::now();
154 ctx.state
155 .add_connection_to_server(
156 server_id,
157 ConnectionState {
158 id,
159 remote_addr: peer,
160 local_addr: addr,
161 bytes_sent: 0,
162 bytes_received: 0,
163 packets_sent: 0,
164 packets_received: 0,
165 last_activity: now,
166 status: ConnectionStatus::Active,
167 status_changed_at: now,
168 protocol_info: ProtocolConnectionInfo::empty(),
169 },
170 )
171 .await;
172 let child = ctx.clone();
173 ctx.state
174 .spawn_server_task(server_id, async move {
175 let _permit = permit;
176 if let Err(e) = tcp_session(&child, id, service, socket, peer, idle).await {
177 Log::new(Some(&child.status_tx))
178 .warn(format!("{} connection {id} ended: {e}", service.name()));
179 }
180 child
181 .state
182 .update_connection_status(server_id, id, ConnectionStatus::Closed)
183 .await;
184 let _ = child.status_tx.send("__UPDATE_UI__".into());
185 })
186 .await;
187 }
188}
189
190fn outcome(ctx: &SpawnContext, service: Service, id: Option<ConnectionId>, decision: &str) {
191 let who = id
192 .map(|id| format!("connection {id}"))
193 .unwrap_or_else(|| "datagram".into());
194 let summary = format!(
195 "{} {who} operation={} decision={decision}",
196 service.name(),
197 service.event().id
198 );
199 let log = Log::new(Some(&ctx.status_tx));
200 if decision.starts_with("fail_closed_") || decision == "model_silent" {
201 log.error(summary);
202 } else {
203 log.info(summary);
204 }
205}
206
207async fn decision(
210 ctx: &SpawnContext,
211 service: Service,
212 id: Option<ConnectionId>,
213 data: Value,
214) -> Result<Option<Value>> {
215 let event = Event::new(service.event_type(), data);
216 let result = match call_llm(
217 &ctx.llm_client,
218 &ctx.state,
219 ctx.server_id,
220 id,
221 &event,
222 actions::protocol(service),
223 )
224 .await
225 {
226 Ok(result) => result,
227 Err(error) => {
228 outcome(ctx, service, id, "fail_closed_llm_error");
229 return Err(error);
230 }
231 };
232 if !result.failures.is_empty() {
233 outcome(ctx, service, id, "fail_closed_invalid_reply");
234 anyhow::bail!("{} handler supplied an invalid action", service.name());
235 }
236 let mut found = None;
237 let mut pending = result.protocol_results;
238 while let Some(result) = pending.pop() {
239 match result {
240 ActionResult::Custom { name, data }
241 if name == service.action_name() || name == service.refuse_name() =>
242 {
243 if found.is_some() {
244 outcome(ctx, service, id, "fail_closed_invalid_reply");
245 anyhow::bail!("Multiple {} answers to one request", service.name());
246 }
247 found = Some((name == service.refuse_name(), data));
248 }
249 ActionResult::Multiple(items) => pending.extend(items),
250 _ => {}
251 }
252 }
253 match found {
254 Some((true, _)) => {
255 outcome(ctx, service, id, "model_reject");
256 Ok(None)
257 }
258 Some((false, answer)) => {
259 outcome(ctx, service, id, "model_answer");
260 Ok(Some(answer))
261 }
262 None => {
263 outcome(ctx, service, id, "model_silent");
264 anyhow::bail!("Handler did not answer the {} request", service.name())
265 }
266 }
267}
268
269async fn send(
270 ctx: &SpawnContext,
271 id: ConnectionId,
272 w: &mut (impl tokio::io::AsyncWrite + Unpin),
273 bytes: &[u8],
274) -> Result<()> {
275 tokio::time::timeout(wire::IO_TIMEOUT, w.write_all(bytes))
276 .await
277 .context("write deadline")??;
278 ctx.state
279 .update_connection_stats(
280 ctx.server_id,
281 id,
282 None,
283 Some(bytes.len() as u64),
284 None,
285 Some(1),
286 )
287 .await;
288 Ok(())
289}
290
291fn single_reply(service: Service, answer: &Value) -> Result<Vec<u8>> {
293 Ok(match service {
294 Service::Daytime => {
295 let text = match answer["text"].as_str() {
296 Some(text) => wire::line(text, wire::MAX_DAYTIME_CHARS),
297 None => wire::daytime_now(),
298 };
299 format!("{text}\r\n").into_bytes()
300 }
301 Service::Qotd => {
302 let quote: String = answer["quote"]
303 .as_str()
304 .unwrap_or_default()
305 .lines()
306 .map(crate::utils::sanitize::strip_controls)
307 .collect::<Vec<_>>()
308 .join("\n")
309 .chars()
310 .take(wire::MAX_QUOTE_CHARS)
311 .collect();
312 format!("{}\r\n", quote.replace('\n', "\r\n")).into_bytes()
313 }
314 Service::Time => wire::time_bytes(wire::requested_time(answer)?).to_vec(),
315 _ => anyhow::bail!("{} is not a single-reply service", service.name()),
316 })
317}
318
319struct Chargen {
320 charset: Vec<u8>,
321 width: usize,
322 max_bytes: Option<u64>,
323}
324
325fn chargen(answer: &Value) -> Result<Chargen> {
326 Ok(Chargen {
327 charset: wire::charset(answer.get("charset"))?.into_bytes(),
328 width: answer["line_length"]
329 .as_u64()
330 .map(|w| w as usize)
331 .unwrap_or(DEFAULT_CHARGEN_LINE),
332 max_bytes: answer["max_bytes"].as_u64(),
333 })
334}
335
336async fn tcp_session(
337 ctx: &SpawnContext,
338 id: ConnectionId,
339 service: Service,
340 socket: TcpStream,
341 peer: SocketAddr,
342 idle: Duration,
343) -> Result<()> {
344 let (mut reader, mut writer) = tokio::io::split(socket);
345 match service {
346 Service::Echo => {
347 let mut buf = vec![0u8; wire::READ_CHUNK];
348 loop {
349 let n = tokio::time::timeout(idle, reader.read(&mut buf))
350 .await
351 .context("idle deadline")??;
352 if n == 0 {
353 return Ok(());
354 }
355 ctx.state
356 .update_connection_stats(ctx.server_id, id, Some(n as u64), None, Some(1), None)
357 .await;
358 let (data, encoding) = wire::encode(&buf[..n]);
359 let answer = match decision(
360 ctx,
361 service,
362 Some(id),
363 json!({"transport":"tcp","data":data,"encoding":encoding,"bytes":n}),
364 )
365 .await
366 {
367 Ok(Some(answer)) => answer,
368 Ok(None) | Err(_) => {
370 let _ = writer.shutdown().await;
371 return Ok(());
372 }
373 };
374 let reply = match answer["data"].as_str() {
375 Some(data) => wire::decode(data, answer["encoding"].as_str())?,
376 None => buf[..n].to_vec(),
377 };
378 send(ctx, id, &mut writer, &reply).await?;
379 }
380 }
381 Service::Discard => {
382 let Ok(Some(answer)) = decision(
383 ctx,
384 service,
385 Some(id),
386 json!({"transport":"tcp","remote_addr":peer.to_string()}),
387 )
388 .await
389 else {
390 return Ok(());
391 };
392 let limit = answer["max_bytes"].as_u64();
393 let mut buf = vec![0u8; wire::READ_CHUNK];
394 let mut total: u64 = 0;
395 loop {
396 let n = tokio::time::timeout(idle, reader.read(&mut buf))
397 .await
398 .context("idle deadline")??;
399 if n == 0 {
400 return Ok(());
401 }
402 total += n as u64;
403 ctx.state
404 .update_connection_stats(ctx.server_id, id, Some(n as u64), None, Some(1), None)
405 .await;
406 if limit.is_some_and(|limit| total >= limit) {
407 let _ = writer.shutdown().await;
408 return Ok(());
409 }
410 }
411 }
412 Service::Daytime | Service::Qotd | Service::Time => {
413 if let Ok(Some(answer)) =
415 decision(ctx, service, Some(id), json!({"transport":"tcp"})).await
416 {
417 send(ctx, id, &mut writer, &single_reply(service, &answer)?).await?;
418 }
419 let _ = writer.shutdown().await;
420 Ok(())
421 }
422 Service::Chargen => {
423 let Ok(Some(answer)) =
424 decision(ctx, service, Some(id), json!({"transport":"tcp"})).await
425 else {
426 return Ok(());
427 };
428 let pattern = chargen(&answer)?;
429 let drain = async {
433 let mut sink = vec![0u8; wire::READ_CHUNK];
434 while let Ok(n) = reader.read(&mut sink).await {
435 if n == 0 {
436 break;
437 }
438 }
439 std::future::pending::<()>().await
440 };
441 let generate = async {
442 let mut sent: u64 = 0;
443 let mut n = 0usize;
444 loop {
445 let mut line = wire::chargen_line(&pattern.charset, pattern.width, n);
446 if let Some(max) = pattern.max_bytes {
447 let left = max.saturating_sub(sent) as usize;
448 if left == 0 {
449 break;
450 }
451 line.truncate(left);
452 }
453 if send(ctx, id, &mut writer, &line).await.is_err() {
454 break;
455 }
456 sent += line.len() as u64;
457 n += 1;
458 }
459 let _ = writer.shutdown().await;
460 };
461 tokio::pin!(drain);
462 tokio::select! {
463 _ = generate => {}
464 _ = &mut drain => {}
465 }
466 Ok(())
467 }
468 }
469}
470
471async fn udp_loop(ctx: SpawnContext, service: Service, socket: Arc<UdpSocket>) {
472 let in_flight = Arc::new(tokio::sync::Semaphore::new(MAX_UDP_IN_FLIGHT));
473 let mut buf = vec![0u8; wire::READ_CHUNK + 1];
474 let mut chargen_offset = 0usize;
475 loop {
476 let (n, peer) = match socket.recv_from(&mut buf).await {
477 Ok(v) => v,
478 Err(_) => continue,
479 };
480 if n > wire::READ_CHUNK {
481 Log::new(Some(&ctx.status_tx)).warn(format!(
482 "{} dropped an oversized datagram from {peer}",
483 service.name()
484 ));
485 continue;
486 }
487 if service == Service::Discard {
488 continue;
489 }
490 let Ok(permit) = in_flight.clone().try_acquire_owned() else {
491 Log::new(Some(&ctx.status_tx)).warn(format!(
492 "{} dropped a datagram from {peer}: too many in flight",
493 service.name()
494 ));
495 continue;
496 };
497 let datagram = buf[..n].to_vec();
498 let offset = chargen_offset;
499 chargen_offset = chargen_offset.wrapping_add(1);
500 let child = ctx.clone();
501 let reply_socket = socket.clone();
502 ctx.state
503 .spawn_server_task(ctx.server_id, async move {
504 let _permit = permit;
505 let data = match service {
506 Service::Echo => {
507 let (data, encoding) = wire::encode(&datagram);
508 json!({"transport":"udp","data":data,"encoding":encoding,"bytes":datagram.len()})
509 }
510 _ => json!({"transport":"udp"}),
511 };
512 let Ok(Some(answer)) = decision(&child, service, None, data).await else {
514 return;
515 };
516 let reply = match service {
517 Service::Echo => match answer["data"].as_str() {
518 Some(data) => wire::decode(data, answer["encoding"].as_str()),
519 None => Ok(datagram),
520 },
521 Service::Chargen => chargen(&answer).map(|p| {
522 let mut line = wire::chargen_line(&p.charset, p.width, offset);
523 line.truncate(wire::MAX_CHARGEN_UDP);
524 line
525 }),
526 other => single_reply(other, &answer),
527 };
528 match reply {
529 Ok(reply) => {
530 let _ = reply_socket.send_to(&reply, peer).await;
531 }
532 Err(e) => Log::new(Some(&child.status_tx)).warn(format!("{} reply to {peer} not sent: {e}", service.name())),
533 }
534 })
535 .await;
536 }
537}