use std::fs::OpenOptions;
use std::io::Write;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::time::Duration;
pub const CENTRAL_SERVER: &str = "wss://liminal.ablative.com.au/ws";
pub const CENTRAL_ISSUER: &str = "https://lys.ablative.com.au";
const DEFAULT_CHANNEL: &str = "console.topology";
#[derive(Debug, thiserror::Error)]
pub enum SeatInitError {
#[error("liminal_seat_agent_invalid: the agent id must be one line of printable characters")]
Agent,
#[error("liminal_seat_key_file_invalid: {0} is not a UTF-8 path")]
KeyFile(String),
#[error("liminal_seat_unwritable: {path}: {message}")]
Unwritable {
path: String,
message: String,
},
}
pub fn init(agent: &str, key_file: &Path, out: &Path) -> Result<(), SeatInitError> {
if agent.is_empty()
|| agent
.chars()
.any(|c| c.is_control() || c == '"' || c == '\\')
{
return Err(SeatInitError::Agent);
}
let key = key_file
.to_str()
.filter(|k| !k.chars().any(|c| c.is_control() || c == '"' || c == '\\'))
.ok_or_else(|| SeatInitError::KeyFile(key_file.display().to_string()))?;
let text = format!(
"# Written by liminal seat init (AGT-006 R3): the central exchange and Lys.\n\
server = \"{CENTRAL_SERVER}\"\n\
channel = \"{DEFAULT_CHANNEL}\"\n\
\n\
[lys]\n\
issuer = \"{CENTRAL_ISSUER}\"\n\
agent = \"{agent}\"\n\
key_file = \"{key}\"\n\
\n\
[limits]\n\
frame_bytes = 65536\n\
record_bytes = 1048576\n\
reply_timeout_ms = 10000\n\
flow_steps = 8\n\
request_bytes = 65536\n"
);
let unwritable = |error: std::io::Error| SeatInitError::Unwritable {
path: out.display().to_string(),
message: error.to_string(),
};
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(out)
.map_err(unwritable)?;
file.write_all(text.as_bytes()).map_err(unwritable)?;
file.sync_all().map_err(unwritable)
}
fn dial_target(address: &str) -> Option<(String, u16)> {
const SCHEMES: [(&str, u16); 4] = [
("wss://", 443),
("https://", 443),
("ws://", 80),
("http://", 80),
];
let (rest, default_port) = SCHEMES
.iter()
.find_map(|(scheme, port)| address.strip_prefix(scheme).map(|rest| (rest, *port)))
.unwrap_or((address, 0));
let authority = rest.split('/').next()?;
if let Some(bracketed) = authority.strip_prefix('[') {
let (host, after) = bracketed.split_once(']')?;
let port = match after.strip_prefix(':') {
Some(port) => port.parse().ok()?,
None if default_port > 0 => default_port,
None => return None,
};
return Some((host.to_owned(), port));
}
match authority.rsplit_once(':') {
Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
None if default_port > 0 => Some((authority.to_owned(), default_port)),
None => None,
}
}
#[must_use]
pub fn names_loopback(address: &str) -> bool {
dial_target(address).is_some_and(|(host, _)| is_loopback(&host))
}
fn is_loopback(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
pub fn refuse_unanswered_loopback(address: &str, bound: Duration) -> Result<(), String> {
let Some((host, port)) = dial_target(address) else {
return Ok(());
};
if !is_loopback(&host) {
return Ok(());
}
let unanswered = || format!("liminal_mcp_loopback_unanswered: nothing answers at {address}");
let targets: Vec<SocketAddr> = (host.as_str(), port)
.to_socket_addrs()
.map_err(|_| unanswered())?
.collect();
if targets
.iter()
.any(|target| TcpStream::connect_timeout(target, bound).is_ok())
{
Ok(())
} else {
Err(unanswered())
}
}
#[cfg(test)]
#[path = "seat_tests.rs"]
mod tests;