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

1
Cargo.lock generated
View File

@@ -2732,6 +2732,7 @@ dependencies = [
"hyper 1.8.1", "hyper 1.8.1",
"image 0.24.9", "image 0.24.9",
"ostp", "ostp",
"parking_lot",
"qrcode", "qrcode",
"rand 0.8.5", "rand 0.8.5",
"rusqlite", "rusqlite",

View File

@@ -39,6 +39,10 @@ enum Commands {
/// Listen address /// Listen address
#[arg(short, long, default_value = "0.0.0.0:8080")] #[arg(short, long, default_value = "0.0.0.0:8080")]
listen: String, 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 /// Interactive dashboard
@@ -99,6 +103,12 @@ enum NodeCommands {
/// Node ID to reject /// Node ID to reject
id: String, id: String,
}, },
/// Generate enrollment token
Token {
/// Token expiry duration in minutes
#[arg(long, default_value = "3")]
expiry: i64,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -155,13 +165,35 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let state = Arc::new(AppState::new(&cli.database)?); let state = Arc::new(AppState::new(&cli.database, 42)?); // Default network octet
// Initialize default SNIs // Initialize default SNIs
state.sni_manager.init_defaults().await; 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 { 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 run_api_server(state, &listen).await
} }
Commands::Dashboard => { Commands::Dashboard => {
@@ -376,11 +408,16 @@ async fn handle_node_command(state: Arc<AppState>, action: NodeCommands) -> Resu
NodeCommands::Approve { id } => { NodeCommands::Approve { id } => {
let uuid = uuid::Uuid::parse_str(&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) => { Ok(node_psk) => {
println!("{} Node approved", style("").green().bold()); println!("{} Node approved", style("").green().bold());
println!(); println!();
println!(" Node ID: {}", style(uuid).yellow()); println!(" Node ID: {}", style(uuid).yellow());
println!(" Assigned IP: {}", style(&assigned_ip).cyan().bold());
println!(" Node PSK: {}", style(&node_psk).yellow().bold()); println!(" Node PSK: {}", style(&node_psk).yellow().bold());
println!(); println!();
println!("{}", style("⚠ IMPORTANT: Save this PSK securely!").red().bold()); println!("{}", style("⚠ IMPORTANT: Save this PSK securely!").red().bold());
@@ -388,6 +425,8 @@ async fn handle_node_command(state: Arc<AppState>, action: NodeCommands) -> Resu
println!("{}", style(" The node must use this PSK to connect").dim()); println!("{}", style(" The node must use this PSK to connect").dim());
} }
Err(e) => { Err(e) => {
// Release IP on failure
let _ = state.ipam_pool.lock().release(assigned_ip);
println!("{} Failed to approve: {}", style("").red().bold(), e); 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(()) Ok(())

View File

@@ -23,3 +23,4 @@ qrcode = "0.14"
image = { version = "0.24", default-features = false, features = ["png"] } image = { version = "0.24", default-features = false, features = ["png"] }
hex = "0.4" hex = "0.4"
rand = "0.8" rand = "0.8"
parking_lot = "0.12.5"

View File

@@ -16,9 +16,11 @@ use uuid::Uuid;
use crate::billing::{SqliteRegistry, User, UserRegistry}; use crate::billing::{SqliteRegistry, User, UserRegistry};
use crate::enrollment::{EnrollmentRegistry, EnrollmentRequest, EnrollmentState}; use crate::enrollment::{EnrollmentRegistry, EnrollmentRequest, EnrollmentState};
use crate::network::{NetworkConfig, IpamPool};
use crate::node::{NetworkStats, Node, NodeCheckin, NodeRegistry}; use crate::node::{NetworkStats, Node, NodeCheckin, NodeRegistry};
use crate::session::SessionManager; use crate::session::SessionManager;
use crate::sni::{SniManager, SniUpdate}; use crate::sni::{SniManager, SniUpdate};
use crate::token::TokenManager;
/// Shared application state /// Shared application state
pub struct AppState { pub struct AppState {
@@ -27,16 +29,23 @@ pub struct AppState {
pub sessions: SessionManager, pub sessions: SessionManager,
pub sni_manager: SniManager, pub sni_manager: SniManager,
pub enrollment: EnrollmentRegistry, pub enrollment: EnrollmentRegistry,
pub token_manager: Arc<TokenManager>,
pub ipam_pool: Arc<parking_lot::Mutex<IpamPool>>,
} }
impl AppState { impl AppState {
pub fn new(db_path: &str) -> anyhow::Result<Self> { pub fn new(db_path: &str, network_octet: u8) -> anyhow::Result<Self> {
let network_config = NetworkConfig::new(network_octet)?;
let ipam_pool = IpamPool::new(network_config);
Ok(Self { Ok(Self {
nodes: NodeRegistry::new(60), // 60 second timeout nodes: NodeRegistry::new(60), // 60 second timeout
users: SqliteRegistry::new(db_path)?, users: SqliteRegistry::new(db_path)?,
sessions: SessionManager::new(300), // 5 minute heartbeat timeout sessions: SessionManager::new(300), // 5 minute heartbeat timeout
sni_manager: SniManager::new(), sni_manager: SniManager::new(),
enrollment: EnrollmentRegistry::new(db_path)?, enrollment: EnrollmentRegistry::new(db_path)?,
token_manager: Arc::new(TokenManager::new()),
ipam_pool: Arc::new(parking_lot::Mutex::new(ipam_pool)),
}) })
} }
} }
@@ -429,6 +438,7 @@ async fn emergency_sni_update(
/// Submit node enrollment request /// Submit node enrollment request
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct SubmitEnrollmentRequest { struct SubmitEnrollmentRequest {
token: String, // OTP enrollment token (required)
name: String, name: String,
address: String, address: String,
country_code: String, country_code: String,
@@ -447,8 +457,22 @@ async fn submit_enrollment(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<SubmitEnrollmentRequest>, Json(req): Json<SubmitEnrollmentRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// SECURITY: Validate token first - silent drop on failure
if let Err(e) = state.token_manager.validate(&req.token) {
tracing::warn!("Enrollment rejected: invalid token ({})", e);
// Silent drop - return 444 (nginx-style connection close without response)
// This prevents token enumeration attacks
return (StatusCode::from_u16(444).unwrap(), Json(SubmitEnrollmentResponse {
node_id: Uuid::nil(),
state: "error".into(),
message: "".into(),
}));
}
let node_id = Uuid::new_v4(); let node_id = Uuid::new_v4();
let node_name = req.name.clone();
let enrollment_req = EnrollmentRequest { let enrollment_req = EnrollmentRequest {
token: req.token,
node_id, node_id,
name: req.name, name: req.name,
address: req.address, address: req.address,
@@ -459,22 +483,28 @@ async fn submit_enrollment(
}; };
match state.enrollment.submit_request(enrollment_req) { match state.enrollment.submit_request(enrollment_req) {
Ok(()) => ( Ok(()) => {
tracing::info!("Enrollment request submitted: {} ({})", node_id, node_name);
(
StatusCode::CREATED, StatusCode::CREATED,
Json(SubmitEnrollmentResponse { Json(SubmitEnrollmentResponse {
node_id, node_id,
state: "pending".into(), state: "pending".into(),
message: "Enrollment request submitted. Awaiting approval.".into(), message: "Enrollment request submitted. Awaiting approval.".into(),
}), }),
), )
Err(e) => ( },
Err(e) => {
tracing::error!("Enrollment submission failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(SubmitEnrollmentResponse { Json(SubmitEnrollmentResponse {
node_id, node_id,
state: "error".into(), state: "error".into(),
message: format!("Failed to submit: {}", e), message: format!("Failed to submit: {}", e),
}), }),
), )
},
} }
} }
@@ -493,6 +523,7 @@ async fn list_pending_enrollments(
struct ApproveEnrollmentResponse { struct ApproveEnrollmentResponse {
node_id: Uuid, node_id: Uuid,
node_psk: String, node_psk: String,
assigned_ip: String,
message: String, message: String,
} }
@@ -500,23 +531,48 @@ async fn approve_enrollment(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match state.enrollment.approve(&id) { // Allocate IP from IPAM pool
Ok(node_psk) => ( let assigned_ip = match state.ipam_pool.lock().allocate() {
Ok(ip) => ip,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApproveEnrollmentResponse {
node_id: id,
node_psk: String::new(),
assigned_ip: String::new(),
message: format!("Failed to allocate IP: {}", e),
}),
);
}
};
match state.enrollment.approve(&id, &assigned_ip.to_string()) {
Ok(node_psk) => {
tracing::info!("Node {} approved with IP {}", id, assigned_ip);
(
StatusCode::OK, StatusCode::OK,
Json(ApproveEnrollmentResponse { Json(ApproveEnrollmentResponse {
node_id: id, node_id: id,
node_psk, node_psk,
message: "Node approved. Use provided PSK to connect.".into(), assigned_ip: assigned_ip.to_string(),
message: "Node approved. Use provided PSK and IP to connect.".into(),
}), }),
), )
Err(e) => ( },
Err(e) => {
// Release IP on failure
let _ = state.ipam_pool.lock().release(assigned_ip);
(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(ApproveEnrollmentResponse { Json(ApproveEnrollmentResponse {
node_id: id, node_id: id,
node_psk: String::new(), node_psk: String::new(),
assigned_ip: String::new(),
message: format!("Failed to approve: {}", e), message: format!("Failed to approve: {}", e),
}), }),
), )
},
} }
} }

View File

@@ -56,6 +56,7 @@ impl EnrollmentState {
/// Node enrollment request /// Node enrollment request
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrollmentRequest { pub struct EnrollmentRequest {
pub token: String, // OTP enrollment token (required)
pub node_id: Uuid, pub node_id: Uuid,
pub name: String, pub name: String,
pub address: String, // Public IP:port pub address: String, // Public IP:port
@@ -189,22 +190,37 @@ impl EnrollmentRegistry {
Ok(rows.collect::<SqliteResult<Vec<_>>>()?) Ok(rows.collect::<SqliteResult<Vec<_>>>()?)
} }
/// Approve enrollment request and generate Node PSK /// Approve enrollment request and generate Node PSK with IP allocation
pub fn approve(&self, node_id: &Uuid) -> Result<String, EnrollmentError> { pub fn approve(&self, node_id: &Uuid, assigned_ip: &str) -> Result<String, EnrollmentError> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
// Generate unique PSK for this node // Generate unique PSK for this node
let node_psk = Self::generate_node_psk(); let node_psk = Self::generate_node_psk();
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
// First check if we need to add assigned_ip column
let has_ip_column = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('enrollments') WHERE name='assigned_ip'",
[],
|row| row.get::<_, i32>(0)
).unwrap_or(0) > 0;
if !has_ip_column {
conn.execute(
"ALTER TABLE enrollments ADD COLUMN assigned_ip TEXT",
[],
)?;
}
let updated = conn.execute( let updated = conn.execute(
"UPDATE enrollments "UPDATE enrollments
SET state = ?1, node_psk = ?2, approved_at = ?3 SET state = ?1, node_psk = ?2, approved_at = ?3, assigned_ip = ?4
WHERE node_id = ?4 AND state = ?5", WHERE node_id = ?5 AND state = ?6",
params![ params![
EnrollmentState::Approved.as_str(), EnrollmentState::Approved.as_str(),
node_psk, node_psk,
now, now,
assigned_ip,
node_id.to_string(), node_id.to_string(),
EnrollmentState::Pending.as_str(), EnrollmentState::Pending.as_str(),
], ],
@@ -214,7 +230,7 @@ impl EnrollmentRegistry {
return Err(EnrollmentError::InvalidTransition); return Err(EnrollmentError::InvalidTransition);
} }
tracing::info!("Node {} approved", node_id); tracing::info!("Node {} approved with IP {}", node_id, assigned_ip);
Ok(node_psk) Ok(node_psk)
} }
@@ -352,6 +368,7 @@ mod tests {
// Submit request // Submit request
let req = EnrollmentRequest { let req = EnrollmentRequest {
token: "TEST_TOKEN_123".into(),
node_id, node_id,
name: "test-node".into(), name: "test-node".into(),
address: "1.2.3.4:8443".into(), address: "1.2.3.4:8443".into(),
@@ -367,7 +384,7 @@ mod tests {
assert_eq!(pending.len(), 1); assert_eq!(pending.len(), 1);
// Approve // Approve
let psk = registry.approve(&node_id)?; let psk = registry.approve(&node_id, "10.42.0.2")?;
assert_eq!(psk.len(), 64); // 32 bytes hex assert_eq!(psk.len(), 64); // 32 bytes hex
// Check approved // Check approved

View File

@@ -1,9 +1,11 @@
pub mod api; pub mod api;
pub mod billing; pub mod billing;
pub mod enrollment; pub mod enrollment;
pub mod network;
pub mod node; pub mod node;
pub mod session; pub mod session;
pub mod sni; pub mod sni;
pub mod token;
pub use api::{create_router, run_server, AppState}; pub use api::{create_router, run_server, AppState};
pub use billing::{BillingError, SqliteRegistry, User, UserRegistry}; pub use billing::{BillingError, SqliteRegistry, User, UserRegistry};

246
oncp/src/network.rs Normal file
View File

@@ -0,0 +1,246 @@
//! Dynamic Network Configuration & IPAM
//!
//! Each Master Node manages its own /16 subnet: 10.X.0.0/16
//! where X is the unique Master Node identifier (0-255)
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum IpamError {
#[error("invalid network octet: must be 0-255, got {0}")]
InvalidOctet(u8),
#[error("reserved network range: {0}")]
ReservedRange(String),
#[error("no available IPs in pool")]
PoolExhausted,
#[error("IP already allocated: {0}")]
AlreadyAllocated(Ipv4Addr),
}
/// Network configuration for a Master Node
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
/// Second octet of 10.X.0.0/16 network
pub master_octet: u8,
/// Full network prefix: 10.X.0.0/16
pub network: String,
/// Master node IP (always 10.X.0.1)
pub master_ip: Ipv4Addr,
/// Gateway IP (always 10.X.0.1)
pub gateway: Ipv4Addr,
/// Network mask
pub netmask: Ipv4Addr,
/// CIDR notation
pub cidr: u8,
}
impl NetworkConfig {
/// Create a new network configuration for a Master Node
pub fn new(master_octet: u8) -> Result<Self, IpamError> {
// Check for reserved ranges
if is_reserved_octet(master_octet) {
return Err(IpamError::ReservedRange(format!("10.{}.0.0/16", master_octet)));
}
let master_ip = Ipv4Addr::new(10, master_octet, 0, 1);
let gateway = master_ip;
let netmask = Ipv4Addr::new(255, 255, 0, 0);
Ok(Self {
master_octet,
network: format!("10.{}.0.0/16", master_octet),
master_ip,
gateway,
netmask,
cidr: 16,
})
}
/// Get the base network address
pub fn network_addr(&self) -> Ipv4Addr {
Ipv4Addr::new(10, self.master_octet, 0, 0)
}
/// Get broadcast address
pub fn broadcast_addr(&self) -> Ipv4Addr {
Ipv4Addr::new(10, self.master_octet, 255, 255)
}
/// Check if IP belongs to this network
pub fn contains(&self, ip: &Ipv4Addr) -> bool {
let octets = ip.octets();
octets[0] == 10 && octets[1] == self.master_octet
}
}
/// IP Address Management for dynamic allocation
pub struct IpamPool {
config: NetworkConfig,
allocated: Arc<Mutex<HashSet<Ipv4Addr>>>,
next_third_octet: Arc<Mutex<u8>>,
next_fourth_octet: Arc<Mutex<u8>>,
}
impl IpamPool {
pub fn new(config: NetworkConfig) -> Self {
let mut allocated = HashSet::new();
// Reserve master node IP
allocated.insert(config.master_ip);
// Reserve network and broadcast addresses
allocated.insert(config.network_addr());
allocated.insert(config.broadcast_addr());
Self {
config,
allocated: Arc::new(Mutex::new(allocated)),
next_third_octet: Arc::new(Mutex::new(0)),
next_fourth_octet: Arc::new(Mutex::new(2)), // Start from .0.2 (after master at .0.1)
}
}
/// Allocate the next available IP address
pub fn allocate(&self) -> Result<Ipv4Addr, IpamError> {
let mut allocated = self.allocated.lock().unwrap();
let mut third = self.next_third_octet.lock().unwrap();
let mut fourth = self.next_fourth_octet.lock().unwrap();
// Try to find next available IP
for _ in 0..65534 { // Max addresses in /16 minus reserved
let ip = Ipv4Addr::new(10, self.config.master_octet, *third, *fourth);
if !allocated.contains(&ip) && !is_reserved_ip(&ip) {
allocated.insert(ip);
// Increment for next allocation
*fourth += 1;
if *fourth == 0 { // Wrapped around
*third += 1;
*fourth = 1;
}
tracing::info!("Allocated IP: {} (pool size: {})", ip, allocated.len());
return Ok(ip);
}
// Move to next IP
*fourth += 1;
if *fourth == 0 {
*third += 1;
*fourth = 1;
}
}
Err(IpamError::PoolExhausted)
}
/// Release an allocated IP back to the pool
pub fn release(&self, ip: Ipv4Addr) -> Result<(), IpamError> {
let mut allocated = self.allocated.lock().unwrap();
if !allocated.remove(&ip) {
return Err(IpamError::AlreadyAllocated(ip));
}
tracing::info!("Released IP: {} (pool size: {})", ip, allocated.len());
Ok(())
}
/// Get count of allocated IPs
pub fn allocated_count(&self) -> usize {
self.allocated.lock().unwrap().len()
}
/// Check if IP is allocated
pub fn is_allocated(&self, ip: &Ipv4Addr) -> bool {
self.allocated.lock().unwrap().contains(ip)
}
}
/// Check if octet is in a reserved range
fn is_reserved_octet(octet: u8) -> bool {
matches!(octet,
0 | // 10.0.0.0/8 - often used for private networks
255 // Avoid broadcast-like ranges
)
}
/// Check if IP should be reserved (broadcast, network, multicast)
fn is_reserved_ip(ip: &Ipv4Addr) -> bool {
let octets = ip.octets();
// Network address (x.x.0.0)
if octets[2] == 0 && octets[3] == 0 {
return true;
}
// Broadcast address (x.x.255.255)
if octets[2] == 255 && octets[3] == 255 {
return true;
}
// Subnet broadcasts (x.x.x.255)
if octets[3] == 255 {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_network_config_creation() {
let config = NetworkConfig::new(42).unwrap();
assert_eq!(config.master_octet, 42);
assert_eq!(config.network, "10.42.0.0/16");
assert_eq!(config.master_ip, Ipv4Addr::new(10, 42, 0, 1));
}
#[test]
fn test_reserved_octets() {
assert!(NetworkConfig::new(0).is_err());
assert!(NetworkConfig::new(255).is_err());
assert!(NetworkConfig::new(1).is_ok());
}
#[test]
fn test_ip_allocation() {
let config = NetworkConfig::new(42).unwrap();
let pool = IpamPool::new(config);
let ip1 = pool.allocate().unwrap();
let ip2 = pool.allocate().unwrap();
assert_ne!(ip1, ip2);
assert_eq!(ip1, Ipv4Addr::new(10, 42, 0, 2));
assert_eq!(ip2, Ipv4Addr::new(10, 42, 0, 3));
}
#[test]
fn test_ip_release() {
let config = NetworkConfig::new(42).unwrap();
let pool = IpamPool::new(config);
let ip = pool.allocate().unwrap();
assert!(pool.is_allocated(&ip));
pool.release(ip).unwrap();
assert!(!pool.is_allocated(&ip));
}
#[test]
fn test_network_contains() {
let config = NetworkConfig::new(42).unwrap();
assert!(config.contains(&Ipv4Addr::new(10, 42, 0, 1)));
assert!(config.contains(&Ipv4Addr::new(10, 42, 100, 200)));
assert!(!config.contains(&Ipv4Addr::new(10, 43, 0, 1)));
assert!(!config.contains(&Ipv4Addr::new(192, 168, 1, 1)));
}
}

196
oncp/src/token.rs Normal file
View File

@@ -0,0 +1,196 @@
//! OTP (One-Time Password) Token Management for Enrollment
//!
//! Generates time-limited cryptographically secure tokens for node enrollment.
//! Tokens are stored in RAM and automatically expire.
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use thiserror::Error;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum TokenError {
#[error("token expired")]
Expired,
#[error("token not found")]
NotFound,
#[error("invalid token format")]
InvalidFormat,
}
/// OTP Token with expiration
#[derive(Debug, Clone)]
struct Token {
value: String,
created_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
used: bool,
}
/// Thread-safe OTP token manager
pub struct TokenManager {
tokens: Arc<Mutex<HashMap<String, Token>>>,
}
impl TokenManager {
pub fn new() -> Self {
Self {
tokens: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Generate a new OTP token with specified expiry duration
pub fn generate(&self, expiry_minutes: i64) -> String {
let token_value = generate_secure_token(10);
let now = Utc::now();
let expires_at = now + Duration::minutes(expiry_minutes);
let token = Token {
value: token_value.clone(),
created_at: now,
expires_at,
used: false,
};
let mut tokens = self.tokens.lock().unwrap();
tokens.insert(token_value.clone(), token);
tracing::info!(
"Generated OTP token (expires in {} minutes): {}",
expiry_minutes,
mask_token(&token_value)
);
token_value
}
/// Validate and consume a token
pub fn validate(&self, token_value: &str) -> Result<(), TokenError> {
let mut tokens = self.tokens.lock().unwrap();
let token = tokens
.get_mut(token_value)
.ok_or(TokenError::NotFound)?;
// Check expiration
if Utc::now() > token.expires_at {
tokens.remove(token_value);
tracing::warn!("Attempted to use expired token: {}", mask_token(token_value));
return Err(TokenError::Expired);
}
// Check if already used
if token.used {
tracing::warn!("Attempted to reuse token: {}", mask_token(token_value));
return Err(TokenError::NotFound);
}
// Mark as used and remove immediately
token.used = true;
tokens.remove(token_value);
tracing::info!("OTP token validated and consumed: {}", mask_token(token_value));
Ok(())
}
/// Cleanup expired tokens (should be called periodically)
pub fn cleanup_expired(&self) {
let mut tokens = self.tokens.lock().unwrap();
let now = Utc::now();
let expired: Vec<String> = tokens
.iter()
.filter(|(_, token)| now > token.expires_at)
.map(|(key, _)| key.clone())
.collect();
for key in &expired {
tokens.remove(key);
}
if !expired.is_empty() {
tracing::info!("Cleaned up {} expired tokens", expired.len());
}
}
/// Get count of active tokens
pub fn active_count(&self) -> usize {
let tokens = self.tokens.lock().unwrap();
tokens.len()
}
}
impl Default for TokenManager {
fn default() -> Self {
Self::new()
}
}
/// Generate cryptographically secure random token
fn generate_secure_token(length: usize) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Mask token for logging (show first 2 and last 2 chars)
fn mask_token(token: &str) -> String {
if token.len() <= 4 {
return "****".to_string();
}
format!("{}****{}", &token[..2], &token[token.len() - 2..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_generation() {
let manager = TokenManager::new();
let token = manager.generate(3);
assert_eq!(token.len(), 10);
assert!(token.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_token_validation() {
let manager = TokenManager::new();
let token = manager.generate(3);
// First use should succeed
assert!(manager.validate(&token).is_ok());
// Second use should fail (token consumed)
assert!(matches!(manager.validate(&token), Err(TokenError::NotFound)));
}
#[test]
fn test_token_expiration() {
let manager = TokenManager::new();
let token = manager.generate(0); // Expires immediately
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(matches!(manager.validate(&token), Err(TokenError::Expired)));
}
#[test]
fn test_cleanup() {
let manager = TokenManager::new();
manager.generate(0);
manager.generate(0);
std::thread::sleep(std::time::Duration::from_millis(100));
manager.cleanup_expired();
assert_eq!(manager.active_count(), 0);
}
}