netget/state/server.rs
1//! Server instance management
2
3use crate::utils::clock::Instant;
4use std::collections::{HashMap, VecDeque};
5use std::net::SocketAddr;
6use std::path::PathBuf;
7
8use crate::server::connection::ConnectionId;
9
10/// Unique identifier for a server instance
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
12pub struct ServerId(u32);
13
14impl ServerId {
15 /// Create a new server ID from a u32
16 pub fn new(id: u32) -> Self {
17 Self(id)
18 }
19
20 /// Get the raw ID value
21 pub fn as_u32(&self) -> u32 {
22 self.0
23 }
24
25 /// Parse from string (expects format "server-123" or just "123")
26 pub fn from_string(s: &str) -> Option<Self> {
27 let s = s.trim();
28 let id_str = s.strip_prefix("server-").unwrap_or(s);
29 id_str.parse::<u32>().ok().map(Self)
30 }
31}
32
33impl std::fmt::Display for ServerId {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 write!(f, "server-{}", self.0)
36 }
37}
38
39/// Status of a server instance
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ServerStatus {
42 /// Server is starting up
43 Starting,
44 /// Server is running and accepting connections
45 Running,
46 /// Server has been stopped
47 Stopped,
48 /// Server encountered an error
49 Error(String),
50}
51
52impl ServerStatus {
53 pub fn as_str(&self) -> &str {
54 match self {
55 Self::Starting => "Starting",
56 Self::Running => "Running",
57 Self::Stopped => "Stopped",
58 Self::Error(_) => "Error",
59 }
60 }
61}
62
63impl std::fmt::Display for ServerStatus {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 Self::Starting => write!(f, "Starting"),
67 Self::Running => write!(f, "Running"),
68 Self::Stopped => write!(f, "Stopped"),
69 Self::Error(msg) => write!(f, "Error: {}", msg),
70 }
71 }
72}
73
74/// Connection state for state machine (Idle/Processing/Accumulating)
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76pub enum ProtocolState {
77 /// Connection is idle, ready to process data
78 Idle,
79 /// LLM is currently processing a request
80 Processing,
81 /// LLM requested more data (WAIT_FOR_MORE)
82 Accumulating,
83}
84
85/// IMAP session state (RFC 3501)
86#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub enum ImapSessionState {
88 /// Not authenticated - initial state, only LOGIN/AUTHENTICATE allowed
89 NotAuthenticated,
90 /// Authenticated - user logged in, can access mailboxes
91 Authenticated,
92 /// Selected - mailbox selected for operations (FETCH, STORE, etc.)
93 Selected,
94 /// Logout - client issued LOGOUT, connection closing
95 Logout,
96}
97
98/// BGP session state (RFC 4271 FSM)
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum BgpSessionState {
101 /// Idle - initial state, waiting for connection
102 Idle,
103 /// Connect - TCP connection established, waiting to send OPEN
104 Connect,
105 /// Active - failed to connect, will retry
106 Active,
107 /// OpenSent - OPEN message sent, waiting for peer's OPEN
108 OpenSent,
109 /// OpenConfirm - OPEN received and validated, waiting for KEEPALIVE
110 OpenConfirm,
111 /// Established - full BGP session established, exchanging routes
112 Established,
113}
114
115/// OSPF neighbor state (RFC 2328 Section 10.1)
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum OspfNeighborState {
118 /// Down - initial state, no Hello received
119 Down,
120 /// Init - Hello received from neighbor
121 Init,
122 /// 2-Way - bidirectional communication established
123 TwoWay,
124 /// ExStart - master/slave negotiation for database exchange
125 ExStart,
126 /// Exchange - database description packets exchanged
127 Exchange,
128 /// Loading - link state requests sent
129 Loading,
130 /// Full - adjacency complete, databases synchronized
131 Full,
132}
133
134/// Protocol-specific connection information
135///
136/// This uses flexible storage to avoid centralized enum fighting between protocols.
137/// Each protocol defines its own structure and serializes it to JSON.
138///
139/// Note: This storage is primarily for UI display and metrics.
140/// Protocols maintain their own local connection data for I/O operations.
141#[derive(Debug, Clone)]
142pub struct ProtocolConnectionInfo {
143 /// Protocol-specific data as JSON
144 /// Protocols can serialize any structure they need here
145 pub data: serde_json::Value,
146}
147
148impl ProtocolConnectionInfo {
149 /// Create new protocol connection info from any serializable data
150 pub fn new<T: serde::Serialize>(data: T) -> Self {
151 Self {
152 data: serde_json::to_value(data).unwrap_or(serde_json::Value::Null),
153 }
154 }
155
156 /// Create empty protocol connection info
157 pub fn empty() -> Self {
158 Self {
159 data: serde_json::Value::Object(serde_json::Map::new()),
160 }
161 }
162
163 /// Get a reference to a field in the data
164 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
165 self.data.get(key)
166 }
167
168 /// Try to deserialize the data into a specific type
169 pub fn try_into<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
170 serde_json::from_value(self.data.clone())
171 }
172}
173
174/// Connection status
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum ConnectionStatus {
177 /// Connection is active
178 Active,
179 /// Connection has been closed
180 Closed,
181}
182
183/// Connection state within a server
184#[derive(Debug, Clone)]
185pub struct ConnectionState {
186 /// Connection ID
187 pub id: ConnectionId,
188 /// Remote address
189 pub remote_addr: SocketAddr,
190 /// Local address
191 pub local_addr: SocketAddr,
192 /// Bytes sent
193 pub bytes_sent: u64,
194 /// Bytes received
195 pub bytes_received: u64,
196 /// Packets sent
197 pub packets_sent: u64,
198 /// Packets received
199 pub packets_received: u64,
200 /// Last activity timestamp
201 pub last_activity: Instant,
202 /// Connection status (Active/Closed)
203 pub status: ConnectionStatus,
204 /// When status last changed (for cleanup reaper)
205 pub status_changed_at: Instant,
206 /// Protocol-specific information
207 pub protocol_info: ProtocolConnectionInfo,
208}
209
210/// How many closed connections a server remembers for the "recently
211/// connected" view. Live connections are reaped ~10s after closing (see the
212/// cleanup reapers in `app_state.rs`), so without this history they vanish
213/// without a trace.
214pub const RECENT_CONNECTION_CAPACITY: usize = 50;
215
216/// Summary of a connection that has closed and been removed from
217/// [`ServerInstance::connections`]. Wall-clock timestamps (not `Instant`) so
218/// the struct is serializable and meaningful across a UI session.
219#[derive(Debug, Clone, serde::Serialize)]
220pub struct ClosedConnectionSummary {
221 /// The connection's id while it was live ("conn-N").
222 pub id: u32,
223 pub remote_addr: String,
224 /// None when the connection was inserted without passing through
225 /// `add_connection` (direct map writes bypass the opened-at bookkeeping).
226 pub opened_unix_ms: Option<u64>,
227 pub closed_unix_ms: u64,
228 pub bytes_sent: u64,
229 pub bytes_received: u64,
230 pub packets_sent: u64,
231 pub packets_received: u64,
232 /// Snapshot of the protocol-specific display info at close time.
233 pub protocol_info: serde_json::Value,
234}
235
236/// Wall-clock milliseconds for a past `Instant` (the `SystemTime::now() -
237/// elapsed` idiom used elsewhere in this file for log naming).
238fn instant_to_unix_ms(at: Instant) -> u64 {
239 let system_time = crate::utils::clock::SystemTime::now() - at.elapsed();
240 system_time
241 .duration_since(crate::utils::clock::UNIX_EPOCH)
242 .map(|d| d.as_millis() as u64)
243 .unwrap_or(0)
244}
245
246/// A server instance with its own connections, state, and configuration
247///
248/// `Clone` is derived. It used to hold a `JoinHandle` — which is not `Clone` — so every
249/// caller that wanted a copy wrote the fields out by hand and each new field had to be added
250/// to all four copies or be silently dropped. Background tasks now live in
251/// `AppState::register_server_task`, so the handle is gone and the hand-written copies with
252/// it.
253#[derive(Debug, Clone)]
254pub struct ServerInstance {
255 /// Unique server ID
256 pub id: ServerId,
257 /// Listening port
258 pub port: u16,
259 /// Protocol name (e.g., "TCP", "HTTP", "SSH")
260 pub protocol_name: String,
261 /// User instructions for this server
262 pub instruction: String,
263 /// LLM memory for this server
264 pub memory: String,
265 /// Server status
266 pub status: ServerStatus,
267 /// Active connections for this server
268 pub connections: HashMap<ConnectionId, ConnectionState>,
269 /// When the server was created
270 pub created_at: Instant,
271 /// When the server status last changed (for cleanup reaper)
272 pub status_changed_at: Instant,
273 /// Local listening address
274 pub local_addr: Option<SocketAddr>,
275 /// Protocol-specific startup parameters
276 pub startup_params: Option<serde_json::Value>,
277 /// Event handler configuration for handling protocol events
278 pub event_handler_config: Option<crate::scripting::EventHandlerConfig>,
279 /// Protocol-specific server data (flexible storage)
280 ///
281 /// This replaces protocol-specific feature-gated fields.
282 /// Each protocol can store any data structure here by serializing to JSON.
283 /// Use get_protocol_data() and set_protocol_data() helper methods.
284 pub protocol_data: serde_json::Value,
285 /// Log file paths (output_name -> log_file_path)
286 pub log_files: HashMap<String, PathBuf>,
287 /// Feedback instructions for automatic server adjustment
288 /// When set, network requests can provide feedback that triggers LLM-based server adjustments
289 pub feedback_instructions: Option<String>,
290 /// Accumulated feedback buffer (cleared after processing)
291 /// Each entry is a JSON value containing feedback data
292 pub feedback_buffer: Vec<serde_json::Value>,
293 /// Last time feedback was processed (for debouncing)
294 pub last_feedback_processed: Option<Instant>,
295 /// Recently closed connections, newest first, capped at
296 /// [`RECENT_CONNECTION_CAPACITY`].
297 pub recent_connections: VecDeque<ClosedConnectionSummary>,
298 /// When each live connection was added, for
299 /// [`ClosedConnectionSummary::opened_unix_ms`]. `ConnectionState` itself
300 /// is built as a struct literal in ~117 protocol call sites, so the
301 /// open-time lives here instead of as a new field there. Public only
302 /// because `ServerInstance` is built as a struct literal in a few places;
303 /// treat it as `add_connection`/`remove_connection` bookkeeping.
304 pub connection_opened_at: HashMap<ConnectionId, Instant>,
305}
306
307/// Longest accepted `append_to_log` output name.
308pub const MAX_LOG_OUTPUT_NAME_LEN: usize = 64;
309
310/// Check an `append_to_log` `output_name` before it becomes part of a filename.
311///
312/// Same allowlist as `sqlite::validate_database_name`, for the same reason: the value is
313/// model-authored, the model is influenced by the peer, and a name is **rejected** rather
314/// than rewritten so the model's own bookkeeping (it reuses the name) matches what is on
315/// disk.
316pub fn validate_log_output_name(name: &str) -> anyhow::Result<()> {
317 if name.is_empty() {
318 anyhow::bail!(
319 "Invalid output_name: it must not be empty. Allowed: 1-{} characters from A-Z, \
320 a-z, 0-9, '_' and '-' (it becomes part of the filename netget_<name>_<time>.log).",
321 MAX_LOG_OUTPUT_NAME_LEN
322 );
323 }
324 if name.len() > MAX_LOG_OUTPUT_NAME_LEN {
325 anyhow::bail!(
326 "Invalid output_name '{}': {} bytes exceeds the {}-byte limit.",
327 name,
328 name.len(),
329 MAX_LOG_OUTPUT_NAME_LEN
330 );
331 }
332 if let Some(bad) = name
333 .chars()
334 .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
335 {
336 anyhow::bail!(
337 "Invalid output_name '{}': character {:?} is not allowed. An output name may \
338 contain only A-Z, a-z, 0-9, '_' and '-' (it becomes part of the filename \
339 netget_<name>_<time>.log).",
340 name,
341 bad
342 );
343 }
344 Ok(())
345}
346
347impl ServerInstance {
348 /// Create a new server instance
349 pub fn new(id: ServerId, port: u16, protocol_name: String, instruction: String) -> Self {
350 let now = Instant::now();
351 Self {
352 id,
353 port,
354 protocol_name,
355 instruction,
356 memory: String::new(),
357 status: ServerStatus::Starting,
358 connections: HashMap::new(),
359 created_at: now,
360 status_changed_at: now,
361 local_addr: None,
362 startup_params: None,
363 event_handler_config: None,
364 protocol_data: serde_json::Value::Object(serde_json::Map::new()),
365 log_files: HashMap::new(),
366 feedback_instructions: None,
367 feedback_buffer: Vec::new(),
368 last_feedback_processed: None,
369 recent_connections: VecDeque::new(),
370 connection_opened_at: HashMap::new(),
371 }
372 }
373
374 /// Get protocol-specific data
375 pub fn get_protocol_data<T: serde::de::DeserializeOwned>(
376 &self,
377 ) -> Result<T, serde_json::Error> {
378 serde_json::from_value(self.protocol_data.clone())
379 }
380
381 /// Set protocol-specific data
382 pub fn set_protocol_data<T: serde::Serialize>(
383 &mut self,
384 data: T,
385 ) -> Result<(), serde_json::Error> {
386 self.protocol_data = serde_json::to_value(data)?;
387 Ok(())
388 }
389
390 /// Get a field from protocol data
391 pub fn get_protocol_field(&self, key: &str) -> Option<&serde_json::Value> {
392 self.protocol_data.get(key)
393 }
394
395 /// Set a field in protocol data
396 pub fn set_protocol_field(&mut self, key: String, value: serde_json::Value) {
397 if let Some(obj) = self.protocol_data.as_object_mut() {
398 obj.insert(key, value);
399 } else {
400 // Initialize as object if not already
401 let mut map = serde_json::Map::new();
402 map.insert(key, value);
403 self.protocol_data = serde_json::Value::Object(map);
404 }
405 }
406
407 /// Get or create a log file path for the given output name.
408 ///
409 /// Format: `netget_<output_name>_<timestamp>_s<server id>.log`, in the process's working
410 /// directory. The timestamp is when the *server* was created, so every line a server
411 /// writes to one output name lands in one file for that server's whole life.
412 ///
413 /// **The server id is what makes the name unique, and it was missing.** The timestamp has
414 /// one-second resolution, so two servers started in the same second and asked for the same
415 /// output name — which is the norm, since `access_logs` is the name eleven tests and most
416 /// example prompts use — produced the *same path* and appended into each other's file.
417 /// Nothing detected that: each server's own `log_files` map is per-server and perfectly
418 /// consistent, so both believed they owned the file.
419 ///
420 /// **`output_name` is model-authored and becomes part of a filename**, so it is checked
421 /// against the same allowlist as a database name and refused otherwise. The prefix and
422 /// suffix meant `..` could only escape through a directory literally named `netget_<x>`
423 /// in the working directory, which is one `mkdir` away from an append into any file the
424 /// process can write, and `append_to_log` is offered to the model on network events.
425 pub fn get_or_create_log_path(&mut self, output_name: &str) -> anyhow::Result<PathBuf> {
426 validate_log_output_name(output_name)?;
427 if let Some(path) = self.log_files.get(output_name) {
428 return Ok(path.clone());
429 }
430
431 // Calculate the absolute time when the server was created
432 // by subtracting the elapsed time from now
433 let now = crate::utils::clock::SystemTime::now();
434 let elapsed = self.created_at.elapsed();
435 let created_system_time = now - elapsed;
436
437 // Convert to DateTime for formatting
438 let timestamp = crate::utils::clock::to_local_datetime(created_system_time);
439 let timestamp_str = timestamp.format("%Y_%m_%d_%H_%M_%S").to_string();
440
441 let log_filename = format!(
442 "netget_{}_{}_s{}.log",
443 output_name,
444 timestamp_str,
445 self.id.as_u32()
446 );
447 let log_path = PathBuf::from(log_filename);
448
449 self.log_files
450 .insert(output_name.to_string(), log_path.clone());
451 Ok(log_path)
452 }
453
454 /// Get a summary for display
455 pub fn summary(&self) -> String {
456 format!(
457 "#{} {} on port {} ({}) - {} connections",
458 self.id.as_u32(),
459 self.protocol_name,
460 self.port,
461 self.status.as_str(),
462 self.connections.len()
463 )
464 }
465
466 /// Add a connection to this server
467 pub fn add_connection(&mut self, state: ConnectionState) {
468 self.connection_opened_at.insert(state.id, Instant::now());
469 self.connections.insert(state.id, state);
470 }
471
472 /// Remove a connection from this server, remembering it in
473 /// [`Self::recent_connections`].
474 pub fn remove_connection(&mut self, id: ConnectionId) -> Option<ConnectionState> {
475 let removed = self.connections.remove(&id);
476 if let Some(state) = &removed {
477 self.record_closed_connection(state);
478 }
479 removed
480 }
481
482 /// Push a closed-connection summary, newest first, trimming to capacity.
483 fn record_closed_connection(&mut self, state: &ConnectionState) {
484 let opened_unix_ms = self
485 .connection_opened_at
486 .remove(&state.id)
487 .map(instant_to_unix_ms);
488 self.recent_connections.push_front(ClosedConnectionSummary {
489 id: state.id.as_u32(),
490 remote_addr: state.remote_addr.to_string(),
491 opened_unix_ms,
492 closed_unix_ms: instant_to_unix_ms(state.status_changed_at),
493 bytes_sent: state.bytes_sent,
494 bytes_received: state.bytes_received,
495 packets_sent: state.packets_sent,
496 packets_received: state.packets_received,
497 protocol_info: state.protocol_info.data.clone(),
498 });
499 self.recent_connections.truncate(RECENT_CONNECTION_CAPACITY);
500 }
501
502 /// Get a connection by ID
503 pub fn get_connection(&self, id: ConnectionId) -> Option<&ConnectionState> {
504 self.connections.get(&id)
505 }
506
507 /// Get a mutable connection by ID
508 pub fn get_connection_mut(&mut self, id: ConnectionId) -> Option<&mut ConnectionState> {
509 self.connections.get_mut(&id)
510 }
511
512 /// Get all connections
513 pub fn get_all_connections(&self) -> Vec<&ConnectionState> {
514 self.connections.values().collect()
515 }
516
517 /// Clean up old connectionless protocol entries (UDP, DNS, etc.)
518 pub fn cleanup_old_connections(&mut self, max_age_secs: u64) {
519 let now = Instant::now();
520 let stale: Vec<ConnectionId> = self
521 .connections
522 .values()
523 .filter(|state| now.duration_since(state.last_activity).as_secs() >= max_age_secs)
524 .map(|state| state.id)
525 .collect();
526 for id in stale {
527 self.remove_connection(id);
528 }
529 }
530}