use std::net::Ipv4Addr;
use std::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)]
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,
}
fn default_true() -> bool {
true
}
#[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 networks: Vec<NetworkConfig>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
mdns_enabled: true,
operator_uid: None,
default_hostname: None,
contact_secret_key: None,
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
}
fn config_path() -> Result<PathBuf> {
let dir = dirs::config_dir()
.context("could not determine config directory")?
.join("rayfish");
std::fs::create_dir_all(&dir)?;
Ok(dir.join("networks.toml"))
}
pub fn load() -> Result<AppConfig> {
let path = config_path()?;
if !path.exists() {
return Ok(AppConfig::default());
}
let contents = std::fs::read_to_string(&path).context("reading networks.toml")?;
toml::from_str(&contents).context("parsing networks.toml")
}
pub fn save(config: &AppConfig) -> Result<()> {
let path = config_path()?;
let contents = toml::to_string_pretty(config).context("serializing config")?;
std::fs::write(&path, contents).context("writing networks.toml")?;
Ok(())
}
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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
},
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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
},
],
..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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
};
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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
}],
..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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
};
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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
},
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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
},
],
..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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
}],
..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,
transport: None,
auto_accept_firewall: false,
admins: vec![],
direct: false,
}],
..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());
}
}