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
21#[derive(Debug, thiserror::Error)]
23pub enum SeatInitError {
24 #[error("liminal_seat_agent_invalid: the agent id must be one line of printable characters")]
26 Agent,
27 #[error("liminal_seat_key_file_invalid: {0} is not a UTF-8 path")]
29 KeyFile(String),
30 #[error("liminal_seat_server_invalid: the server must be one line of printable characters")]
32 Server,
33 #[error("liminal_seat_name_invalid: the name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'")]
35 Name,
36 #[error("liminal_seat_unwritable: {path}: {message}")]
38 Unwritable {
39 path: String,
41 message: String,
43 },
44}
45
46pub 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
84pub fn init_open(server: &str, out: &Path) -> Result<(), SeatInitError> {
93 init_open_named(server, None, out)
94}
95
96pub 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
156fn 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#[must_use]
189pub fn names_loopback(address: &str) -> bool {
190 dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
191}
192
193fn is_loopback(host: &str) -> bool {
195 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
196}
197
198pub 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;