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); 5] = [
23 ("frame_bytes", 65536),
24 ("record_bytes", 1_048_576),
25 ("reply_timeout_ms", 10000),
26 ("flow_steps", 8),
27 ("request_bytes", 65536),
28];
29
30pub(crate) fn defaults() -> toml::Table {
34 let limits: toml::Table = DEFAULT_LIMITS
35 .iter()
36 .map(|(field, value)| ((*field).to_owned(), toml::Value::Integer(*value)))
37 .collect();
38 toml::Table::from_iter([
39 (
40 "channel".to_owned(),
41 toml::Value::String(DEFAULT_CHANNEL.to_owned()),
42 ),
43 ("limits".to_owned(), toml::Value::Table(limits)),
44 ])
45}
46
47fn limits_text() -> String {
49 DEFAULT_LIMITS
50 .iter()
51 .map(|(field, value)| format!("{field} = {value}\n"))
52 .fold(String::from("[limits]\n"), |text, line| text + &line)
53}
54
55#[derive(Debug, thiserror::Error)]
57pub enum SeatInitError {
58 #[error("liminal_seat_agent_invalid: the agent id must be one line of printable characters")]
60 Agent,
61 #[error("liminal_seat_key_file_invalid: {0} is not a UTF-8 path")]
63 KeyFile(String),
64 #[error("liminal_seat_server_invalid: the server must be one line of printable characters")]
66 Server,
67 #[error("liminal_seat_name_invalid: the name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'")]
69 Name,
70 #[error("liminal_seat_unwritable: {path}: {message}")]
72 Unwritable {
73 path: String,
75 message: String,
77 },
78}
79
80pub fn init(agent: &str, key_file: &Path, out: &Path) -> Result<(), SeatInitError> {
87 if agent.is_empty()
88 || agent
89 .chars()
90 .any(|c| c.is_control() || c == '"' || c == '\\')
91 {
92 return Err(SeatInitError::Agent);
93 }
94 let key = key_file
95 .to_str()
96 .filter(|k| !k.chars().any(|c| c.is_control() || c == '"' || c == '\\'))
97 .ok_or_else(|| SeatInitError::KeyFile(key_file.display().to_string()))?;
98 let text = format!(
99 "# Written by liminal seat init (AGT-006 R3): the central exchange and Lys.\n\
100 server = \"{CENTRAL_SERVER}\"\n\
101 channel = \"{DEFAULT_CHANNEL}\"\n\
102 \n\
103 [lys]\n\
104 issuer = \"{CENTRAL_ISSUER}\"\n\
105 agent = \"{agent}\"\n\
106 key_file = \"{key}\"\n\
107 \n\
108 {limits}",
109 limits = limits_text()
110 );
111 write_private(out, &text)
112}
113
114pub fn init_open(server: &str, out: &Path) -> Result<(), SeatInitError> {
123 init_open_named(server, None, out)
124}
125
126pub fn init_open_named(server: &str, name: Option<&str>, out: &Path) -> Result<(), SeatInitError> {
134 if server.is_empty()
135 || server
136 .chars()
137 .any(|c| c.is_control() || c == '"' || c == '\\')
138 {
139 return Err(SeatInitError::Server);
140 }
141 if name.is_some_and(|name| !crate::config::is_open_name(name)) {
142 return Err(SeatInitError::Name);
143 }
144 let reach = if names_loopback(server) {
145 ""
146 } else {
147 "reach = \"network\"\n"
148 };
149 let name = name.map_or(String::new(), |name| format!("name = \"{name}\"\n"));
150 let open = if reach.is_empty() && name.is_empty() {
151 String::new()
152 } else {
153 format!("\n[open]\n{reach}{name}")
154 };
155 let text = format!(
156 "# Written by liminal seat init --open (ACCESS-011 R1): an open exchange, no credential.\n\
157 server = \"{server}\"\n\
158 channel = \"{DEFAULT_CHANNEL}\"\n\
159 {open}\
160 \n\
161 {limits}",
162 limits = limits_text()
163 );
164 write_private(out, &text)
165}
166
167fn write_private(out: &Path, text: &str) -> Result<(), SeatInitError> {
168 let unwritable = |error: std::io::Error| SeatInitError::Unwritable {
169 path: out.display().to_string(),
170 message: error.to_string(),
171 };
172 let mut file = OpenOptions::new()
173 .write(true)
174 .create_new(true)
175 .mode(0o600)
176 .open(out)
177 .map_err(unwritable)?;
178 file.write_all(text.as_bytes()).map_err(unwritable)?;
179 file.sync_all().map_err(unwritable)
180}
181
182fn dial_target(address: &str) -> Option<(String, u16)> {
185 const SCHEMES: [(&str, u16); 4] = [
186 ("wss://", 443),
187 ("https://", 443),
188 ("ws://", 80),
189 ("http://", 80),
190 ];
191 let (rest, default_port) = SCHEMES
192 .iter()
193 .find_map(|(scheme, port)| address.strip_prefix(scheme).map(|rest| (rest, *port)))
194 .unwrap_or((address, 0));
195 let authority = rest.split('/').next()?;
196 if let Some(bracketed) = authority.strip_prefix('[') {
197 let (host, after) = bracketed.split_once(']')?;
198 let port = match after.strip_prefix(':') {
199 Some(port) => port.parse().ok()?,
200 None if default_port > 0 => default_port,
201 None => return None,
202 };
203 return Some((host.to_owned(), port));
204 }
205 match authority.rsplit_once(':') {
206 Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
207 None if default_port > 0 => Some((authority.to_owned(), default_port)),
208 None => None,
209 }
210}
211
212#[must_use]
215pub fn names_loopback(address: &str) -> bool {
216 dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
217}
218
219fn is_loopback(host: &str) -> bool {
221 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
222}
223
224pub fn refuse_unanswered_loopback(address: &str, bound: Duration) -> Result<(), String> {
231 let Some((host, port)) = dial_target(address) else {
232 return Ok(());
233 };
234 if !is_loopback(&host) {
235 return Ok(());
236 }
237 let unanswered = || format!("liminal_mcp_loopback_unanswered: nothing answers at {address}");
238 let targets: Vec<SocketAddr> = (host.as_str(), port)
239 .to_socket_addrs()
240 .map_err(|_| unanswered())?
241 .collect();
242 if targets
243 .iter()
244 .any(|target| TcpStream::connect_timeout(target, bound).is_ok())
245 {
246 Ok(())
247 } else {
248 Err(unanswered())
249 }
250}
251
252#[cfg(test)]
253#[path = "seat_tests.rs"]
254mod tests;