mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
- Implement fips.rs binary with config loading, node creation, and logging - Create src/tun.rs module (TunDevice, TunState, TunError) - Use rtnetlink crate for IPv6 address configuration - Make TunDevice::create() and Node::init_tun() async - Add IPv6 disabled check with helpful error message - Add rtnetlink, tokio, futures dependencies - Single tokio current_thread runtime for entire driver 162 tests pass.
453 lines
13 KiB
Rust
453 lines
13 KiB
Rust
//! FIPS Configuration System
|
|
//!
|
|
//! Loads configuration from YAML files with a cascading priority system:
|
|
//! 1. `./fips.yaml` (current directory - highest priority)
|
|
//! 2. `~/.config/fips/fips.yaml` (user config directory)
|
|
//! 3. `/etc/fips/fips.yaml` (system - lowest priority)
|
|
//!
|
|
//! Values from higher priority files override those from lower priority files.
|
|
//!
|
|
//! # YAML Structure
|
|
//!
|
|
//! The YAML structure mirrors the sysctl-style paths in the architecture docs.
|
|
//! For example, `node.identity.nsec` in the docs corresponds to:
|
|
//!
|
|
//! ```yaml
|
|
//! node:
|
|
//! identity:
|
|
//! nsec: "nsec1..."
|
|
//! ```
|
|
|
|
use crate::{Identity, IdentityError};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
use thiserror::Error;
|
|
|
|
/// Default config filename.
|
|
const CONFIG_FILENAME: &str = "fips.yaml";
|
|
|
|
/// Errors that can occur during configuration loading.
|
|
#[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("identity error: {0}")]
|
|
Identity(#[from] IdentityError),
|
|
}
|
|
|
|
/// Identity configuration (`node.identity.*`).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct IdentityConfig {
|
|
/// Secret key in nsec (bech32) or hex format (`node.identity.nsec`).
|
|
/// If not specified, a new keypair will be generated.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub nsec: Option<String>,
|
|
}
|
|
|
|
/// Node configuration (`node.*`).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct NodeConfig {
|
|
/// Identity configuration (`node.identity.*`).
|
|
#[serde(default)]
|
|
pub identity: IdentityConfig,
|
|
|
|
/// Leaf-only mode (`node.leaf_only`).
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub leaf_only: bool,
|
|
}
|
|
|
|
/// Default TUN device name.
|
|
const DEFAULT_TUN_NAME: &str = "fips0";
|
|
|
|
/// Default TUN MTU (IPv6 minimum).
|
|
const DEFAULT_TUN_MTU: u16 = 1280;
|
|
|
|
/// TUN interface configuration (`tun.*`).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct TunConfig {
|
|
/// Enable TUN interface (`tun.enabled`).
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub enabled: bool,
|
|
|
|
/// TUN device name (`tun.name`). Defaults to "fips0".
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
|
|
/// TUN MTU (`tun.mtu`). Defaults to 1280 (IPv6 minimum).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub mtu: Option<u16>,
|
|
|
|
}
|
|
|
|
impl TunConfig {
|
|
/// Get the TUN device name, using default if not configured.
|
|
pub fn name(&self) -> &str {
|
|
self.name.as_deref().unwrap_or(DEFAULT_TUN_NAME)
|
|
}
|
|
|
|
/// Get the TUN MTU, using default if not configured.
|
|
pub fn mtu(&self) -> u16 {
|
|
self.mtu.unwrap_or(DEFAULT_TUN_MTU)
|
|
}
|
|
}
|
|
|
|
/// Root configuration structure.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
/// Node configuration (`node.*`).
|
|
#[serde(default)]
|
|
pub node: NodeConfig,
|
|
|
|
/// TUN interface configuration (`tun.*`).
|
|
#[serde(default)]
|
|
pub tun: TunConfig,
|
|
}
|
|
|
|
impl Config {
|
|
/// Create a new empty configuration.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Load configuration from the standard search paths.
|
|
///
|
|
/// Files are loaded in reverse priority order and merged:
|
|
/// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority)
|
|
/// 2. `~/.config/fips/fips.yaml` (user config)
|
|
/// 3. `./fips.yaml` (loaded last, highest priority)
|
|
///
|
|
/// Returns a tuple of (config, paths_loaded) where paths_loaded contains
|
|
/// the paths that were successfully loaded.
|
|
pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
|
|
let search_paths = Self::search_paths();
|
|
Self::load_from_paths(&search_paths)
|
|
}
|
|
|
|
/// Load configuration from specific paths.
|
|
///
|
|
/// Paths are processed in order, with later paths overriding earlier ones.
|
|
pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
|
|
let mut config = Config::default();
|
|
let mut loaded_paths = Vec::new();
|
|
|
|
for path in paths {
|
|
if path.exists() {
|
|
let file_config = Self::load_file(path)?;
|
|
config.merge(file_config);
|
|
loaded_paths.push(path.clone());
|
|
}
|
|
}
|
|
|
|
Ok((config, loaded_paths))
|
|
}
|
|
|
|
/// Load configuration from a single file.
|
|
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,
|
|
})
|
|
}
|
|
|
|
/// Get the standard search paths in priority order (lowest to highest).
|
|
pub fn search_paths() -> Vec<PathBuf> {
|
|
let mut paths = Vec::new();
|
|
|
|
// System config (lowest priority)
|
|
paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
|
|
|
|
// User config directory
|
|
if let Some(config_dir) = dirs::config_dir() {
|
|
paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
|
|
}
|
|
|
|
// Home directory (legacy location)
|
|
if let Some(home_dir) = dirs::home_dir() {
|
|
paths.push(home_dir.join(".fips.yaml"));
|
|
}
|
|
|
|
// Current directory (highest priority)
|
|
paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
|
|
|
|
paths
|
|
}
|
|
|
|
/// Merge another configuration into this one.
|
|
///
|
|
/// Values from `other` override values in `self` when present.
|
|
pub fn merge(&mut self, other: Config) {
|
|
// Merge node.identity section
|
|
if other.node.identity.nsec.is_some() {
|
|
self.node.identity.nsec = other.node.identity.nsec;
|
|
}
|
|
// Merge node.leaf_only
|
|
if other.node.leaf_only {
|
|
self.node.leaf_only = true;
|
|
}
|
|
// Merge tun section
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Create an Identity from this configuration.
|
|
///
|
|
/// If an nsec is configured, uses that to create the identity.
|
|
/// Otherwise, generates a new random identity.
|
|
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()),
|
|
}
|
|
}
|
|
|
|
/// Check if an identity is configured (vs. will be generated).
|
|
pub fn has_identity(&self) -> bool {
|
|
self.node.identity.nsec.is_some()
|
|
}
|
|
|
|
/// Check if leaf-only mode is configured.
|
|
pub fn is_leaf_only(&self) -> bool {
|
|
self.node.leaf_only
|
|
}
|
|
|
|
/// Serialize this configuration to YAML.
|
|
pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
|
|
serde_yaml::to_string(self)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_empty_config() {
|
|
let config = Config::new();
|
|
assert!(config.node.identity.nsec.is_none());
|
|
assert!(!config.has_identity());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_yaml_with_nsec() {
|
|
let yaml = r#"
|
|
node:
|
|
identity:
|
|
nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
|
|
"#;
|
|
let config: Config = serde_yaml::from_str(yaml).unwrap();
|
|
assert!(config.node.identity.nsec.is_some());
|
|
assert!(config.has_identity());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_yaml_with_hex() {
|
|
let yaml = r#"
|
|
node:
|
|
identity:
|
|
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
|
"#;
|
|
let config: Config = serde_yaml::from_str(yaml).unwrap();
|
|
assert!(config.node.identity.nsec.is_some());
|
|
|
|
let identity = config.create_identity().unwrap();
|
|
assert!(!identity.npub().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_yaml_empty() {
|
|
let yaml = "";
|
|
let config: Config = serde_yaml::from_str(yaml).unwrap();
|
|
assert!(config.node.identity.nsec.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_yaml_partial() {
|
|
let yaml = r#"
|
|
node:
|
|
identity: {}
|
|
"#;
|
|
let config: Config = serde_yaml::from_str(yaml).unwrap();
|
|
assert!(config.node.identity.nsec.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_configs() {
|
|
let mut base = Config::new();
|
|
base.node.identity.nsec = Some("base_nsec".to_string());
|
|
|
|
let mut override_config = Config::new();
|
|
override_config.node.identity.nsec = Some("override_nsec".to_string());
|
|
|
|
base.merge(override_config);
|
|
assert_eq!(
|
|
base.node.identity.nsec,
|
|
Some("override_nsec".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_preserves_base_when_override_empty() {
|
|
let mut base = Config::new();
|
|
base.node.identity.nsec = Some("base_nsec".to_string());
|
|
|
|
let override_config = Config::new();
|
|
|
|
base.merge(override_config);
|
|
assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_identity_from_nsec() {
|
|
let mut config = Config::new();
|
|
config.node.identity.nsec = Some(
|
|
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
|
|
);
|
|
|
|
let identity = config.create_identity().unwrap();
|
|
assert!(!identity.npub().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_identity_generates_new() {
|
|
let config = Config::new();
|
|
let identity = config.create_identity().unwrap();
|
|
assert!(!identity.npub().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_from_file() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config_path = temp_dir.path().join("fips.yaml");
|
|
|
|
let yaml = r#"
|
|
node:
|
|
identity:
|
|
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
|
"#;
|
|
fs::write(&config_path, yaml).unwrap();
|
|
|
|
let config = Config::load_file(&config_path).unwrap();
|
|
assert!(config.node.identity.nsec.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_from_paths_merges() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
// Create two config files
|
|
let low_priority = temp_dir.path().join("low.yaml");
|
|
let high_priority = temp_dir.path().join("high.yaml");
|
|
|
|
fs::write(
|
|
&low_priority,
|
|
r#"
|
|
node:
|
|
identity:
|
|
nsec: "low_priority_nsec"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
fs::write(
|
|
&high_priority,
|
|
r#"
|
|
node:
|
|
identity:
|
|
nsec: "high_priority_nsec"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let paths = vec![low_priority.clone(), high_priority.clone()];
|
|
let (config, loaded) = Config::load_from_paths(&paths).unwrap();
|
|
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(
|
|
config.node.identity.nsec,
|
|
Some("high_priority_nsec".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_skips_missing_files() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let existing = temp_dir.path().join("exists.yaml");
|
|
let missing = temp_dir.path().join("missing.yaml");
|
|
|
|
fs::write(
|
|
&existing,
|
|
r#"
|
|
node:
|
|
identity:
|
|
nsec: "existing_nsec"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let paths = vec![missing, existing.clone()];
|
|
let (config, loaded) = Config::load_from_paths(&paths).unwrap();
|
|
|
|
assert_eq!(loaded.len(), 1);
|
|
assert_eq!(loaded[0], existing);
|
|
assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_paths_includes_expected() {
|
|
let paths = Config::search_paths();
|
|
|
|
// Should include current directory
|
|
assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
|
|
|
|
// Should include /etc/fips
|
|
assert!(paths
|
|
.iter()
|
|
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_yaml() {
|
|
let mut config = Config::new();
|
|
config.node.identity.nsec = Some("test_nsec".to_string());
|
|
|
|
let yaml = config.to_yaml().unwrap();
|
|
assert!(yaml.contains("node:"));
|
|
assert!(yaml.contains("identity:"));
|
|
assert!(yaml.contains("nsec:"));
|
|
assert!(yaml.contains("test_nsec"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_yaml_empty_nsec_omitted() {
|
|
let config = Config::new();
|
|
let yaml = config.to_yaml().unwrap();
|
|
|
|
// Empty nsec should not be serialized
|
|
assert!(!yaml.contains("nsec:"));
|
|
}
|
|
}
|