use std::net::Ipv4Addr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use iroh::{EndpointId, SecretKey};
use serde::{Deserialize, Serialize};
use crate::membership::GroupMode;
pub use ray_proto::TransportMode;
#[allow(dead_code)]
mod secret_key_hex {
use iroh::SecretKey;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(key: &SecretKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&hex::encode(key.to_bytes()))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<SecretKey, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let bytes: [u8; 32] = hex::decode(&s)
.map_err(serde::de::Error::custom)?
.try_into()
.map_err(|_| serde::de::Error::custom("secret key must be 32 bytes"))?;
Ok(SecretKey::from(bytes))
}
}
mod option_secret_key_hex {
use iroh::SecretKey;
use serde::{self, Deserializer, Serializer};
pub fn serialize<S>(key: &Option<SecretKey>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match key {
Some(k) => super::secret_key_hex::serialize(k, serializer),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SecretKey>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = serde::Deserialize::deserialize(deserializer)?;
match opt {
Some(s) => {
let bytes: [u8; 32] = hex::decode(&s)
.map_err(serde::de::Error::custom)?
.try_into()
.map_err(|_| serde::de::Error::custom("secret key must be 32 bytes"))?;
Ok(Some(SecretKey::from(bytes)))
}
None => Ok(None),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemberEntry {
pub identity: EndpointId,
pub ip: Ipv4Addr,
#[serde(default)]
pub is_coordinator: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApprovedConfigEntry {
pub identity: EndpointId,
pub ip: Ipv4Addr,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
pub name: String,
#[serde(default)]
pub group_mode: GroupMode,
pub my_ip: Option<Ipv4Addr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub my_hostname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_hostname: Option<String>,
#[serde(default)]
pub members: Vec<MemberEntry>,
#[serde(default)]
pub approved: Vec<ApprovedConfigEntry>,
#[serde(default, with = "option_secret_key_hex")]
pub network_secret_key: Option<SecretKey>,
#[serde(default)]
pub network_public_key: Option<EndpointId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<TransportMode>,
#[serde(default, alias = "allow_trusted")]
pub auto_accept_firewall: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub admins: Vec<EndpointId>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub direct: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ssh_allow: Vec<SshRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshRule {
pub peer: String,
#[serde(default)]
pub users: Vec<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ServerOverride {
#[serde(default)]
pub servers: Vec<String>,
#[serde(default)]
pub replace: bool,
}
impl ServerOverride {
pub fn is_unset(&self) -> bool {
self.servers.is_empty()
}
}
pub const RELAY_PRESET_RAYFISH: &str = "http://relay.iroh.rayfish.xyz:3340";
pub const DISCOVERY_PRESET_RAYFISH: &str = "http://dns.iroh.rayfish.xyz:8080";
fn validate_http_url(s: &str) -> Result<()> {
let u = url::Url::parse(s).with_context(|| format!("invalid URL: {s}"))?;
anyhow::ensure!(
matches!(u.scheme(), "http" | "https"),
"URL must be http or https: {s}"
);
Ok(())
}
fn resolve_url_entry(entry: &str, preset: &str) -> Result<String> {
match entry {
"rayfish" => Ok(preset.to_string()),
other => {
validate_http_url(other)?;
Ok(other.to_string())
}
}
}
pub fn relay_urls(o: &ServerOverride) -> Result<Vec<String>> {
o.servers
.iter()
.map(|e| resolve_url_entry(e, RELAY_PRESET_RAYFISH))
.collect()
}
pub fn discovery_urls(o: &ServerOverride) -> Result<Vec<String>> {
o.servers
.iter()
.map(|e| resolve_url_entry(e, DISCOVERY_PRESET_RAYFISH))
.collect()
}
pub fn resolve_upstreams(o: &ServerOverride, captured: Vec<Ipv4Addr>) -> Vec<Ipv4Addr> {
if o.servers.is_empty() {
return captured;
}
let custom: Vec<Ipv4Addr> = o.servers.iter().filter_map(|s| s.parse().ok()).collect();
if o.replace {
custom
} else {
custom.into_iter().chain(captured).collect()
}
}
fn parse_entries(value: &str) -> Vec<String> {
value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn config_set(cfg: &mut AppConfig, key: &str, value: &str, replace: bool) -> Result<()> {
let entries = parse_entries(value);
let reset = entries.is_empty() || entries == ["n0"];
match key {
"relay" => {
if reset {
cfg.relay = ServerOverride::default();
} else {
for e in &entries {
resolve_url_entry(e, RELAY_PRESET_RAYFISH)?;
}
cfg.relay = ServerOverride {
servers: entries,
replace,
};
}
}
"discovery-dns" => {
if reset {
cfg.discovery_dns = ServerOverride::default();
} else {
for e in &entries {
resolve_url_entry(e, DISCOVERY_PRESET_RAYFISH)?;
}
cfg.discovery_dns = ServerOverride {
servers: entries,
replace,
};
}
}
"dns-upstreams" => {
if entries.is_empty() {
cfg.dns_upstreams = ServerOverride::default();
} else {
for e in &entries {
e.parse::<Ipv4Addr>()
.with_context(|| format!("invalid IPv4 address: {e}"))?;
}
cfg.dns_upstreams = ServerOverride {
servers: entries,
replace,
};
}
}
other => anyhow::bail!(
"unknown config key: {other} (expected relay, discovery-dns, or dns-upstreams)"
),
}
Ok(())
}
fn render_override(o: &ServerOverride) -> String {
if o.is_unset() {
"<default>".to_string()
} else {
let mode = if o.replace { "replace" } else { "augment" };
format!("{} ({mode})", o.servers.join(","))
}
}
pub fn config_get(cfg: &AppConfig, key: Option<&str>) -> Result<Vec<(String, String)>> {
let row = |k: &str| -> Result<(String, String)> {
let o = match k {
"relay" => &cfg.relay,
"discovery-dns" => &cfg.discovery_dns,
"dns-upstreams" => &cfg.dns_upstreams,
other => anyhow::bail!(
"unknown config key: {other} (expected relay, discovery-dns, or dns-upstreams)"
),
};
Ok((k.to_string(), render_override(o)))
};
match key {
Some(k) => Ok(vec![row(k)?]),
None => Ok(vec![
row("relay")?,
row("discovery-dns")?,
row("dns-upstreams")?,
]),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_true")]
pub mdns_enabled: bool,
#[serde(default)]
pub operator_uid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_hostname: Option<String>,
#[serde(default, with = "option_secret_key_hex")]
pub contact_secret_key: Option<SecretKey>,
#[serde(default)]
pub relay: ServerOverride,
#[serde(default)]
pub discovery_dns: ServerOverride,
#[serde(default)]
pub dns_upstreams: ServerOverride,
#[serde(default)]
pub ssh_enabled: bool,
#[serde(default)]
pub networks: Vec<NetworkConfig>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
mdns_enabled: true,
operator_uid: None,
default_hostname: None,
contact_secret_key: None,
relay: ServerOverride::default(),
discovery_dns: ServerOverride::default(),
dns_upstreams: ServerOverride::default(),
ssh_enabled: false,
networks: Vec::new(),
}
}
}
pub fn contact_secret(config: &mut AppConfig) -> SecretKey {
if let Some(k) = &config.contact_secret_key {
return k.clone();
}
let secret = SecretKey::generate();
config.contact_secret_key = Some(secret.clone());
secret
}
pub fn rotate_contact_secret(config: &mut AppConfig) -> SecretKey {
let secret = SecretKey::generate();
config.contact_secret_key = Some(secret.clone());
secret
}
const LEGACY_FILE: &str = "networks.toml";
const SETTINGS_FILE: &str = "settings.toml";
const NETWORKS_SUBDIR: &str = "networks";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Settings {
#[serde(default = "default_true")]
mdns_enabled: bool,
#[serde(default)]
operator_uid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_hostname: Option<String>,
#[serde(default, with = "option_secret_key_hex")]
contact_secret_key: Option<SecretKey>,
#[serde(default)]
relay: ServerOverride,
#[serde(default)]
discovery_dns: ServerOverride,
#[serde(default)]
dns_upstreams: ServerOverride,
#[serde(default)]
ssh_enabled: bool,
}
#[cfg(target_os = "linux")]
fn rayfish_gid() -> Option<u32> {
use std::ffi::CString;
let name = CString::new("rayfish").ok()?;
let grp = unsafe { libc::getgrnam(name.as_ptr()) };
if grp.is_null() {
None
} else {
Some(unsafe { (*grp).gr_gid })
}
}
#[cfg(target_os = "linux")]
fn set_owner(path: &Path, secret: bool) {
let gid = if secret {
Some(0)
} else {
rayfish_gid().or(Some(0))
};
if let Err(e) = std::os::unix::fs::chown(path, Some(0), gid) {
tracing::debug!(path = %path.display(), error = %e, "chown failed (non-fatal)");
}
}
fn ensure_dir(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
#[cfg(target_os = "linux")]
{
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o750));
set_owner(dir, false);
}
Ok(())
}
pub fn config_dir() -> Result<PathBuf> {
#[cfg(target_os = "linux")]
let dir = PathBuf::from("/etc/rayfish");
#[cfg(not(target_os = "linux"))]
let dir = dirs::config_dir()
.context("could not determine config directory")?
.join("rayfish");
ensure_dir(&dir)?;
Ok(dir)
}
fn validate_net_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
anyhow::bail!("invalid network name for config file: {name:?}");
}
Ok(())
}
pub fn write_file(path: &Path, bytes: &[u8], secret: bool) -> Result<()> {
let dir = path.parent().context("config path has no parent")?;
ensure_dir(dir)?;
let fname = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config");
let tmp = dir.join(format!(".{fname}.tmp.{}", std::process::id()));
{
use std::io::Write;
let mut f =
std::fs::File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
f.write_all(bytes)
.with_context(|| format!("writing {}", tmp.display()))?;
f.sync_all().ok();
}
let mode = if secret { 0o600 } else { 0o640 };
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
#[cfg(target_os = "linux")]
set_owner(&tmp, secret);
let renamed = std::fs::rename(&tmp, path);
if renamed.is_err() {
let _ = std::fs::remove_file(&tmp);
}
renamed.with_context(|| format!("renaming into {}", path.display()))?;
Ok(())
}
fn write_atomic(path: &Path, contents: &str, secret: bool) -> Result<()> {
write_file(path, contents.as_bytes(), secret)
}
pub fn restrict_perms(path: &Path, secret: bool) {
let mode = if secret { 0o600 } else { 0o640 };
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
#[cfg(target_os = "linux")]
set_owner(path, secret);
}
pub fn migrate_location() {
#[cfg(target_os = "linux")]
{
let Ok(new) = config_dir() else { return };
if new.join("secret_key").exists()
|| new.join(SETTINGS_FILE).exists()
|| new.join(LEGACY_FILE).exists()
|| new.join(NETWORKS_SUBDIR).is_dir()
{
return;
}
let Some(old) = dirs::config_dir().map(|d| d.join("rayfish")) else {
return;
};
if old == new || !old.is_dir() {
return;
}
let Ok(entries) = std::fs::read_dir(&old) else {
return;
};
let mut moved = 0;
for e in entries.flatten() {
let dest = new.join(e.file_name());
match std::fs::rename(e.path(), &dest) {
Ok(()) => moved += 1,
Err(err) => {
tracing::warn!(entry = ?e.path(), error = %err, "could not relocate config entry into /etc/rayfish")
}
}
}
if moved > 0 {
if let Ok(entries) = std::fs::read_dir(&new) {
for e in entries.flatten() {
if e.path().is_file() {
restrict_perms(&e.path(), true);
}
}
}
tracing::info!(from = %old.display(), to = %new.display(), entries = moved, "relocated config tree to /etc/rayfish");
}
}
}
fn migrate_legacy(dir: &Path) -> Result<()> {
let legacy = dir.join(LEGACY_FILE);
if !legacy.exists() {
return Ok(());
}
let contents = std::fs::read_to_string(&legacy).context("reading legacy networks.toml")?;
let old: AppConfig = toml::from_str(&contents).context("parsing legacy networks.toml")?;
save_settings_in(dir, &old)?;
for net in &old.networks {
save_network_in(dir, net)?;
}
let bak = dir.join("networks.toml.bak");
std::fs::rename(&legacy, &bak)
.with_context(|| format!("renaming legacy config to {}", bak.display()))?;
tracing::info!(backup = %bak.display(), networks = old.networks.len(), "migrated legacy config to per-network files");
Ok(())
}
pub fn load() -> Result<AppConfig> {
let dir = config_dir()?;
migrate_legacy(&dir)?;
load_in(&dir)
}
fn load_in(dir: &Path) -> Result<AppConfig> {
let settings_path = dir.join(SETTINGS_FILE);
let settings: Settings = if settings_path.exists() {
let s = std::fs::read_to_string(&settings_path).context("reading settings.toml")?;
toml::from_str(&s).context("parsing settings.toml")?
} else {
Settings {
mdns_enabled: true,
operator_uid: None,
default_hostname: None,
contact_secret_key: None,
relay: ServerOverride::default(),
discovery_dns: ServerOverride::default(),
dns_upstreams: ServerOverride::default(),
ssh_enabled: false,
}
};
let mut networks = Vec::new();
let ndir = dir.join(NETWORKS_SUBDIR);
if ndir.is_dir() {
let mut paths: Vec<PathBuf> = std::fs::read_dir(&ndir)
.with_context(|| format!("reading {}", ndir.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "toml").unwrap_or(false))
.collect();
paths.sort();
for p in paths {
let s =
std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
match toml::from_str::<NetworkConfig>(&s) {
Ok(nc) => networks.push(nc),
Err(e) => {
tracing::warn!(path = %p.display(), error = %e, "skipping unreadable network config")
}
}
}
}
Ok(AppConfig {
mdns_enabled: settings.mdns_enabled,
operator_uid: settings.operator_uid,
default_hostname: settings.default_hostname,
contact_secret_key: settings.contact_secret_key,
relay: settings.relay,
discovery_dns: settings.discovery_dns,
dns_upstreams: settings.dns_upstreams,
ssh_enabled: settings.ssh_enabled,
networks,
})
}
pub fn save_settings(config: &AppConfig) -> Result<()> {
save_settings_in(&config_dir()?, config)
}
fn save_settings_in(dir: &Path, config: &AppConfig) -> Result<()> {
let settings = Settings {
mdns_enabled: config.mdns_enabled,
operator_uid: config.operator_uid,
default_hostname: config.default_hostname.clone(),
contact_secret_key: config.contact_secret_key.clone(),
relay: config.relay.clone(),
discovery_dns: config.discovery_dns.clone(),
dns_upstreams: config.dns_upstreams.clone(),
ssh_enabled: config.ssh_enabled,
};
let path = dir.join(SETTINGS_FILE);
let contents = toml::to_string_pretty(&settings).context("serializing settings")?;
write_atomic(&path, &contents, true)
}
pub fn save_network(net: &NetworkConfig) -> Result<()> {
save_network_in(&config_dir()?, net)
}
fn save_network_in(dir: &Path, net: &NetworkConfig) -> Result<()> {
validate_net_name(&net.name)?;
let ndir = dir.join(NETWORKS_SUBDIR);
let path = ndir.join(format!("{}.toml", net.name));
let contents = toml::to_string_pretty(net).context("serializing network config")?;
write_atomic(&path, &contents, true)
}
pub fn load_network(name: &str) -> Result<Option<NetworkConfig>> {
load_network_in(&config_dir()?, name)
}
fn load_network_in(dir: &Path, name: &str) -> Result<Option<NetworkConfig>> {
validate_net_name(name)?;
let path = dir.join(NETWORKS_SUBDIR).join(format!("{name}.toml"));
if !path.exists() {
return Ok(None);
}
let s =
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
Ok(Some(
toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))?,
))
}
pub fn delete_network(name: &str) -> Result<bool> {
delete_network_in(&config_dir()?, name)
}
fn delete_network_in(dir: &Path, name: &str) -> Result<bool> {
validate_net_name(name)?;
let path = dir.join(NETWORKS_SUBDIR).join(format!("{name}.toml"));
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
}
}
pub fn upsert_network(config: &mut AppConfig, network: NetworkConfig) {
if let Some(existing) = config.networks.iter_mut().find(|n| n.name == network.name) {
*existing = network;
} else {
config.networks.push(network);
}
}
pub fn remove_network(config: &mut AppConfig, name: &str) -> bool {
let before = config.networks.len();
config.networks.retain(|n| n.name != name);
config.networks.len() < before
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::EndpointId;
fn test_id(seed: u8) -> EndpointId {
let mut key_bytes = [0u8; 32];
key_bytes[0] = seed;
iroh::SecretKey::from(key_bytes).public()
}
#[test]
fn test_serialize_roundtrip() {
let config = AppConfig {
networks: vec![
NetworkConfig {
name: "gaming".to_string(),
group_mode: GroupMode::Open,
my_ip: Some(Ipv4Addr::new(100, 64, 10, 5)),
members: vec![
MemberEntry {
identity: test_id(2),
ip: Ipv4Addr::new(100, 64, 5, 3),
is_coordinator: true,
hostname: None,
},
MemberEntry {
identity: test_id(3),
ip: Ipv4Addr::new(100, 64, 10, 5),
is_coordinator: false,
hostname: None,
},
],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
},
NetworkConfig {
name: "work".to_string(),
group_mode: GroupMode::Restricted,
my_ip: None,
members: vec![],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
},
],
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.networks.len(), 2);
assert_eq!(parsed.networks[0].name, "gaming");
assert_eq!(parsed.networks[0].members.len(), 2);
assert_eq!(parsed.networks[1].name, "work");
}
#[test]
fn test_deserialize_empty() {
let config: AppConfig = toml::from_str("").unwrap();
assert!(config.networks.is_empty());
}
#[test]
fn test_upsert_new() {
let mut config = AppConfig::default();
let net = NetworkConfig {
name: "test".to_string(),
group_mode: GroupMode::Open,
my_ip: Some(Ipv4Addr::new(100, 64, 10, 5)),
members: vec![],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
};
upsert_network(&mut config, net);
assert_eq!(config.networks.len(), 1);
assert_eq!(config.networks[0].name, "test");
assert_eq!(config.networks[0].group_mode, GroupMode::Open);
}
#[test]
fn test_upsert_replaces_existing() {
let mut config = AppConfig {
networks: vec![NetworkConfig {
name: "test".to_string(),
group_mode: GroupMode::Restricted,
my_ip: None,
members: vec![],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
}],
..Default::default()
};
let updated = NetworkConfig {
name: "test".to_string(),
group_mode: GroupMode::Open,
my_ip: Some(Ipv4Addr::new(100, 64, 10, 5)),
members: vec![],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
};
upsert_network(&mut config, updated.clone());
assert_eq!(config.networks.len(), 1);
assert_eq!(config.networks[0].group_mode, GroupMode::Open);
assert_eq!(
config.networks[0].my_ip,
Some(Ipv4Addr::new(100, 64, 10, 5))
);
}
#[test]
fn test_remove_network() {
let mut config = AppConfig {
networks: vec![
NetworkConfig {
name: "keep".to_string(),
group_mode: GroupMode::Restricted,
my_ip: None,
members: vec![],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
},
NetworkConfig {
name: "remove-me".to_string(),
group_mode: GroupMode::Restricted,
my_ip: None,
members: vec![],
approved: vec![],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
},
],
..Default::default()
};
assert!(remove_network(&mut config, "remove-me"));
assert_eq!(config.networks.len(), 1);
assert_eq!(config.networks[0].name, "keep");
}
#[test]
fn test_remove_nonexistent() {
let mut config = AppConfig::default();
assert!(!remove_network(&mut config, "nope"));
}
#[test]
fn test_serialize_with_approved() {
let id1 = test_id(1);
let id2 = test_id(2);
let config = AppConfig {
networks: vec![NetworkConfig {
name: "gaming".to_string(),
group_mode: GroupMode::Restricted,
my_ip: Some(Ipv4Addr::new(100, 64, 10, 5)),
members: vec![MemberEntry {
identity: id1,
ip: Ipv4Addr::new(100, 64, 5, 3),
is_coordinator: true,
hostname: None,
}],
approved: vec![ApprovedConfigEntry {
identity: id2,
ip: Ipv4Addr::new(100, 64, 12, 34),
hostname: None,
}],
network_secret_key: None,
network_public_key: None,
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
}],
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.networks[0].approved.len(), 1);
assert_eq!(parsed.networks[0].approved[0].identity, id2);
}
#[test]
fn test_serialize_with_network_key() {
let secret = iroh::SecretKey::generate();
let public = secret.public();
let config = AppConfig {
networks: vec![NetworkConfig {
name: "gaming".to_string(),
group_mode: GroupMode::Restricted,
my_ip: Some(Ipv4Addr::new(100, 64, 10, 5)),
members: vec![],
approved: vec![],
network_secret_key: Some(secret.clone()),
network_public_key: Some(public),
my_hostname: None,
pending_hostname: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
}],
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.networks[0].network_public_key, Some(public));
assert!(parsed.networks[0].network_secret_key.is_some());
}
#[test]
fn test_contact_secret_generate_and_persist() {
let mut config = AppConfig::default();
assert!(config.contact_secret_key.is_none());
let first = contact_secret(&mut config);
let second = contact_secret(&mut config);
assert_eq!(first.public(), second.public());
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
parsed.contact_secret_key.map(|k| k.public()),
Some(first.public())
);
let rotated = rotate_contact_secret(&mut config);
assert_ne!(rotated.public(), first.public());
}
#[test]
fn test_direct_flag_default_false() {
let toml_str = r#"
[[networks]]
name = "dario-alice"
"#;
let config: AppConfig = toml::from_str(toml_str).unwrap();
assert!(!config.networks[0].direct);
}
#[test]
fn test_deserialize_minimal() {
let toml_str = r#"
[[networks]]
name = "test"
"#;
let config: AppConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.networks.len(), 1);
assert_eq!(config.networks[0].name, "test");
assert_eq!(config.networks[0].group_mode, GroupMode::Restricted);
assert!(config.networks[0].members.is_empty());
assert!(config.networks[0].approved.is_empty());
assert!(config.networks[0].network_secret_key.is_none());
assert!(config.networks[0].network_public_key.is_none());
}
fn net(name: &str) -> NetworkConfig {
NetworkConfig {
name: name.to_string(),
group_mode: GroupMode::Restricted,
my_ip: None,
my_hostname: None,
pending_hostname: None,
members: vec![],
approved: vec![],
network_secret_key: Some(iroh::SecretKey::generate()),
network_public_key: None,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
ssh_allow: vec![],
}
}
#[test]
fn per_network_roundtrip_and_delete() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
save_network_in(dir, &net("homelab")).unwrap();
save_network_in(dir, &net("genesis")).unwrap();
save_settings_in(
dir,
&AppConfig {
default_hostname: Some("dario".into()),
..Default::default()
},
)
.unwrap();
let loaded = load_in(dir).unwrap();
assert_eq!(loaded.networks.len(), 2);
assert_eq!(loaded.default_hostname.as_deref(), Some("dario"));
assert!(load_network_in(dir, "homelab").unwrap().is_some());
assert!(load_network_in(dir, "absent").unwrap().is_none());
assert!(delete_network_in(dir, "homelab").unwrap());
assert!(!delete_network_in(dir, "homelab").unwrap());
let after = load_in(dir).unwrap();
assert_eq!(after.networks.len(), 1);
assert_eq!(after.networks[0].name, "genesis");
}
#[test]
fn settings_roundtrip_server_overrides() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let fresh = load_in(dir).unwrap();
assert!(fresh.relay.is_unset());
assert!(fresh.discovery_dns.is_unset());
assert!(fresh.dns_upstreams.is_unset());
let cfg = AppConfig {
relay: ServerOverride {
servers: vec!["http://r:1".into()],
replace: true,
},
dns_upstreams: ServerOverride {
servers: vec!["1.1.1.1".into()],
replace: false,
},
..Default::default()
};
save_settings_in(dir, &cfg).unwrap();
let loaded = load_in(dir).unwrap();
assert_eq!(loaded.relay, cfg.relay);
assert_eq!(loaded.dns_upstreams, cfg.dns_upstreams);
assert!(loaded.discovery_dns.is_unset());
}
#[test]
fn relay_urls_expands_rayfish_preset() {
let o = ServerOverride {
servers: vec!["rayfish".into()],
replace: false,
};
assert_eq!(
relay_urls(&o).unwrap(),
vec![RELAY_PRESET_RAYFISH.to_string()]
);
let d = ServerOverride {
servers: vec!["rayfish".into()],
replace: false,
};
assert_eq!(
discovery_urls(&d).unwrap(),
vec![DISCOVERY_PRESET_RAYFISH.to_string()]
);
}
#[test]
fn url_entry_rejects_bad() {
assert!(
relay_urls(&ServerOverride {
servers: vec!["ftp://x".into()],
replace: false
})
.is_err()
);
assert!(
relay_urls(&ServerOverride {
servers: vec!["not a url".into()],
replace: false
})
.is_err()
);
let ok = ServerOverride {
servers: vec!["http://r:1".into()],
replace: false,
};
assert_eq!(relay_urls(&ok).unwrap(), vec!["http://r:1".to_string()]);
}
#[test]
fn resolve_upstreams_augment_and_replace() {
let captured = vec![Ipv4Addr::new(192, 168, 1, 1)];
let one = Ipv4Addr::new(1, 1, 1, 1);
assert_eq!(
resolve_upstreams(&ServerOverride::default(), captured.clone()),
captured
);
let aug = ServerOverride {
servers: vec!["1.1.1.1".into()],
replace: false,
};
assert_eq!(
resolve_upstreams(&aug, captured.clone()),
vec![one, captured[0]]
);
let rep = ServerOverride {
servers: vec!["1.1.1.1".into()],
replace: true,
};
assert_eq!(resolve_upstreams(&rep, captured.clone()), vec![one]);
}
#[test]
fn config_set_unknown_key_errors() {
let mut cfg = AppConfig::default();
assert!(config_set(&mut cfg, "bogus", "rayfish", false).is_err());
assert!(config_get(&cfg, Some("bogus")).is_err());
}
#[test]
fn config_set_n0_resets() {
let mut cfg = AppConfig::default();
config_set(&mut cfg, "relay", "rayfish", true).unwrap();
assert!(!cfg.relay.is_unset());
config_set(&mut cfg, "relay", "n0", false).unwrap();
assert!(cfg.relay.is_unset());
}
#[test]
fn config_set_dns_upstreams_rejects_non_ip() {
let mut cfg = AppConfig::default();
assert!(config_set(&mut cfg, "dns-upstreams", "1.1.1.1", false).is_ok());
assert!(config_set(&mut cfg, "dns-upstreams", "not-an-ip", false).is_err());
assert!(config_set(&mut cfg, "dns-upstreams", "rayfish", false).is_err());
}
#[test]
fn concurrent_saves_do_not_clobber() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
const N: usize = 24;
std::thread::scope(|s| {
for i in 0..N {
let dir = dir.clone();
s.spawn(move || {
save_network_in(&dir, &net(&format!("net-{i}"))).unwrap();
});
}
});
let loaded = load_in(&dir).unwrap();
assert_eq!(
loaded.networks.len(),
N,
"all concurrent saves must survive"
);
}
#[test]
fn migrate_legacy_splits_and_backs_up() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let legacy = AppConfig {
default_hostname: Some("dario".into()),
networks: vec![net("homelab"), net("genesis")],
..Default::default()
};
std::fs::write(
dir.join(LEGACY_FILE),
toml::to_string_pretty(&legacy).unwrap(),
)
.unwrap();
migrate_legacy(dir).unwrap();
assert!(!dir.join(LEGACY_FILE).exists());
assert!(dir.join("networks.toml.bak").exists());
let loaded = load_in(dir).unwrap();
assert_eq!(loaded.networks.len(), 2);
assert_eq!(loaded.default_hostname.as_deref(), Some("dario"));
migrate_legacy(dir).unwrap();
assert_eq!(load_in(dir).unwrap().networks.len(), 2);
}
#[test]
fn rejects_unsafe_network_names() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert!(save_network_in(dir, &net("../escape")).is_err());
assert!(load_network_in(dir, "a/b").is_err());
}
}