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}