use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::exec::acl::{parse_agent_id, parse_machine_id, LoadMode};
use crate::identity::{AgentId, MachineId};
use crate::server::InstanceName;
#[must_use]
pub fn default_connect_acl_path() -> PathBuf {
#[cfg(target_os = "macos")]
{
PathBuf::from("/usr/local/etc/x0x/connect-acl.toml")
}
#[cfg(not(target_os = "macos"))]
{
PathBuf::from("/etc/x0x/connect-acl.toml")
}
}
#[must_use]
pub fn default_connect_acl_path_for(name: &InstanceName) -> PathBuf {
let file = format!("connect-acl-{}.toml", name.as_str());
default_connect_acl_path()
.parent()
.map(|dir| dir.join(&file))
.unwrap_or_else(|| PathBuf::from(file))
}
#[derive(Debug, Clone)]
pub enum ConnectPolicy {
Disabled {
path: PathBuf,
reason: String,
loaded_at_unix_ms: u64,
},
Enabled(ConnectAcl),
}
impl Default for ConnectPolicy {
fn default() -> Self {
Self::Disabled {
path: default_connect_acl_path(),
reason: "no connect ACL configured".to_string(),
loaded_at_unix_ms: 0,
}
}
}
impl ConnectPolicy {
#[must_use]
pub fn enabled(&self) -> bool {
matches!(self, Self::Enabled(_))
}
#[must_use]
pub fn path(&self) -> &Path {
match self {
Self::Disabled { path, .. } => path,
Self::Enabled(acl) => &acl.loaded_from,
}
}
#[must_use]
pub fn summary(&self) -> ConnectAclSummary {
match self {
Self::Disabled {
path,
reason,
loaded_at_unix_ms,
} => ConnectAclSummary {
enabled: false,
loaded_from: path.display().to_string(),
loaded_at_unix_ms: *loaded_at_unix_ms,
allow_entry_count: 0,
target_entry_count: 0,
disabled_reason: Some(reason.clone()),
},
Self::Enabled(acl) => ConnectAclSummary {
enabled: true,
loaded_from: acl.loaded_from.display().to_string(),
loaded_at_unix_ms: acl.loaded_at_unix_ms,
allow_entry_count: acl.allow.len(),
target_entry_count: acl.allow.iter().map(|e| e.targets.len()).sum(),
disabled_reason: None,
},
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ConnectAclSummary {
pub enabled: bool,
pub loaded_from: String,
pub loaded_at_unix_ms: u64,
pub allow_entry_count: usize,
pub target_entry_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConnectAcl {
pub loaded_from: PathBuf,
pub loaded_at_unix_ms: u64,
pub allow: Vec<ConnectAllowEntry>,
}
impl ConnectAcl {
#[must_use]
pub fn entry_for(
&self,
agent_id: &AgentId,
machine_id: &MachineId,
) -> Option<&ConnectAllowEntry> {
self.allow
.iter()
.find(|e| &e.agent_id == agent_id && &e.machine_id == machine_id)
}
#[must_use]
pub fn is_allowed(
&self,
agent_id: &AgentId,
machine_id: &MachineId,
target: &SocketAddr,
) -> bool {
self.entry_for(agent_id, machine_id)
.is_some_and(|e| e.targets.iter().any(|t| t == target))
}
}
#[derive(Debug, Clone)]
pub struct ConnectAllowEntry {
pub description: Option<String>,
pub agent_id: AgentId,
pub machine_id: MachineId,
pub targets: Vec<SocketAddr>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConnectAclError {
#[error("connect ACL file not found: {0}")]
Missing(String),
#[error("failed to read connect ACL {path}: {source}")]
Read {
path: String,
source: std::io::Error,
},
#[error("failed to parse connect ACL {path}: {source}")]
Parse {
path: String,
source: toml::de::Error,
},
#[error("invalid connect ACL {path}: {reason}")]
Invalid { path: String, reason: String },
}
pub async fn load_connect_policy(
path: Option<&Path>,
mode: LoadMode,
) -> Result<ConnectPolicy, ConnectAclError> {
let acl_path = path
.map(Path::to_path_buf)
.unwrap_or_else(default_connect_acl_path);
let loaded_at_unix_ms = now_unix_ms();
if !acl_path.exists() {
if mode == LoadMode::ExplicitPath {
return Err(ConnectAclError::Missing(acl_path.display().to_string()));
}
return Ok(ConnectPolicy::Disabled {
path: acl_path,
reason: "acl_missing".to_string(),
loaded_at_unix_ms,
});
}
let text = tokio::fs::read_to_string(&acl_path)
.await
.map_err(|source| ConnectAclError::Read {
path: acl_path.display().to_string(),
source,
})?;
parse_connect_policy(&acl_path, loaded_at_unix_ms, &text)
}
pub fn parse_connect_policy(
path: &Path,
loaded_at_unix_ms: u64,
text: &str,
) -> Result<ConnectPolicy, ConnectAclError> {
let parsed: ConnectFileToml =
toml::from_str(text).map_err(|source| ConnectAclError::Parse {
path: path.display().to_string(),
source,
})?;
let Some(connect) = parsed.connect else {
return Ok(ConnectPolicy::Disabled {
path: path.to_path_buf(),
reason: "missing_connect_section".to_string(),
loaded_at_unix_ms,
});
};
if !connect.enabled {
return Ok(ConnectPolicy::Disabled {
path: path.to_path_buf(),
reason: "connect_disabled".to_string(),
loaded_at_unix_ms,
});
}
let mut allow = Vec::with_capacity(connect.allow.len());
for (idx, entry) in connect.allow.into_iter().enumerate() {
let agent_id =
parse_agent_id(&entry.agent_id).map_err(|reason| ConnectAclError::Invalid {
path: path.display().to_string(),
reason: format!("allow[{idx}].agent_id: {reason}"),
})?;
let machine_id =
parse_machine_id(&entry.machine_id).map_err(|reason| ConnectAclError::Invalid {
path: path.display().to_string(),
reason: format!("allow[{idx}].machine_id: {reason}"),
})?;
if entry.targets.is_empty() {
return Err(ConnectAclError::Invalid {
path: path.display().to_string(),
reason: format!("allow[{idx}] must contain at least one target"),
});
}
let mut targets = Vec::with_capacity(entry.targets.len());
for (tidx, raw) in entry.targets.into_iter().enumerate() {
let addr = parse_target(&raw).map_err(|reason| ConnectAclError::Invalid {
path: path.display().to_string(),
reason: format!("allow[{idx}].targets[{tidx}]: {reason}"),
})?;
targets.push(addr);
}
allow.push(ConnectAllowEntry {
description: entry.description,
agent_id,
machine_id,
targets,
});
}
Ok(ConnectPolicy::Enabled(ConnectAcl {
loaded_from: path.to_path_buf(),
loaded_at_unix_ms,
allow,
}))
}
pub fn parse_target(raw: &str) -> Result<SocketAddr, String> {
let addr: SocketAddr = raw.parse().map_err(|_| {
"targets must be numeric IP:port (e.g. \"127.0.0.1:22\" or \"[::1]:22\"); \
hostnames such as \"localhost\" are not accepted"
.to_string()
})?;
if addr.port() == 0 {
return Err("port 0 is not a connectable target".to_string());
}
if let std::net::IpAddr::V6(v6) = addr.ip() {
if v6.to_ipv4_mapped().is_some() {
return Err(format!(
"IPv4-mapped IPv6 target {addr} is not loopback; write it as 127.0.0.1:{}",
addr.port()
));
}
}
if !is_loopback(addr.ip()) {
return Err(format!(
"only loopback targets (127.0.0.0/8, ::1) are permitted in this release; \
{addr} is not loopback (LAN/subnet targets are not supported)"
));
}
Ok(addr)
}
#[must_use]
pub fn is_loopback(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => v4.is_loopback(),
std::net::IpAddr::V6(v6) => v6.is_loopback(),
}
}
fn now_unix_ms() -> u64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => d.as_millis().min(u128::from(u64::MAX)) as u64,
Err(_) => 0,
}
}
#[derive(Debug, Deserialize)]
struct ConnectFileToml {
connect: Option<ConnectSectionToml>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConnectSectionToml {
#[serde(default)]
enabled: bool,
#[serde(default)]
allow: Vec<ConnectAllowEntryToml>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConnectAllowEntryToml {
description: Option<String>,
agent_id: String,
machine_id: String,
targets: Vec<String>,
}
#[cfg(test)]
mod tests;