Skip to main content

fips_core/control/
mod.rs

1//! Control socket for runtime management and observability.
2//!
3//! Provides a control interface that accepts commands and returns
4//! structured JSON responses. Supports both read-only queries (show_*)
5//! and mutating commands (connect, disconnect).
6//!
7//! Platform-specific implementations:
8//! - Unix: Uses a Unix domain socket for local IPC
9//! - Windows: Uses a TCP socket on localhost (see commit 3)
10
11pub mod commands;
12pub mod firewall_state;
13pub mod listening;
14pub mod protocol;
15pub mod queries;
16
17use crate::config::ControlConfig;
18use protocol::{Request, Response};
19use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
20use tokio::sync::{mpsc, oneshot};
21use tracing::{debug, info, warn};
22
23/// Maximum request size in bytes (4 KB).
24const MAX_REQUEST_SIZE: usize = 4096;
25
26/// I/O timeout for client connections.
27const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
28
29/// A message sent from the accept loop to the main event loop.
30pub type ControlMessage = (Request, oneshot::Sender<Response>);
31
32/// Split control-plane send handles.
33///
34/// Read-only `show_*` requests are safe to service in short reserved turns,
35/// while mutating commands may await transport/discovery work. Keeping them on
36/// separate channels prevents a slow command from blocking status queries that
37/// operators use during dataplane pressure.
38#[derive(Clone)]
39pub(crate) struct ControlSenders {
40    query_tx: mpsc::Sender<ControlMessage>,
41    command_tx: mpsc::Sender<ControlMessage>,
42}
43
44impl ControlSenders {
45    pub(crate) fn new(
46        query_tx: mpsc::Sender<ControlMessage>,
47        command_tx: mpsc::Sender<ControlMessage>,
48    ) -> Self {
49        Self {
50            query_tx,
51            command_tx,
52        }
53    }
54
55    fn single(control_tx: mpsc::Sender<ControlMessage>) -> Self {
56        Self {
57            query_tx: control_tx.clone(),
58            command_tx: control_tx,
59        }
60    }
61
62    async fn send(
63        &self,
64        message: ControlMessage,
65    ) -> Result<(), mpsc::error::SendError<ControlMessage>> {
66        if message.0.command.starts_with("show_") {
67            self.query_tx.send(message).await
68        } else {
69            self.command_tx.send(message).await
70        }
71    }
72}
73
74/// Handle a single client connection over any AsyncRead + AsyncWrite stream.
75///
76/// Shared between Unix and Windows implementations to avoid duplicating
77/// the request/response protocol logic.
78async fn handle_connection_generic<S>(
79    stream: S,
80    control_senders: ControlSenders,
81) -> Result<(), Box<dyn std::error::Error>>
82where
83    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
84{
85    let (reader, mut writer) = tokio::io::split(stream);
86    let mut buf_reader = BufReader::new(reader);
87    let mut line = String::new();
88
89    // Read one line with timeout and size limit
90    let read_result = tokio::time::timeout(IO_TIMEOUT, async {
91        let mut total = 0usize;
92        loop {
93            let n = buf_reader.read_line(&mut line).await?;
94            if n == 0 {
95                break; // EOF
96            }
97            total += n;
98            if total > MAX_REQUEST_SIZE {
99                return Err(std::io::Error::new(
100                    std::io::ErrorKind::InvalidData,
101                    "request too large",
102                ));
103            }
104            if line.ends_with('\n') {
105                break;
106            }
107        }
108        Ok(())
109    })
110    .await;
111
112    let response = match read_result {
113        Ok(Ok(())) if line.is_empty() => Response::error("empty request"),
114        Ok(Ok(())) => {
115            // Parse the request
116            match serde_json::from_str::<Request>(line.trim()) {
117                Ok(request) => {
118                    // Send to main loop and wait for response
119                    let (resp_tx, resp_rx) = oneshot::channel();
120                    if control_senders.send((request, resp_tx)).await.is_err() {
121                        Response::error("node shutting down")
122                    } else {
123                        match tokio::time::timeout(IO_TIMEOUT, resp_rx).await {
124                            Ok(Ok(resp)) => resp,
125                            Ok(Err(_)) => Response::error("response channel closed"),
126                            Err(_) => Response::error("query timeout"),
127                        }
128                    }
129                }
130                Err(e) => Response::error(format!("invalid request: {}", e)),
131            }
132        }
133        Ok(Err(e)) => Response::error(format!("read error: {}", e)),
134        Err(_) => Response::error("read timeout"),
135    };
136
137    // Write response with timeout
138    let json = serde_json::to_string(&response)?;
139    let write_result = tokio::time::timeout(IO_TIMEOUT, async {
140        writer.write_all(json.as_bytes()).await?;
141        writer.write_all(b"\n").await?;
142        writer.shutdown().await?;
143        Ok::<_, std::io::Error>(())
144    })
145    .await;
146
147    if let Err(_) | Ok(Err(_)) = write_result {
148        debug!("Control socket write failed or timed out");
149    }
150
151    Ok(())
152}
153
154// ============================================================================
155// Unix implementation
156// ============================================================================
157
158#[cfg(unix)]
159mod unix_impl {
160    use super::*;
161    use std::path::{Path, PathBuf};
162    use tokio::net::UnixListener;
163
164    /// Control socket listener (Unix domain socket).
165    ///
166    /// Manages the Unix domain socket lifecycle: bind, accept, cleanup.
167    pub struct ControlSocket {
168        listener: UnixListener,
169        socket_path: PathBuf,
170    }
171
172    impl ControlSocket {
173        /// Bind a new control socket.
174        ///
175        /// Creates parent directories if needed, removes stale socket files,
176        /// and binds the Unix listener.
177        pub fn bind(config: &ControlConfig) -> Result<Self, std::io::Error> {
178            let socket_path = PathBuf::from(&config.socket_path);
179
180            // Create parent directory if it doesn't exist
181            if let Some(parent) = socket_path.parent()
182                && !parent.exists()
183            {
184                std::fs::create_dir_all(parent)?;
185                debug!(path = %parent.display(), "Created control socket directory");
186            }
187
188            // Remove stale socket if it exists
189            if socket_path.exists() {
190                Self::remove_stale_socket(&socket_path)?;
191            }
192
193            let listener = UnixListener::bind(&socket_path)?;
194
195            // Make the socket and its parent directory group-accessible so
196            // 'fips' group members can use fipsctl/fipstop without root.
197            use std::os::unix::fs::PermissionsExt;
198            std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?;
199            Self::chown_to_fips_group(&socket_path);
200            if let Some(parent) = socket_path.parent() {
201                Self::chown_to_fips_group(parent);
202            }
203
204            info!(path = %socket_path.display(), "Control socket listening");
205
206            Ok(Self {
207                listener,
208                socket_path,
209            })
210        }
211
212        /// Remove a stale socket file.
213        ///
214        /// If the file exists but no one is listening, remove it so we can
215        /// bind. This handles unclean daemon exits.
216        fn remove_stale_socket(path: &Path) -> Result<(), std::io::Error> {
217            // Try connecting to see if someone is listening
218            match std::os::unix::net::UnixStream::connect(path) {
219                Ok(_) => {
220                    // Someone is listening — don't remove it
221                    Err(std::io::Error::new(
222                        std::io::ErrorKind::AddrInUse,
223                        format!("control socket already in use: {}", path.display()),
224                    ))
225                }
226                Err(_) => {
227                    // No one listening — remove the stale socket
228                    debug!(path = %path.display(), "Removing stale control socket");
229                    std::fs::remove_file(path)?;
230                    Ok(())
231                }
232            }
233        }
234
235        /// Set group ownership of a path to the 'fips' group (best-effort).
236        fn chown_to_fips_group(path: &Path) {
237            use std::ffi::CString;
238            use std::os::unix::ffi::OsStrExt;
239
240            // Look up the 'fips' group
241            let group_name = CString::new("fips").unwrap();
242            let grp = unsafe { libc::getgrnam(group_name.as_ptr()) };
243            if grp.is_null() {
244                debug!(
245                    "'fips' group not found, skipping chown for {}",
246                    path.display()
247                );
248                return;
249            }
250            let gid = unsafe { (*grp).gr_gid };
251
252            let c_path = match CString::new(path.as_os_str().as_bytes()) {
253                Ok(p) => p,
254                Err(_) => return,
255            };
256            let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) };
257            if ret != 0 {
258                warn!(
259                    path = %path.display(),
260                    error = %std::io::Error::last_os_error(),
261                    "Failed to chown control socket to 'fips' group"
262                );
263            }
264        }
265
266        /// Run the accept loop, forwarding requests to the main event loop via mpsc.
267        ///
268        /// Each accepted connection is handled in a spawned task:
269        /// 1. Read one line of JSON (the request)
270        /// 2. Send (Request, oneshot::Sender) to the main loop
271        /// 3. Wait for the response via oneshot
272        /// 4. Write the response as one line of JSON
273        /// 5. Close the connection
274        pub async fn accept_loop(self, control_tx: mpsc::Sender<ControlMessage>) {
275            self.accept_loop_split(ControlSenders::single(control_tx))
276                .await;
277        }
278
279        pub(crate) async fn accept_loop_split(self, control_senders: ControlSenders) {
280            loop {
281                let (stream, _addr) = match self.listener.accept().await {
282                    Ok(conn) => conn,
283                    Err(e) => {
284                        warn!(error = %e, "Control socket accept failed");
285                        continue;
286                    }
287                };
288
289                let senders = control_senders.clone();
290                tokio::spawn(async move {
291                    if let Err(e) = handle_connection_generic(stream, senders).await {
292                        debug!(error = %e, "Control connection error");
293                    }
294                });
295            }
296        }
297
298        /// Get the socket path.
299        pub fn socket_path(&self) -> &Path {
300            &self.socket_path
301        }
302
303        /// Clean up the socket file.
304        fn cleanup(&self) {
305            if self.socket_path.exists() {
306                if let Err(e) = std::fs::remove_file(&self.socket_path) {
307                    warn!(
308                        path = %self.socket_path.display(),
309                        error = %e,
310                        "Failed to remove control socket"
311                    );
312                } else {
313                    debug!(path = %self.socket_path.display(), "Control socket removed");
314                }
315            }
316        }
317    }
318
319    impl Drop for ControlSocket {
320        fn drop(&mut self) {
321            self.cleanup();
322        }
323    }
324}
325
326// ============================================================================
327// Windows implementation (TCP on localhost)
328// ============================================================================
329
330#[cfg(windows)]
331mod windows_impl {
332    use super::*;
333    use tokio::net::TcpListener;
334
335    /// Default TCP port for the control socket on Windows.
336    const DEFAULT_CONTROL_PORT: u16 = 21210;
337
338    /// Control socket listener (Windows TCP on localhost).
339    ///
340    /// On Windows, the control socket uses a TCP listener bound to
341    /// `127.0.0.1` since Windows does not support Unix domain sockets
342    /// reliably. Only localhost connections are accepted.
343    ///
344    /// Note: Unlike Unix domain sockets, TCP does not provide filesystem-level
345    /// ACLs. Any local user can connect to the control port. This is acceptable
346    /// for single-user Windows installations but should be documented.
347    pub struct ControlSocket {
348        listener: TcpListener,
349        port: u16,
350    }
351
352    impl ControlSocket {
353        /// Bind a TCP control socket on localhost.
354        ///
355        /// Parses the port from `config.socket_path` (which is a port number
356        /// string on Windows, e.g. "21210"). Falls back to the default port
357        /// with a warning if parsing fails.
358        pub fn bind(config: &ControlConfig) -> Result<Self, std::io::Error> {
359            let port: u16 = match config.socket_path.parse() {
360                Ok(p) => p,
361                Err(e) => {
362                    warn!(
363                        path = %config.socket_path,
364                        error = %e,
365                        default = DEFAULT_CONTROL_PORT,
366                        "Invalid control port, using default"
367                    );
368                    DEFAULT_CONTROL_PORT
369                }
370            };
371
372            let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
373            let std_listener = std::net::TcpListener::bind(addr)?;
374            std_listener.set_nonblocking(true)?;
375            let listener = TcpListener::from_std(std_listener)?;
376
377            info!(port = port, "Control socket listening on localhost");
378
379            Ok(Self { listener, port })
380        }
381
382        /// Get the listening port.
383        pub fn port(&self) -> u16 {
384            self.port
385        }
386
387        /// Run the accept loop, forwarding requests to the main event loop via mpsc.
388        ///
389        /// Each accepted connection is handled in a spawned task using the
390        /// shared `handle_connection_generic` protocol handler.
391        pub async fn accept_loop(self, control_tx: mpsc::Sender<ControlMessage>) {
392            self.accept_loop_split(ControlSenders::single(control_tx))
393                .await;
394        }
395
396        pub(crate) async fn accept_loop_split(self, control_senders: ControlSenders) {
397            loop {
398                let (stream, addr) = match self.listener.accept().await {
399                    Ok(conn) => conn,
400                    Err(e) => {
401                        warn!(error = %e, "Control socket accept failed");
402                        continue;
403                    }
404                };
405
406                // Only accept connections from localhost
407                if !addr.ip().is_loopback() {
408                    warn!(addr = %addr, "Rejected non-localhost control connection");
409                    continue;
410                }
411
412                let senders = control_senders.clone();
413                tokio::spawn(async move {
414                    if let Err(e) = handle_connection_generic(stream, senders).await {
415                        debug!(error = %e, "Control connection error");
416                    }
417                });
418            }
419        }
420    }
421}
422
423// Re-export platform-specific types
424#[cfg(unix)]
425pub use unix_impl::ControlSocket;
426#[cfg(windows)]
427pub use windows_impl::ControlSocket;
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[tokio::test]
434    async fn control_senders_route_show_queries_to_query_lane() {
435        let (query_tx, mut query_rx) = mpsc::channel(1);
436        let (command_tx, mut command_rx) = mpsc::channel(1);
437        let senders = ControlSenders::new(query_tx, command_tx);
438        let (resp_tx, _resp_rx) = oneshot::channel();
439
440        senders
441            .send((
442                Request {
443                    command: "show_status".to_string(),
444                    params: None,
445                },
446                resp_tx,
447            ))
448            .await
449            .unwrap();
450
451        let (request, _response_tx) = query_rx.recv().await.unwrap();
452        assert_eq!(request.command, "show_status");
453        assert!(command_rx.try_recv().is_err());
454    }
455
456    #[tokio::test]
457    async fn control_senders_route_mutations_to_command_lane() {
458        let (query_tx, mut query_rx) = mpsc::channel(1);
459        let (command_tx, mut command_rx) = mpsc::channel(1);
460        let senders = ControlSenders::new(query_tx, command_tx);
461        let (resp_tx, _resp_rx) = oneshot::channel();
462
463        senders
464            .send((
465                Request {
466                    command: "connect".to_string(),
467                    params: None,
468                },
469                resp_tx,
470            ))
471            .await
472            .unwrap();
473
474        let (request, _response_tx) = command_rx.recv().await.unwrap();
475        assert_eq!(request.command, "connect");
476        assert!(query_rx.try_recv().is_err());
477    }
478
479    #[cfg(windows)]
480    #[tokio::test]
481    async fn test_tcp_control_socket_bind() {
482        let config = ControlConfig {
483            enabled: true,
484            socket_path: "0".to_string(), // port 0 = ephemeral
485        };
486
487        // Verify the socket binds successfully on an ephemeral port
488        let _socket = ControlSocket::bind(&config).expect("failed to bind control socket");
489    }
490
491    #[cfg(windows)]
492    #[tokio::test]
493    async fn test_tcp_control_socket_invalid_port_uses_default() {
494        let config = ControlConfig {
495            enabled: true,
496            socket_path: "not-a-port".to_string(),
497        };
498
499        // Should fall back to default port 21210. This may fail if 21210
500        // is already in use, which is acceptable for a unit test.
501        let result = ControlSocket::bind(&config);
502        // We mainly verify it doesn't panic on invalid input
503        if let Ok(socket) = result {
504            assert_eq!(socket.port(), 21210);
505        }
506    }
507}