1use 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
12pub const CENTRAL_SERVER: &str = "wss://liminal.ablative.com.au/ws";
14
15pub const CENTRAL_ISSUER: &str = "https://lys.ablative.com.au";
17
18const DEFAULT_CHANNEL: &str = "console.topology";
20
21const 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
31pub(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
48fn 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#[derive(Debug, thiserror::Error)]
58pub enum SeatInitError {
59 #[error("liminal_seat_agent_invalid: the agent id must be one line of printable characters")]
61 Agent,
62 #[error("liminal_seat_key_file_invalid: {0} is not a UTF-8 path")]
64 KeyFile(String),
65 #[error("liminal_seat_server_invalid: the server must be one line of printable characters")]
67 Server,
68 #[error("liminal_seat_name_invalid: the name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'")]
70 Name,
71 #[error("liminal_seat_unwritable: {path}: {message}")]
73 Unwritable {
74 path: String,
76 message: String,
78 },
79}
80
81pub 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
115pub fn init_open(server: &str, out: &Path) -> Result<(), SeatInitError> {
124 init_open_named(server, None, out)
125}
126
127pub 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
183fn 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#[must_use]
216pub fn names_loopback(address: &str) -> bool {
217 dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
218}
219
220fn is_loopback(host: &str) -> bool {
222 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
223}
224
225pub 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;