feat(oncp): OTP enrollment tokens and dynamic IPAM

- Add OTP token management (oncp/src/token.rs)
  * Time-limited tokens with expiry (default 3 minutes)
  * One-time use validation (token deleted after use)
  * Automatic cleanup of expired tokens
  * Cryptographically secure random generation (10 chars)
  * Token masking in logs (XX****XX format)

- Add dynamic IPAM (oncp/src/network.rs)
  * NetworkConfig for 10.X.0.0/16 subnet management
  * IpamPool with sequential IP allocation
  * Master node octet validation (0-255, excluding reserved)
  * IP release mechanism for rollback scenarios

- Update enrollment flow
  * EnrollmentRequest requires OTP token field
  * Silent drop (HTTP 444) for invalid tokens
  * IP allocation during node approval
  * CLI command: 'node token --expiry 3'
  * Master CLI --network-octet parameter

- Security enhancements
  * Two-factor enrollment: token + admin approval
  * Token enumeration prevention (no error responses)
  * Automatic token cleanup every 60 seconds
  * PSK + assigned IP returned on approval

Tests: All 16 tests passing (4 token, 5 network, 7 existing)
This commit is contained in:
2026-01-02 02:43:27 +03:00
parent 85a2b01074
commit 91ab02dc8e
8 changed files with 616 additions and 45 deletions

View File

@@ -39,6 +39,10 @@ enum Commands {
/// Listen address
#[arg(short, long, default_value = "0.0.0.0:8080")]
listen: String,
/// Network octet for 10.X.0.0/16 subnet (0-255)
#[arg(short = 'n', long, default_value = "42")]
network_octet: u8,
},
/// Interactive dashboard
@@ -99,6 +103,12 @@ enum NodeCommands {
/// Node ID to reject
id: String,
},
/// Generate enrollment token
Token {
/// Token expiry duration in minutes
#[arg(long, default_value = "3")]
expiry: i64,
},
}
#[derive(Subcommand)]
@@ -155,13 +165,35 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let state = Arc::new(AppState::new(&cli.database)?);
let state = Arc::new(AppState::new(&cli.database, 42)?); // Default network octet
// Initialize default SNIs
state.sni_manager.init_defaults().await;
// Start background token cleanup
let cleanup_state = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
cleanup_state.token_manager.cleanup_expired();
}
});
match cli.command {
Commands::Serve { listen } => {
Commands::Serve { listen, network_octet } => {
// Update state with correct network octet
let state = Arc::new(AppState::new(&cli.database, network_octet)?);
state.sni_manager.init_defaults().await;
// Start token cleanup for this state too
let cleanup_state = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
cleanup_state.token_manager.cleanup_expired();
}
});
run_api_server(state, &listen).await
}
Commands::Dashboard => {
@@ -376,18 +408,25 @@ async fn handle_node_command(state: Arc<AppState>, action: NodeCommands) -> Resu
NodeCommands::Approve { id } => {
let uuid = uuid::Uuid::parse_str(&id)?;
match state.enrollment.approve(&uuid) {
// Allocate IP from IPAM pool
let assigned_ip = state.ipam_pool.lock().allocate()
.map_err(|e| anyhow::anyhow!("Failed to allocate IP: {}", e))?;
match state.enrollment.approve(&uuid, &assigned_ip.to_string()) {
Ok(node_psk) => {
println!("{} Node approved", style("").green().bold());
println!();
println!(" Node ID: {}", style(uuid).yellow());
println!(" Node PSK: {}", style(&node_psk).yellow().bold());
println!(" Node ID: {}", style(uuid).yellow());
println!(" Assigned IP: {}", style(&assigned_ip).cyan().bold());
println!(" Node PSK: {}", style(&node_psk).yellow().bold());
println!();
println!("{}", style("⚠ IMPORTANT: Save this PSK securely!").red().bold());
println!("{}", style(" Send it to the node operator via secure channel").dim());
println!("{}", style(" The node must use this PSK to connect").dim());
}
Err(e) => {
// Release IP on failure
let _ = state.ipam_pool.lock().release(assigned_ip);
println!("{} Failed to approve: {}", style("").red().bold(), e);
}
}
@@ -405,6 +444,19 @@ async fn handle_node_command(state: Arc<AppState>, action: NodeCommands) -> Resu
}
}
}
NodeCommands::Token { expiry } => {
let token = state.token_manager.generate(expiry);
println!("{} Enrollment token generated", style("").green().bold());
println!();
println!(" Token: {}", style(&token).yellow().bold());
println!(" Expires: {} minutes", style(expiry).cyan());
println!();
println!("{}", style("⚠ IMPORTANT: This token can only be used once!").red().bold());
println!("{}", style(" Send it to the node operator via secure channel").dim());
println!("{}", style(" Token will expire and be deleted after use").dim());
}
}
Ok(())