Skip to main content

liminal_cli/
seat.rs

1//! AGT-006 R3: a seat names the central services by default, `liminal seat
2//! init` writes them, and a seat on an unanswering loopback address is
3//! refused by name before the MCP handshake.
4
5use std::fs::OpenOptions;
6use std::io::Write;
7use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
8use std::os::unix::fs::OpenOptionsExt;
9use std::path::Path;
10use std::time::Duration;
11
12/// The central exchange's WebSocket door (AGT-006 R1).
13pub const CENTRAL_SERVER: &str = "wss://liminal.ablative.com.au/ws";
14
15/// The central Lys.
16pub const CENTRAL_ISSUER: &str = "https://lys.ablative.com.au";
17
18/// The channel a new seat's routed requests travel on.
19const DEFAULT_CHANNEL: &str = "console.topology";
20
21/// The limits a new seat is written with, and a seat named only by flags takes.
22const DEFAULT_LIMITS: [(&str, i64); 6] = [
23    ("frame_bytes", 65536),
24    ("record_bytes", 1_048_576),
25    ("reply_timeout_ms", 10000),
26    ("flow_steps", 8),
27    ("request_bytes", 65536),
28    ("answer_bytes", 16_777_216),
29];
30
31/// A seat with no file: the default channel and limits, the central
32/// exchange by the server's own default, and no credential until a flag
33/// names one.
34pub(crate) fn defaults() -> toml::Table {
35    let limits: toml::Table = DEFAULT_LIMITS
36        .iter()
37        .map(|(field, value)| ((*field).to_owned(), toml::Value::Integer(*value)))
38        .collect();
39    toml::Table::from_iter([
40        (
41            "channel".to_owned(),
42            toml::Value::String(DEFAULT_CHANNEL.to_owned()),
43        ),
44        ("limits".to_owned(), toml::Value::Table(limits)),
45    ])
46}
47
48/// The `[limits]` table as `liminal seat init` writes it.
49fn limits_text() -> String {
50    DEFAULT_LIMITS
51        .iter()
52        .map(|(field, value)| format!("{field} = {value}\n"))
53        .fold(String::from("[limits]\n"), |text, line| text + &line)
54}
55
56/// Why `liminal seat init` wrote nothing.
57#[derive(Debug, thiserror::Error)]
58pub enum SeatInitError {
59    /// The agent id is empty or is not one line of printable characters.
60    #[error("liminal_seat_agent_invalid: the agent id must be one line of printable characters")]
61    Agent,
62    /// The key file path cannot be written into the seat as text.
63    #[error("liminal_seat_key_file_invalid: {0} is not a UTF-8 path")]
64    KeyFile(String),
65    /// The open exchange address is empty or is not one line of printable characters.
66    #[error("liminal_seat_server_invalid: the server must be one line of printable characters")]
67    Server,
68    /// The declared name is not 1 to 64 of `a-z`, `0-9`, `.`, `_` or `-` (ACCESS-012 R2).
69    #[error("liminal_seat_name_invalid: the name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'")]
70    Name,
71    /// The seat file exists or cannot be created; it is never overwritten.
72    #[error("liminal_seat_unwritable: {path}: {message}")]
73    Unwritable {
74        /// The seat file asked for.
75        path: String,
76        /// The operating system's words.
77        message: String,
78    },
79}
80
81/// Writes a seat file at `out`, mode 0600, naming the central exchange and
82/// Lys, the agent and its key file, and never a pass. An existing file is
83/// refused, never replaced.
84///
85/// # Errors
86/// An invalid agent id or key path, or a file that exists or cannot be made.
87pub fn init(agent: &str, key_file: &Path, out: &Path) -> Result<(), SeatInitError> {
88    if agent.is_empty()
89        || agent
90            .chars()
91            .any(|c| c.is_control() || c == '"' || c == '\\')
92    {
93        return Err(SeatInitError::Agent);
94    }
95    let key = key_file
96        .to_str()
97        .filter(|k| !k.chars().any(|c| c.is_control() || c == '"' || c == '\\'))
98        .ok_or_else(|| SeatInitError::KeyFile(key_file.display().to_string()))?;
99    let text = format!(
100        "# Written by liminal seat init (AGT-006 R3): the central exchange and Lys.\n\
101         server = \"{CENTRAL_SERVER}\"\n\
102         channel = \"{DEFAULT_CHANNEL}\"\n\
103         \n\
104         [lys]\n\
105         issuer = \"{CENTRAL_ISSUER}\"\n\
106         agent = \"{agent}\"\n\
107         key_file = \"{key}\"\n\
108         \n\
109         {limits}",
110        limits = limits_text()
111    );
112    write_private(out, &text)
113}
114
115/// Writes an open seat file at `out`, mode 0600 (ACCESS-011 R1).
116///
117/// The file names the open exchange at `server`, no credential, and
118/// `[open] reach = "network"` when the address is off loopback. An existing
119/// file is refused, never replaced.
120///
121/// # Errors
122/// An invalid server address, or a file that exists or cannot be made.
123pub fn init_open(server: &str, out: &Path) -> Result<(), SeatInitError> {
124    init_open_named(server, None, out)
125}
126
127/// Writes an open seat for the open exchange at `server` declaring `name`
128/// (ACCESS-012 R2), recorded by the exchange as `open:<name>`; with no name
129/// the seat is anonymous.
130///
131/// # Errors
132/// The server address or the name is not one the seat can carry, or the
133/// file cannot be written.
134pub fn init_open_named(server: &str, name: Option<&str>, out: &Path) -> Result<(), SeatInitError> {
135    if server.is_empty()
136        || server
137            .chars()
138            .any(|c| c.is_control() || c == '"' || c == '\\')
139    {
140        return Err(SeatInitError::Server);
141    }
142    if name.is_some_and(|name| !crate::config::is_open_name(name)) {
143        return Err(SeatInitError::Name);
144    }
145    let reach = if names_loopback(server) {
146        ""
147    } else {
148        "reach = \"network\"\n"
149    };
150    let name = name.map_or(String::new(), |name| format!("name = \"{name}\"\n"));
151    let open = if reach.is_empty() && name.is_empty() {
152        String::new()
153    } else {
154        format!("\n[open]\n{reach}{name}")
155    };
156    let text = format!(
157        "# Written by liminal seat init --open (ACCESS-011 R1): an open exchange, no credential.\n\
158         server = \"{server}\"\n\
159         channel = \"{DEFAULT_CHANNEL}\"\n\
160         {open}\
161         \n\
162         {limits}",
163        limits = limits_text()
164    );
165    write_private(out, &text)
166}
167
168fn write_private(out: &Path, text: &str) -> Result<(), SeatInitError> {
169    let unwritable = |error: std::io::Error| SeatInitError::Unwritable {
170        path: out.display().to_string(),
171        message: error.to_string(),
172    };
173    let mut file = OpenOptions::new()
174        .write(true)
175        .create_new(true)
176        .mode(0o600)
177        .open(out)
178        .map_err(unwritable)?;
179    file.write_all(text.as_bytes()).map_err(unwritable)?;
180    file.sync_all().map_err(unwritable)
181}
182
183/// The `host:port` an address dials: a `host:port` as written, or a URL's
184/// host and its port (its scheme's default when it names none).
185fn dial_target(address: &str) -> Option<(String, u16)> {
186    const SCHEMES: [(&str, u16); 4] = [
187        ("wss://", 443),
188        ("https://", 443),
189        ("ws://", 80),
190        ("http://", 80),
191    ];
192    let (rest, default_port) = SCHEMES
193        .iter()
194        .find_map(|(scheme, port)| address.strip_prefix(scheme).map(|rest| (rest, *port)))
195        .unwrap_or((address, 0));
196    let authority = rest.split('/').next()?;
197    if let Some(bracketed) = authority.strip_prefix('[') {
198        let (host, after) = bracketed.split_once(']')?;
199        let port = match after.strip_prefix(':') {
200            Some(port) => port.parse().ok()?,
201            None if default_port > 0 => default_port,
202            None => return None,
203        };
204        return Some((host.to_owned(), port));
205    }
206    match authority.rsplit_once(':') {
207        Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
208        None if default_port > 0 => Some((authority.to_owned(), default_port)),
209        None => None,
210    }
211}
212
213/// Whether `address` names this machine: a loopback host, with or without a
214/// scheme and a path (ACCESS-010 R3: an open seat reaches loopback only).
215#[must_use]
216pub fn names_loopback(address: &str) -> bool {
217    dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
218}
219
220/// Whether `host` names this machine.
221fn is_loopback(host: &str) -> bool {
222    host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
223}
224
225/// Refuses an address on this machine that does not accept a connection
226/// within `bound`, with `liminal_mcp_loopback_unanswered` and the address;
227/// any other address is left to its own connection.
228///
229/// # Errors
230/// The loopback address named, when nothing answers on it.
231pub fn refuse_unanswered_loopback(address: &str, bound: Duration) -> Result<(), String> {
232    let Some((host, port)) = dial_target(address) else {
233        return Ok(());
234    };
235    if !is_loopback(&host) {
236        return Ok(());
237    }
238    let unanswered = || format!("liminal_mcp_loopback_unanswered: nothing answers at {address}");
239    let targets: Vec<SocketAddr> = (host.as_str(), port)
240        .to_socket_addrs()
241        .map_err(|_| unanswered())?
242        .collect();
243    if targets
244        .iter()
245        .any(|target| TcpStream::connect_timeout(target, bound).is_ok())
246    {
247        Ok(())
248    } else {
249        Err(unanswered())
250    }
251}
252
253#[cfg(test)]
254#[path = "seat_tests.rs"]
255mod tests;