Restructure config to match sysctl-style paths

- Add NodeConfig wrapper: node.identity.nsec, node.leaf_only
- YAML structure mirrors architecture documentation exactly
- Move main.rs to src/bin/fips.rs (empty binary placeholder)
- Add "Connection Model" section to fips-transports.md documenting
  connectionless vs connection-oriented transport categories
- Fix clippy warnings (dead_code, clone_on_copy, assertions_on_constants)
This commit is contained in:
Johnathan Corgan
2026-01-29 19:49:37 +00:00
parent 6e343c35e5
commit 185fe50570
6 changed files with 151 additions and 113 deletions
+56
View File
@@ -94,6 +94,62 @@ the routing layer.
**Bandwidth**: Order of magnitude. Affects flow control and congestion
decisions, but FIPS routing itself is low-bandwidth (control plane only).
## Connection Model
Transports fall into two categories based on whether they require connection
establishment before data can be exchanged:
### Connectionless Transports
These transports can send datagrams to a peer address without prior setup.
Links are lightweight—just a `(transport_id, remote_addr)` tuple with implicit
"established" state.
| Transport | Notes |
|-----------|-------|
| UDP/IP | Stateless datagrams; NAT state is implicit |
| Ethernet | Send to MAC address directly |
| WiFi | Same as Ethernet (802.11 frame) |
| DOCSIS | Cable modem layer; uses IP in practice |
| LoRa | Raw packets to device address |
| I2P | Datagram mode (not streaming) |
### Connection-Oriented Transports
These transports require explicit connection setup before FIPS traffic can flow.
Links track real connection state and hold I/O handles. The link must complete
transport-layer connection before FIPS authentication can proceed.
| Transport | Connection Setup |
|-----------|------------------|
| TCP/IP | TCP handshake |
| WebSocket | HTTP upgrade + TCP |
| Tor | Circuit establishment (slow: 500ms-5s) |
| Bluetooth Classic | L2CAP connection |
| BLE | L2CAP CoC or GATT connection |
| Zigbee | Network join + binding |
| Serial | Physical connection (static) |
| Dialup | PPP negotiation |
### Implications for FIPS
**Link lifecycle**: Connectionless transports use a trivial link model (no state
machine). Connection-oriented transports require a real state machine:
`Connecting → Connected → Disconnected`. See
[fips-architecture.md](fips-architecture.md) for link lifecycle details.
**Startup latency**: Connection-oriented transports add latency before a peer
becomes usable. Tor is particularly slow (circuit setup). This affects peer
timeout configuration.
**Failure modes**: Connectionless links "fail" only when the transport itself
is down or the peer stops responding. Connection-oriented links can fail during
connection setup, adding more error handling paths.
**Framing**: Connection-oriented stream transports (TCP, WebSocket, Tor) require
length-prefix framing to delineate FIPS packets. Datagram transports have
natural packet boundaries.
## UDP/IP as Primary Internet Transport
For internet-connected nodes, UDP/IP is the recommended transport:
+3
View File
@@ -0,0 +1,3 @@
fn main() {
//
}
+88 -51
View File
@@ -6,6 +6,17 @@
//! 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};
@@ -34,21 +45,33 @@ pub enum ConfigError {
Identity(#[from] IdentityError),
}
/// Identity configuration section.
/// Identity configuration (`node.identity.*`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityConfig {
/// Node secret key in nsec (bech32) or hex format.
/// 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,
}
/// Root configuration structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
/// Identity configuration.
/// Node configuration (`node.*`).
#[serde(default)]
pub identity: IdentityConfig,
pub node: NodeConfig,
}
impl Config {
@@ -129,9 +152,13 @@ impl Config {
///
/// Values from `other` override values in `self` when present.
pub fn merge(&mut self, other: Config) {
// Merge identity section
if other.identity.nsec.is_some() {
self.identity.nsec = other.identity.nsec;
// 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;
}
}
@@ -140,7 +167,7 @@ impl Config {
/// 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.identity.nsec {
match &self.node.identity.nsec {
Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
None => Ok(Identity::generate()),
}
@@ -148,7 +175,12 @@ impl Config {
/// Check if an identity is configured (vs. will be generated).
pub fn has_identity(&self) -> bool {
self.identity.nsec.is_some()
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.
@@ -166,29 +198,31 @@ mod tests {
#[test]
fn test_empty_config() {
let config = Config::new();
assert!(config.identity.nsec.is_none());
assert!(config.node.identity.nsec.is_none());
assert!(!config.has_identity());
}
#[test]
fn test_parse_yaml_with_nsec() {
let yaml = r#"
identity:
nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
node:
identity:
nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.identity.nsec.is_some());
assert!(config.node.identity.nsec.is_some());
assert!(config.has_identity());
}
#[test]
fn test_parse_yaml_with_hex() {
let yaml = r#"
identity:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
node:
identity:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.identity.nsec.is_some());
assert!(config.node.identity.nsec.is_some());
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
@@ -198,53 +232,51 @@ identity:
fn test_parse_yaml_empty() {
let yaml = "";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.identity.nsec.is_none());
assert!(config.node.identity.nsec.is_none());
}
#[test]
fn test_parse_yaml_partial() {
let yaml = r#"
identity: {}
node:
identity: {}
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.identity.nsec.is_none());
assert!(config.node.identity.nsec.is_none());
}
#[test]
fn test_merge_configs() {
let mut base = Config::new();
base.identity.nsec = Some("base_nsec".to_string());
base.node.identity.nsec = Some("base_nsec".to_string());
let override_config = Config {
identity: IdentityConfig {
nsec: Some("override_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.identity.nsec, Some("override_nsec".to_string()));
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.identity.nsec = Some("base_nsec".to_string());
base.node.identity.nsec = Some("base_nsec".to_string());
let override_config = Config::new();
base.merge(override_config);
assert_eq!(base.identity.nsec, Some("base_nsec".to_string()));
assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
}
#[test]
fn test_create_identity_from_nsec() {
let config = Config {
identity: IdentityConfig {
nsec: Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
),
},
};
let mut config = Config::new();
config.node.identity.nsec = Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
);
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
@@ -263,13 +295,14 @@ identity: {}
let config_path = temp_dir.path().join("fips.yaml");
let yaml = r#"
identity:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
node:
identity:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
"#;
fs::write(&config_path, yaml).unwrap();
let config = Config::load_file(&config_path).unwrap();
assert!(config.identity.nsec.is_some());
assert!(config.node.identity.nsec.is_some());
}
#[test]
@@ -283,8 +316,9 @@ identity:
fs::write(
&low_priority,
r#"
identity:
nsec: "low_priority_nsec"
node:
identity:
nsec: "low_priority_nsec"
"#,
)
.unwrap();
@@ -292,8 +326,9 @@ identity:
fs::write(
&high_priority,
r#"
identity:
nsec: "high_priority_nsec"
node:
identity:
nsec: "high_priority_nsec"
"#,
)
.unwrap();
@@ -302,7 +337,10 @@ identity:
let (config, loaded) = Config::load_from_paths(&paths).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(config.identity.nsec, Some("high_priority_nsec".to_string()));
assert_eq!(
config.node.identity.nsec,
Some("high_priority_nsec".to_string())
);
}
#[test]
@@ -314,8 +352,9 @@ identity:
fs::write(
&existing,
r#"
identity:
nsec: "existing_nsec"
node:
identity:
nsec: "existing_nsec"
"#,
)
.unwrap();
@@ -325,7 +364,7 @@ identity:
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], existing);
assert_eq!(config.identity.nsec, Some("existing_nsec".to_string()));
assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
}
#[test]
@@ -343,13 +382,11 @@ identity:
#[test]
fn test_to_yaml() {
let config = Config {
identity: IdentityConfig {
nsec: Some("test_nsec".to_string()),
},
};
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"));
-61
View File
@@ -1,61 +0,0 @@
use fips::Config;
fn main() {
println!("FIPS Node Startup");
println!("=================\n");
// Load configuration from standard search paths
println!("1. Loading configuration...");
println!(" Search paths (in priority order, lowest to highest):");
for path in Config::search_paths() {
let exists = path.exists();
let status = if exists { "[found]" } else { "[not found]" };
println!(" {} {}", status, path.display());
}
println!();
let (config, loaded_paths) = match Config::load() {
Ok(result) => result,
Err(e) => {
eprintln!(" Error loading config: {}", e);
std::process::exit(1);
}
};
if loaded_paths.is_empty() {
println!(" No config files found, using defaults.");
} else {
println!(" Loaded {} config file(s):", loaded_paths.len());
for path in &loaded_paths {
println!(" - {}", path.display());
}
}
// Create identity from configuration
println!("\n2. Initializing identity...");
let identity = match config.create_identity() {
Ok(id) => id,
Err(e) => {
eprintln!(" Error creating identity: {}", e);
std::process::exit(1);
}
};
if config.has_identity() {
println!(" Using configured identity.");
} else {
println!(" No identity configured, generated new keypair.");
}
println!();
if let Some(nsec) = &config.identity.nsec {
println!(" nsec: {}", nsec);
} else {
println!(" nsec: (generated)");
}
println!(" npub: {}", identity.npub());
println!(" node_id: {}", identity.node_id());
println!(" IPv6 address: {}", identity.address());
println!("\nReady.");
}
+2 -1
View File
@@ -501,6 +501,7 @@ mod tests {
Node::new(config).unwrap()
}
#[allow(dead_code)]
fn make_node_id(val: u8) -> NodeId {
let mut bytes = [0u8; 32];
bytes[0] = val;
@@ -646,7 +647,7 @@ mod tests {
let peer_identity = Identity::generate();
let peer_pub = crate::PeerIdentity::from_pubkey(peer_identity.pubkey());
let peer1 = Peer::discovered(peer_pub.clone(), LinkId::new(1));
let peer1 = Peer::discovered(peer_pub, LinkId::new(1));
let peer2 = Peer::discovered(peer_pub, LinkId::new(2));
node.add_peer(peer1).unwrap();
+2
View File
@@ -695,7 +695,9 @@ mod tests {
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn test_transport_type_constants() {
// These assertions verify the constant definitions are correct
assert!(!TransportType::UDP.connection_oriented);
assert!(!TransportType::UDP.reliable);
assert!(TransportType::UDP.is_connectionless());