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