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_unwritable: {path}: {message}")]
35 Unwritable {
36 path: String,
38 message: String,
40 },
41}
42
43pub fn init(agent: &str, key_file: &Path, out: &Path) -> Result<(), SeatInitError> {
50 if agent.is_empty()
51 || agent
52 .chars()
53 .any(|c| c.is_control() || c == '"' || c == '\\')
54 {
55 return Err(SeatInitError::Agent);
56 }
57 let key = key_file
58 .to_str()
59 .filter(|k| !k.chars().any(|c| c.is_control() || c == '"' || c == '\\'))
60 .ok_or_else(|| SeatInitError::KeyFile(key_file.display().to_string()))?;
61 let text = format!(
62 "# Written by liminal seat init (AGT-006 R3): the central exchange and Lys.\n\
63 server = \"{CENTRAL_SERVER}\"\n\
64 channel = \"{DEFAULT_CHANNEL}\"\n\
65 \n\
66 [lys]\n\
67 issuer = \"{CENTRAL_ISSUER}\"\n\
68 agent = \"{agent}\"\n\
69 key_file = \"{key}\"\n\
70 \n\
71 [limits]\n\
72 frame_bytes = 65536\n\
73 record_bytes = 1048576\n\
74 reply_timeout_ms = 10000\n\
75 flow_steps = 8\n\
76 request_bytes = 65536\n"
77 );
78 write_private(out, &text)
79}
80
81pub fn init_open(server: &str, out: &Path) -> Result<(), SeatInitError> {
90 if server.is_empty()
91 || server
92 .chars()
93 .any(|c| c.is_control() || c == '"' || c == '\\')
94 {
95 return Err(SeatInitError::Server);
96 }
97 let reach = if names_loopback(server) {
98 ""
99 } else {
100 "\n[open]\nreach = \"network\"\n"
101 };
102 let text = format!(
103 "# Written by liminal seat init --open (ACCESS-011 R1): an open exchange, no credential.\n\
104 server = \"{server}\"\n\
105 channel = \"{DEFAULT_CHANNEL}\"\n\
106 {reach}\
107 \n\
108 [limits]\n\
109 frame_bytes = 65536\n\
110 record_bytes = 1048576\n\
111 reply_timeout_ms = 10000\n\
112 flow_steps = 8\n\
113 request_bytes = 65536\n"
114 );
115 write_private(out, &text)
116}
117
118fn write_private(out: &Path, text: &str) -> Result<(), SeatInitError> {
119 let unwritable = |error: std::io::Error| SeatInitError::Unwritable {
120 path: out.display().to_string(),
121 message: error.to_string(),
122 };
123 let mut file = OpenOptions::new()
124 .write(true)
125 .create_new(true)
126 .mode(0o600)
127 .open(out)
128 .map_err(unwritable)?;
129 file.write_all(text.as_bytes()).map_err(unwritable)?;
130 file.sync_all().map_err(unwritable)
131}
132
133fn dial_target(address: &str) -> Option<(String, u16)> {
136 const SCHEMES: [(&str, u16); 4] = [
137 ("wss://", 443),
138 ("https://", 443),
139 ("ws://", 80),
140 ("http://", 80),
141 ];
142 let (rest, default_port) = SCHEMES
143 .iter()
144 .find_map(|(scheme, port)| address.strip_prefix(scheme).map(|rest| (rest, *port)))
145 .unwrap_or((address, 0));
146 let authority = rest.split('/').next()?;
147 if let Some(bracketed) = authority.strip_prefix('[') {
148 let (host, after) = bracketed.split_once(']')?;
149 let port = match after.strip_prefix(':') {
150 Some(port) => port.parse().ok()?,
151 None if default_port > 0 => default_port,
152 None => return None,
153 };
154 return Some((host.to_owned(), port));
155 }
156 match authority.rsplit_once(':') {
157 Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
158 None if default_port > 0 => Some((authority.to_owned(), default_port)),
159 None => None,
160 }
161}
162
163#[must_use]
166pub fn names_loopback(address: &str) -> bool {
167 dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
168}
169
170fn is_loopback(host: &str) -> bool {
172 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
173}
174
175pub fn refuse_unanswered_loopback(address: &str, bound: Duration) -> Result<(), String> {
182 let Some((host, port)) = dial_target(address) else {
183 return Ok(());
184 };
185 if !is_loopback(&host) {
186 return Ok(());
187 }
188 let unanswered = || format!("liminal_mcp_loopback_unanswered: nothing answers at {address}");
189 let targets: Vec<SocketAddr> = (host.as_str(), port)
190 .to_socket_addrs()
191 .map_err(|_| unanswered())?
192 .collect();
193 if targets
194 .iter()
195 .any(|target| TcpStream::connect_timeout(target, bound).is_ok())
196 {
197 Ok(())
198 } else {
199 Err(unanswered())
200 }
201}
202
203#[cfg(test)]
204#[path = "seat_tests.rs"]
205mod tests;