Skip to main content

flint_lean/
uds.rs

1//! The UDS door (§2.5, Phase 5) — `.flint-sync/ctl.sock`.
2//!
3//! Three verbs: `POST /v1/boundary`, `POST /v1/sync`, `GET /v1/status`.
4//! It is deliberately **sugar over the file protocol**, not a second
5//! protocol: a request lands in the same pending record a
6//! `.flint/publish` touch would, so min-interval, coalescing, the
7//! work-metered budget, the covered-nonce ack and every crash rule
8//! apply to it without a second implementation that could disagree
9//! with the first. What the socket buys is a SYNCHRONOUS answer —
10//! the caller gets the ack in the response instead of polling
11//! `.flint/publish.ack`.
12//!
13//! Why it is pod-internal only, and stays that way: the socket lives in
14//! the state directory, which is outside the scan by the shipped
15//! exclusion and inside the emptyDir every container in the pod shares.
16//! There is no TCP listener and no authentication, because the trust
17//! boundary is the pod — exactly as it is for the file sentinels, which
18//! any process in the pod can already touch (§3 residual 6). A TCP
19//! listener would be a new remote surface, which is a stated non-goal.
20//!
21//! The listener owns NO syncer state. It forwards requests over a
22//! channel to the run loop, which is the single thread that holds the
23//! lease and the state directory — the same reason `flint-sync status`
24//! reads files instead of claiming anything.
25
26use std::path::{Path, PathBuf};
27
28use tokio::sync::{mpsc, oneshot};
29
30/// What the socket asks the run loop to do.
31#[derive(Debug)]
32pub enum CtlRequest {
33    /// Honor a boundary now (subject to min-interval and budget).
34    Boundary { note: Option<String>, reply: oneshot::Sender<serde_json::Value> },
35    /// Run the sync verb. Unlike the GATEWAY's sync request — which is
36    /// carried, never performed (D14) — this one executes, because the
37    /// caller is inside the pod: it is the agent asking for its own
38    /// tree to be updated, which is the agent's own decision to make.
39    Sync { reply: oneshot::Sender<serde_json::Value> },
40    /// The same report `flint-sync status` renders.
41    Status { reply: oneshot::Sender<serde_json::Value> },
42}
43
44/// Bind the socket, replacing a stale one left by a crashed container.
45///
46/// A leftover socket file is the normal case after a container restart
47/// (the emptyDir survives, the process does not), and `bind` fails with
48/// EADDRINUSE on it. Refusing to start on that would make the door
49/// work exactly once per pod.
50pub fn bind(path: &Path) -> std::io::Result<tokio::net::UnixListener> {
51    if let Ok(m) = std::fs::symlink_metadata(path) {
52        // Only ever unlink a SOCKET. A regular file or a symlink here is
53        // not ours to remove — it is either someone else's data or an
54        // attempt to make us delete something.
55        use std::os::unix::fs::FileTypeExt;
56        if !m.file_type().is_socket() {
57            return Err(std::io::Error::new(
58                std::io::ErrorKind::AlreadyExists,
59                "control socket path is occupied by a non-socket",
60            ));
61        }
62        let _ = std::fs::remove_file(path);
63    }
64    tokio::net::UnixListener::bind(path)
65}
66
67/// Serve until the listener dies. One connection, one request, one
68/// response — HTTP/1.1 with no keep-alive, which is all `curl
69/// --unix-socket` and every agent library needs.
70pub async fn serve(listener: tokio::net::UnixListener, tx: mpsc::Sender<CtlRequest>) {
71    loop {
72        let (stream, _) = match listener.accept().await {
73            Ok(v) => v,
74            Err(e) => {
75                eprintln!("flint-sync: ctl.sock accept failed: {e}");
76                return;
77            }
78        };
79        let tx = tx.clone();
80        tokio::spawn(async move {
81            if let Err(e) = handle_conn(stream, tx).await {
82                eprintln!("flint-sync: ctl.sock connection: {e}");
83            }
84        });
85    }
86}
87
88const MAX_REQUEST_BYTES: usize = 16 * 1024;
89
90async fn handle_conn(
91    mut stream: tokio::net::UnixStream,
92    tx: mpsc::Sender<CtlRequest>,
93) -> std::io::Result<()> {
94    use tokio::io::{AsyncReadExt, AsyncWriteExt};
95
96    // Read only the request line + headers. Bounded, because a hung or
97    // hostile in-pod caller must not be able to grow this task without
98    // limit — the same rule the sentinel body reader follows.
99    let mut buf = Vec::new();
100    let mut chunk = [0u8; 1024];
101    loop {
102        let n = stream.read(&mut chunk).await?;
103        if n == 0 {
104            break;
105        }
106        buf.extend_from_slice(&chunk[..n]);
107        if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() >= MAX_REQUEST_BYTES {
108            break;
109        }
110    }
111    let head = String::from_utf8_lossy(&buf);
112    let mut parts = head.lines().next().unwrap_or("").split_whitespace();
113    let method = parts.next().unwrap_or("");
114    let path = parts.next().unwrap_or("");
115
116    let (code, body) = match (method, path) {
117        ("POST", "/v1/boundary") => {
118            let (reply, rx) = oneshot::channel();
119            match tx.send(CtlRequest::Boundary { note: None, reply }).await {
120                Ok(()) => match rx.await {
121                    Ok(v) => (200, v),
122                    Err(_) => (503, json_err("the syncer did not answer")),
123                },
124                Err(_) => (503, json_err("the syncer run loop is gone")),
125            }
126        }
127        ("POST", "/v1/sync") => {
128            let (reply, rx) = oneshot::channel();
129            match tx.send(CtlRequest::Sync { reply }).await {
130                Ok(()) => match rx.await {
131                    Ok(v) => (200, v),
132                    Err(_) => (503, json_err("the syncer did not answer")),
133                },
134                Err(_) => (503, json_err("the syncer run loop is gone")),
135            }
136        }
137        ("GET", "/v1/status") => {
138            let (reply, rx) = oneshot::channel();
139            match tx.send(CtlRequest::Status { reply }).await {
140                Ok(()) => match rx.await {
141                    Ok(v) => (200, v),
142                    Err(_) => (503, json_err("the syncer did not answer")),
143                },
144                Err(_) => (503, json_err("the syncer run loop is gone")),
145            }
146        }
147        _ => (
148            404,
149            json_err("unknown verb (POST /v1/boundary, POST /v1/sync, GET /v1/status)"),
150        ),
151    };
152    let body = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
153    let resp = format!(
154        "HTTP/1.1 {code} {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\
155         connection: close\r\n\r\n",
156        match code {
157            200 => "OK",
158            404 => "Not Found",
159            _ => "Service Unavailable",
160        },
161        body.len()
162    );
163    stream.write_all(resp.as_bytes()).await?;
164    stream.write_all(&body).await?;
165    stream.flush().await?;
166    Ok(())
167}
168
169fn json_err(msg: &str) -> serde_json::Value {
170    serde_json::json!({ "status": "error", "message": msg })
171}
172
173/// Where the socket lives: the state directory, which is outside the
174/// scan and shared by every container in the pod.
175pub fn socket_path(state_dir: &Path) -> PathBuf {
176    state_dir.join("ctl.sock")
177}