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";
const DEFAULT_LIMITS: [(&str, i64); 6] = [
("frame_bytes", 65536),
("record_bytes", 1_048_576),
("reply_timeout_ms", 10000),
("flow_steps", 8),
("request_bytes", 65536),
("answer_bytes", 16_777_216),
];
pub(crate) fn defaults() -> toml::Table {
let limits: toml::Table = DEFAULT_LIMITS
.iter()
.map(|(field, value)| ((*field).to_owned(), toml::Value::Integer(*value)))
.collect();
toml::Table::from_iter([
(
"channel".to_owned(),
toml::Value::String(DEFAULT_CHANNEL.to_owned()),
),
("limits".to_owned(), toml::Value::Table(limits)),
])
}
fn limits_text() -> String {
DEFAULT_LIMITS
.iter()
.map(|(field, value)| format!("{field} = {value}\n"))
.fold(String::from("[limits]\n"), |text, line| text + &line)
}
#[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_server_invalid: the server must be one line of printable characters")]
Server,
#[error("liminal_seat_name_invalid: the name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'")]
Name,
#[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}",
limits = limits_text()
);
write_private(out, &text)
}
pub fn init_open(server: &str, out: &Path) -> Result<(), SeatInitError> {
init_open_named(server, None, out)
}
pub fn init_open_named(server: &str, name: Option<&str>, out: &Path) -> Result<(), SeatInitError> {
if server.is_empty()
|| server
.chars()
.any(|c| c.is_control() || c == '"' || c == '\\')
{
return Err(SeatInitError::Server);
}
if name.is_some_and(|name| !crate::config::is_open_name(name)) {
return Err(SeatInitError::Name);
}
let reach = if names_loopback(server) {
""
} else {
"reach = \"network\"\n"
};
let name = name.map_or(String::new(), |name| format!("name = \"{name}\"\n"));
let open = if reach.is_empty() && name.is_empty() {
String::new()
} else {
format!("\n[open]\n{reach}{name}")
};
let text = format!(
"# Written by liminal seat init --open (ACCESS-011 R1): an open exchange, no credential.\n\
server = \"{server}\"\n\
channel = \"{DEFAULT_CHANNEL}\"\n\
{open}\
\n\
{limits}",
limits = limits_text()
);
write_private(out, &text)
}
fn write_private(out: &Path, text: &str) -> Result<(), SeatInitError> {
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;