Skip to main content

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}