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_unwritable: {path}: {message}")]
32 Unwritable {
33 path: String,
35 message: String,
37 },
38}
39
40pub fn init(agent: &str, key_file: &Path, out: &Path) -> Result<(), SeatInitError> {
47 if agent.is_empty()
48 || agent
49 .chars()
50 .any(|c| c.is_control() || c == '"' || c == '\\')
51 {
52 return Err(SeatInitError::Agent);
53 }
54 let key = key_file
55 .to_str()
56 .filter(|k| !k.chars().any(|c| c.is_control() || c == '"' || c == '\\'))
57 .ok_or_else(|| SeatInitError::KeyFile(key_file.display().to_string()))?;
58 let text = format!(
59 "# Written by liminal seat init (AGT-006 R3): the central exchange and Lys.\n\
60 server = \"{CENTRAL_SERVER}\"\n\
61 channel = \"{DEFAULT_CHANNEL}\"\n\
62 \n\
63 [lys]\n\
64 issuer = \"{CENTRAL_ISSUER}\"\n\
65 agent = \"{agent}\"\n\
66 key_file = \"{key}\"\n\
67 \n\
68 [limits]\n\
69 frame_bytes = 65536\n\
70 record_bytes = 1048576\n\
71 reply_timeout_ms = 10000\n\
72 flow_steps = 8\n\
73 request_bytes = 65536\n"
74 );
75 let unwritable = |error: std::io::Error| SeatInitError::Unwritable {
76 path: out.display().to_string(),
77 message: error.to_string(),
78 };
79 let mut file = OpenOptions::new()
80 .write(true)
81 .create_new(true)
82 .mode(0o600)
83 .open(out)
84 .map_err(unwritable)?;
85 file.write_all(text.as_bytes()).map_err(unwritable)?;
86 file.sync_all().map_err(unwritable)
87}
88
89fn dial_target(address: &str) -> Option<(String, u16)> {
92 const SCHEMES: [(&str, u16); 4] = [
93 ("wss://", 443),
94 ("https://", 443),
95 ("ws://", 80),
96 ("http://", 80),
97 ];
98 let (rest, default_port) = SCHEMES
99 .iter()
100 .find_map(|(scheme, port)| address.strip_prefix(scheme).map(|rest| (rest, *port)))
101 .unwrap_or((address, 0));
102 let authority = rest.split('/').next()?;
103 if let Some(bracketed) = authority.strip_prefix('[') {
104 let (host, after) = bracketed.split_once(']')?;
105 let port = match after.strip_prefix(':') {
106 Some(port) => port.parse().ok()?,
107 None if default_port > 0 => default_port,
108 None => return None,
109 };
110 return Some((host.to_owned(), port));
111 }
112 match authority.rsplit_once(':') {
113 Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
114 None if default_port > 0 => Some((authority.to_owned(), default_port)),
115 None => None,
116 }
117}
118
119#[must_use]
122pub fn names_loopback(address: &str) -> bool {
123 dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
124}
125
126fn is_loopback(host: &str) -> bool {
128 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
129}
130
131pub fn refuse_unanswered_loopback(address: &str, bound: Duration) -> Result<(), String> {
138 let Some((host, port)) = dial_target(address) else {
139 return Ok(());
140 };
141 if !is_loopback(&host) {
142 return Ok(());
143 }
144 let unanswered = || format!("liminal_mcp_loopback_unanswered: nothing answers at {address}");
145 let targets: Vec<SocketAddr> = (host.as_str(), port)
146 .to_socket_addrs()
147 .map_err(|_| unanswered())?
148 .collect();
149 if targets
150 .iter()
151 .any(|target| TcpStream::connect_timeout(target, bound).is_ok())
152 {
153 Ok(())
154 } else {
155 Err(unanswered())
156 }
157}
158
159#[cfg(test)]
160#[path = "seat_tests.rs"]
161mod tests;