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:
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user