#[cfg(target_os = "linux")]
mod gateway;
mod node;
mod peer;
mod transport;
use crate::upper::config::{DnsConfig, TunConfig};
use crate::{Identity, IdentityError};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
pub use crate::discovery::local::LocalInstanceDiscoveryConfig;
#[cfg(target_os = "linux")]
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
pub use node::{
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig, TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
#[cfg(feature = "sim-transport")]
pub use transport::SimTransportConfig;
pub use transport::{
BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
TransportsConfig, UdpConfig, WebRtcConfig,
};
const CONFIG_FILENAME: &str = "fips.yaml";
const KEY_FILENAME: &str = "fips.key";
const PUB_FILENAME: &str = "fips.pub";
fn is_loopback_addr_str(addr: &str) -> bool {
if let Some(rest) = addr.strip_prefix('[')
&& let Some(end) = rest.find(']')
{
let host = &rest[..end];
return host == "::1";
}
let host = match addr.rsplit_once(':') {
Some((h, _)) => h,
None => addr,
};
host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
}
pub fn key_file_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or(Path::new("."))
.join(KEY_FILENAME)
}
pub fn pub_file_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or(Path::new("."))
.join(PUB_FILENAME)
}
#[cfg(unix)]
pub(crate) fn resolve_default_socket(filename: &str) -> String {
if Path::new("/run/fips").is_dir() {
return format!("/run/fips/{filename}");
}
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR")
&& Path::new(&xdg).is_dir()
{
return format!("{xdg}/fips/{filename}");
}
format!("/tmp/fips-{filename}")
}
pub fn default_control_path() -> PathBuf {
#[cfg(unix)]
{
PathBuf::from(resolve_default_socket("control.sock"))
}
#[cfg(windows)]
{
PathBuf::from("21210")
}
}
pub fn default_gateway_path() -> PathBuf {
#[cfg(unix)]
{
PathBuf::from(resolve_default_socket("gateway.sock"))
}
#[cfg(windows)]
{
PathBuf::from("21211")
}
}
pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
let nsec = contents.trim().to_string();
if nsec.is_empty() {
return Err(ConfigError::EmptyKeyFile {
path: path.to_path_buf(),
});
}
Ok(nsec)
}
pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
}
file.write_all(nsec.as_bytes())
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(b"\n")
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o644);
}
let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(npub.as_bytes())
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(b"\n")
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub fn resolve_identity(
config: &Config,
loaded_paths: &[PathBuf],
) -> Result<ResolvedIdentity, ConfigError> {
use crate::encode_nsec;
if let Some(nsec) = &config.node.identity.nsec {
return Ok(ResolvedIdentity {
nsec: nsec.clone(),
source: IdentitySource::Config,
});
}
let config_ref = if let Some(path) = loaded_paths.last() {
path.clone()
} else {
Config::search_paths()
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("./fips.yaml"))
};
let key_path = key_file_path(&config_ref);
let pub_path = pub_file_path(&config_ref);
if config.node.identity.persistent {
if key_path.exists() {
let nsec = read_key_file(&key_path)?;
let identity = Identity::from_secret_str(&nsec)?;
let _ = write_pub_file(&pub_path, &identity.npub());
return Ok(ResolvedIdentity {
nsec,
source: IdentitySource::KeyFile(key_path),
});
}
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if let Some(parent) = key_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match write_key_file(&key_path, &nsec) {
Ok(()) => {
let _ = write_pub_file(&pub_path, &npub);
Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Generated(key_path),
})
}
Err(_) => Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Ephemeral,
}),
}
} else {
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if let Some(parent) = key_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = write_key_file(&key_path, &nsec);
let _ = write_pub_file(&pub_path, &npub);
Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Ephemeral,
})
}
}
pub struct ResolvedIdentity {
pub nsec: String,
pub source: IdentitySource,
}
pub enum IdentitySource {
Config,
KeyFile(PathBuf),
Generated(PathBuf),
Ephemeral,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config file {path}: {source}")]
ParseYaml {
path: PathBuf,
source: serde_yaml::Error,
},
#[error("key file is empty: {path}")]
EmptyKeyFile { path: PathBuf },
#[error("failed to write key file {path}: {source}")]
WriteKeyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("identity error: {0}")]
Identity(#[from] IdentityError),
#[error("invalid configuration: {0}")]
Validation(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nsec: Option<String>,
#[serde(default)]
pub persistent: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub node: NodeConfig,
#[serde(default)]
pub tun: TunConfig,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
pub transports: TransportsConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<PeerConfig>,
#[cfg(target_os = "linux")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewayConfig>,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
let search_paths = Self::search_paths();
Self::load_from_paths(&search_paths)
}
pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let mut loaded_paths = Vec::new();
for path in paths {
if path.exists() {
let contents =
std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
let mut file_config: serde_yaml::Value =
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
path: path.to_path_buf(),
source: e,
})?;
if file_config.is_null() {
file_config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
}
merge_yaml_value(&mut merged, file_config);
loaded_paths.push(path.clone());
}
}
let config = serde_yaml::from_value(merged).map_err(|e| ConfigError::ParseYaml {
path: loaded_paths
.last()
.cloned()
.unwrap_or_else(|| PathBuf::from("<merged config>")),
source: e,
})?;
Ok((config, loaded_paths))
}
pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
path: path.to_path_buf(),
source: e,
})
}
pub fn search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
}
if let Some(home_dir) = dirs::home_dir() {
paths.push(home_dir.join(".fips.yaml"));
}
paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
paths
}
pub fn merge(&mut self, other: Config) {
if other.node.identity.nsec.is_some() {
self.node.identity.nsec = other.node.identity.nsec;
}
if other.node.identity.persistent {
self.node.identity.persistent = true;
}
if other.node.leaf_only {
self.node.leaf_only = true;
}
if other.tun.enabled {
self.tun.enabled = true;
}
if other.tun.name.is_some() {
self.tun.name = other.tun.name;
}
if other.tun.mtu.is_some() {
self.tun.mtu = other.tun.mtu;
}
self.dns.enabled = other.dns.enabled;
if other.dns.bind_addr.is_some() {
self.dns.bind_addr = other.dns.bind_addr;
}
if other.dns.port.is_some() {
self.dns.port = other.dns.port;
}
if other.dns.ttl.is_some() {
self.dns.ttl = other.dns.ttl;
}
self.transports.merge(other.transports);
if !other.peers.is_empty() {
self.peers = other.peers;
}
#[cfg(target_os = "linux")]
if other.gateway.is_some() {
self.gateway = other.gateway;
}
}
pub fn create_identity(&self) -> Result<Identity, ConfigError> {
match &self.node.identity.nsec {
Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
None => Ok(Identity::generate()),
}
}
pub fn has_identity(&self) -> bool {
self.node.identity.nsec.is_some()
}
pub fn is_leaf_only(&self) -> bool {
self.node.leaf_only
}
pub fn peers(&self) -> &[PeerConfig] {
&self.peers
}
pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
self.peers.iter().filter(|p| p.is_auto_connect())
}
pub fn validate(&self) -> Result<(), ConfigError> {
let nostr = &self.node.discovery.nostr;
let any_transport_advertises_on_nostr = self
.transports
.udp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.tcp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.tor
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.webrtc
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr());
if any_transport_advertises_on_nostr && !nostr.enabled {
return Err(ConfigError::Validation(
"at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
));
}
for (i, peer) in self.peers.iter().enumerate() {
if peer.addresses.is_empty() && !nostr.enabled {
return Err(ConfigError::Validation(format!(
"peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
peer.npub
)));
}
}
let has_nat_udp_advert = self
.transports
.udp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
if nostr.enabled && has_nat_udp_advert {
if nostr.dm_relays.is_empty() {
return Err(ConfigError::Validation(
"NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
));
}
if nostr.stun_servers.is_empty() {
return Err(ConfigError::Validation(
"NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
));
}
}
let has_webrtc_advert_without_relays = self.transports.webrtc.iter().any(|(_, cfg)| {
cfg.advertise_on_nostr() && cfg.signal_relays(&nostr.dm_relays).is_empty()
});
if nostr.enabled && has_webrtc_advert_without_relays {
return Err(ConfigError::Validation(
"WebRTC advert publishing requires `node.discovery.nostr.dm_relays` or `transports.webrtc.signal_relays` to be non-empty".to_string(),
));
}
for (name, cfg) in self.transports.udp.iter() {
if cfg.outbound_only() {
continue;
}
if is_loopback_addr_str(cfg.bind_addr()) {
let any_external_peer = self.peers.iter().any(|peer| {
peer.addresses
.iter()
.any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
});
if any_external_peer {
let label = name.unwrap_or("(unnamed)");
return Err(ConfigError::Validation(format!(
"transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
fips cannot reach external peers from a loopback-bound socket. \
Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
cfg.bind_addr()
)));
}
}
}
Ok(())
}
pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
serde_yaml::to_string(self)
}
}
fn merge_yaml_value(base: &mut serde_yaml::Value, overlay: serde_yaml::Value) {
match (base, overlay) {
(serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(overlay_map)) => {
for (key, value) in overlay_map {
match base_map.get_mut(&key) {
Some(existing) => merge_yaml_value(existing, value),
None => {
base_map.insert(key, value);
}
}
}
}
(base_slot, overlay) => *base_slot = overlay,
}
}
#[cfg(test)]
mod tests;