diff --git a/Cargo.lock b/Cargo.lock index df352b5..a428174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,10 +200,10 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -216,7 +216,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower 0.5.2", "tower-layer", @@ -233,13 +233,13 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -594,6 +594,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -617,9 +627,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -630,7 +640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1066,6 +1076,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1073,7 +1092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1087,6 +1106,12 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1494,6 +1519,25 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.12" @@ -1505,7 +1549,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.12.1", "slab", "tokio", @@ -1582,6 +1626,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1592,6 +1647,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1599,7 +1665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1610,8 +1676,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1627,6 +1693,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1637,9 +1727,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1650,6 +1740,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1661,14 +1764,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -2261,6 +2364,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2587,10 +2707,12 @@ dependencies = [ "axum", "base64 0.21.7", "chrono", - "hyper", + "hex", + "hyper 1.8.1", "image 0.24.9", "ostp", "qrcode", + "rand 0.8.5", "rusqlite", "serde", "serde_json", @@ -2641,6 +2763,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2740,6 +2906,7 @@ dependencies = [ "ostp", "ostp-guard", "rand 0.8.5", + "reqwest 0.11.27", "serde", "serde_json", "tokio", @@ -3330,6 +3497,46 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3340,10 +3547,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "js-sys", "log", @@ -3352,7 +3559,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-util", "tower 0.5.2", @@ -3401,6 +3608,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3422,6 +3638,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3479,6 +3704,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3792,6 +4040,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -3926,6 +4184,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3946,6 +4210,27 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3967,7 +4252,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -4032,7 +4317,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.4.0", "jni", "libc", "log", @@ -4046,7 +4331,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_repr", @@ -4177,7 +4462,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.4.0", "jni", "objc2", "objc2-ui-kit", @@ -4200,7 +4485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" dependencies = [ "gtk", - "http", + "http 1.4.0", "jni", "log", "objc2", @@ -4233,7 +4518,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http", + "http 1.4.0", "infer", "json-patch", "kuchikiki", @@ -4395,7 +4680,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -4411,6 +4696,16 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -4540,7 +4835,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4555,8 +4850,8 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.10.0", "bytes", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "pin-project-lite", "tower-layer", @@ -4573,8 +4868,8 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower 0.5.2", @@ -5277,6 +5572,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5583,6 +5887,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.52.0" @@ -5631,7 +5945,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.4.0", "javascriptcore-rs", "jni", "kuchikiki", diff --git a/oncp-master/src/main.rs b/oncp-master/src/main.rs index ae31921..a9d3858 100644 --- a/oncp-master/src/main.rs +++ b/oncp-master/src/main.rs @@ -87,6 +87,18 @@ enum NodeCommands { /// Node ID id: String, }, + /// List pending enrollment requests + Pending, + /// Approve enrollment request + Approve { + /// Node ID to approve + id: String, + }, + /// Reject enrollment request + Reject { + /// Node ID to reject + id: String, + }, } #[derive(Subcommand)] @@ -328,6 +340,71 @@ async fn handle_node_command(state: Arc, action: NodeCommands) -> Resu } } } + + NodeCommands::Pending => { + use oncp::EnrollmentState; + + let pending = state.enrollment.list_by_state(EnrollmentState::Pending)?; + + println!("{}", style("Pending Enrollment Requests").green().bold()); + println!("{}", style("─").dim().to_string().repeat(80)); + + if pending.is_empty() { + println!("{}", style("No pending requests").dim()); + } else { + println!("{:<36} {:<15} {:<20} {:<8}", + style("Node ID").bold(), + style("Name").bold(), + style("Address").bold(), + style("Country").bold() + ); + + for node in pending { + println!("{:<36} {:<15} {:<20} {:<8}", + node.node_id, + node.name, + node.address, + node.country_code + ); + } + + println!(); + println!("{}", style("Use 'node approve ' to approve").dim()); + } + } + + NodeCommands::Approve { id } => { + let uuid = uuid::Uuid::parse_str(&id)?; + + match state.enrollment.approve(&uuid) { + 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!(); + 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) => { + println!("{} Failed to approve: {}", style("✗").red().bold(), e); + } + } + } + + NodeCommands::Reject { id } => { + let uuid = uuid::Uuid::parse_str(&id)?; + + match state.enrollment.reject(&uuid) { + Ok(()) => { + println!("{} Node enrollment rejected", style("✓").green().bold()); + } + Err(e) => { + println!("{} Failed to reject: {}", style("✗").red().bold(), e); + } + } + } } Ok(()) diff --git a/oncp/Cargo.toml b/oncp/Cargo.toml index 7a33fad..16a0e16 100644 --- a/oncp/Cargo.toml +++ b/oncp/Cargo.toml @@ -21,3 +21,5 @@ hyper = { version = "1.0", features = ["full"] } base64 = "0.21" qrcode = "0.14" image = { version = "0.24", default-features = false, features = ["png"] } +hex = "0.4" +rand = "0.8" diff --git a/oncp/src/api.rs b/oncp/src/api.rs index 7675138..4106802 100644 --- a/oncp/src/api.rs +++ b/oncp/src/api.rs @@ -15,6 +15,7 @@ use tower_http::trace::TraceLayer; use uuid::Uuid; use crate::billing::{SqliteRegistry, User, UserRegistry}; +use crate::enrollment::{EnrollmentRegistry, EnrollmentRequest, EnrollmentState}; use crate::node::{NetworkStats, Node, NodeCheckin, NodeRegistry}; use crate::session::SessionManager; use crate::sni::{SniManager, SniUpdate}; @@ -25,6 +26,7 @@ pub struct AppState { pub users: SqliteRegistry, pub sessions: SessionManager, pub sni_manager: SniManager, + pub enrollment: EnrollmentRegistry, } impl AppState { @@ -34,6 +36,7 @@ impl AppState { users: SqliteRegistry::new(db_path)?, sessions: SessionManager::new(300), // 5 minute heartbeat timeout sni_manager: SniManager::new(), + enrollment: EnrollmentRegistry::new(db_path)?, }) } } @@ -47,6 +50,12 @@ pub fn create_router(state: Arc) -> Router { .route("/api/v1/nodes/:id/checkin", post(node_checkin)) .route("/api/v1/nodes/best", get(best_nodes)) + // Node enrollment + .route("/api/v1/enrollment/request", post(submit_enrollment)) + .route("/api/v1/enrollment/pending", get(list_pending_enrollments)) + .route("/api/v1/enrollment/:id/approve", post(approve_enrollment)) + .route("/api/v1/enrollment/:id/reject", post(reject_enrollment)) + // User management .route("/api/v1/users", get(list_users).post(create_user)) .route("/api/v1/users/:id", get(get_user).delete(delete_user)) @@ -147,25 +156,62 @@ async fn node_checkin( } } -/// Get best nodes for client connection +/// Get best nodes for client connection (CDN Steering) #[derive(Debug, Deserialize)] struct BestNodesQuery { country: Option, limit: Option, } +#[derive(Debug, Serialize)] +struct BestNodesResponse { + nodes: Vec, + steering_info: SteeringInfo, +} + +#[derive(Debug, Serialize)] +struct SteeringInfo { + requested_country: Option, + matched_country: bool, + fallback_used: bool, + total_available: usize, +} + async fn best_nodes( State(state): State>, Query(query): Query, -) -> Json> { +) -> Json { let limit = query.limit.unwrap_or(3); - let nodes = match &query.country { - Some(country) => state.nodes.best_for_country(country, limit).await, - None => state.nodes.best_global(limit).await, + let (nodes, matched, fallback) = match &query.country { + Some(country) => { + let country_nodes = state.nodes.best_for_country(country, limit).await; + + if country_nodes.is_empty() { + // No nodes in requested country - fallback to global + let global_nodes = state.nodes.best_global(limit).await; + (global_nodes, false, true) + } else { + (country_nodes, true, false) + } + } + None => { + let global = state.nodes.best_global(limit).await; + (global, false, false) + } }; - Json(nodes) + let total = state.nodes.list_online().await.len(); + + Json(BestNodesResponse { + nodes: nodes.clone(), + steering_info: SteeringInfo { + requested_country: query.country.clone(), + matched_country: matched, + fallback_used: fallback, + total_available: total, + }, + }) } // ============================================================================ @@ -376,6 +422,115 @@ async fn emergency_sni_update( StatusCode::OK } +// ============================================================================ +// Enrollment Endpoints +// ============================================================================ + +/// Submit node enrollment request +#[derive(Debug, Deserialize)] +struct SubmitEnrollmentRequest { + name: String, + address: String, + country_code: String, + hardware_id: String, + region: String, +} + +#[derive(Debug, Serialize)] +struct SubmitEnrollmentResponse { + node_id: Uuid, + state: String, + message: String, +} + +async fn submit_enrollment( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let node_id = Uuid::new_v4(); + let enrollment_req = EnrollmentRequest { + node_id, + name: req.name, + address: req.address, + country_code: req.country_code, + hardware_id: req.hardware_id, + region: req.region, + requested_at: chrono::Utc::now(), + }; + + match state.enrollment.submit_request(enrollment_req) { + Ok(()) => ( + StatusCode::CREATED, + Json(SubmitEnrollmentResponse { + node_id, + state: "pending".into(), + message: "Enrollment request submitted. Awaiting approval.".into(), + }), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SubmitEnrollmentResponse { + node_id, + state: "error".into(), + message: format!("Failed to submit: {}", e), + }), + ), + } +} + +/// List pending enrollments +async fn list_pending_enrollments( + State(state): State>, +) -> impl IntoResponse { + match state.enrollment.list_by_state(EnrollmentState::Pending) { + Ok(nodes) => (StatusCode::OK, Json(nodes)), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(vec![])), + } +} + +/// Approve enrollment +#[derive(Debug, Serialize)] +struct ApproveEnrollmentResponse { + node_id: Uuid, + node_psk: String, + message: String, +} + +async fn approve_enrollment( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match state.enrollment.approve(&id) { + Ok(node_psk) => ( + StatusCode::OK, + Json(ApproveEnrollmentResponse { + node_id: id, + node_psk, + message: "Node approved. Use provided PSK to connect.".into(), + }), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(ApproveEnrollmentResponse { + node_id: id, + node_psk: String::new(), + message: format!("Failed to approve: {}", e), + }), + ), + } +} + +/// Reject enrollment +async fn reject_enrollment( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + match state.enrollment.reject(&id) { + Ok(()) => StatusCode::NO_CONTENT, + Err(_) => StatusCode::BAD_REQUEST, + } +} + // ============================================================================ // Statistics // ============================================================================ diff --git a/oncp/src/enrollment.rs b/oncp/src/enrollment.rs new file mode 100644 index 0000000..fded652 --- /dev/null +++ b/oncp/src/enrollment.rs @@ -0,0 +1,386 @@ +//! Node enrollment and dynamic registration system + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Result as SqliteResult}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum EnrollmentError { + #[error("database error: {0}")] + Database(#[from] rusqlite::Error), + #[error("node not found")] + NotFound, + #[error("invalid state transition")] + InvalidTransition, + #[error("crypto error: {0}")] + Crypto(String), +} + +/// Node enrollment state machine +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EnrollmentState { + Pending, + Approved, + Active, + Rejected, + Suspended, +} + +impl EnrollmentState { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Active => "active", + Self::Rejected => "rejected", + Self::Suspended => "suspended", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "pending" => Some(Self::Pending), + "approved" => Some(Self::Approved), + "active" => Some(Self::Active), + "rejected" => Some(Self::Rejected), + "suspended" => Some(Self::Suspended), + _ => None, + } + } +} + +/// Node enrollment request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnrollmentRequest { + pub node_id: Uuid, + pub name: String, + pub address: String, // Public IP:port + pub country_code: String, + pub hardware_id: String, // Unique hardware fingerprint + pub region: String, // Geographic region + pub requested_at: DateTime, +} + +/// Enrolled node record +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnrolledNode { + pub node_id: Uuid, + pub name: String, + pub address: String, + pub country_code: String, + pub hardware_id: String, + pub region: String, + pub state: EnrollmentState, + pub node_psk: Option, // Generated PSK for approved nodes + pub requested_at: DateTime, + pub approved_at: Option>, + pub activated_at: Option>, +} + +impl EnrolledNode { + /// Check if node can transition to new state + pub fn can_transition_to(&self, new_state: EnrollmentState) -> bool { + match (self.state, new_state) { + (EnrollmentState::Pending, EnrollmentState::Approved) => true, + (EnrollmentState::Pending, EnrollmentState::Rejected) => true, + (EnrollmentState::Approved, EnrollmentState::Active) => true, + (EnrollmentState::Approved, EnrollmentState::Rejected) => true, + (EnrollmentState::Active, EnrollmentState::Suspended) => true, + (EnrollmentState::Suspended, EnrollmentState::Active) => true, + _ => false, + } + } +} + +/// Node enrollment registry with SQLite backend +pub struct EnrollmentRegistry { + conn: Arc>, +} + +impl EnrollmentRegistry { + /// Create new enrollment registry with SQLite database + pub fn new(db_path: impl AsRef) -> Result { + let conn = Connection::open(db_path)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS enrollments ( + node_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + address TEXT NOT NULL, + country_code TEXT NOT NULL, + hardware_id TEXT NOT NULL, + region TEXT NOT NULL, + state TEXT NOT NULL, + node_psk TEXT, + requested_at TEXT NOT NULL, + approved_at TEXT, + activated_at TEXT + )", + [], + )?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Submit enrollment request (server wants to join network) + pub fn submit_request(&self, req: EnrollmentRequest) -> Result<(), EnrollmentError> { + let conn = self.conn.lock().unwrap(); + + conn.execute( + "INSERT INTO enrollments ( + node_id, name, address, country_code, hardware_id, region, + state, node_psk, requested_at, approved_at, activated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, NULL, NULL)", + params![ + req.node_id.to_string(), + req.name, + req.address, + req.country_code, + req.hardware_id, + req.region, + EnrollmentState::Pending.as_str(), + req.requested_at.to_rfc3339(), + ], + )?; + + tracing::info!("Enrollment request submitted for node {}", req.node_id); + Ok(()) + } + + /// Get enrollment by node ID + pub fn get(&self, node_id: &Uuid) -> Result, EnrollmentError> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT node_id, name, address, country_code, hardware_id, region, + state, node_psk, requested_at, approved_at, activated_at + FROM enrollments WHERE node_id = ?1" + )?; + + let mut rows = stmt.query(params![node_id.to_string()])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_node(row)?)) + } else { + Ok(None) + } + } + + /// List all enrollments by state + pub fn list_by_state(&self, state: EnrollmentState) -> Result, EnrollmentError> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT node_id, name, address, country_code, hardware_id, region, + state, node_psk, requested_at, approved_at, activated_at + FROM enrollments WHERE state = ?1" + )?; + + let rows = stmt.query_map(params![state.as_str()], |row| { + Ok(Self::row_to_node(row).unwrap()) + })?; + + Ok(rows.collect::>>()?) + } + + /// Approve enrollment request and generate Node PSK + pub fn approve(&self, node_id: &Uuid) -> Result { + let conn = self.conn.lock().unwrap(); + + // Generate unique PSK for this node + let node_psk = Self::generate_node_psk(); + let now = Utc::now().to_rfc3339(); + + let updated = conn.execute( + "UPDATE enrollments + SET state = ?1, node_psk = ?2, approved_at = ?3 + WHERE node_id = ?4 AND state = ?5", + params![ + EnrollmentState::Approved.as_str(), + node_psk, + now, + node_id.to_string(), + EnrollmentState::Pending.as_str(), + ], + )?; + + if updated == 0 { + return Err(EnrollmentError::InvalidTransition); + } + + tracing::info!("Node {} approved", node_id); + Ok(node_psk) + } + + /// Reject enrollment request + pub fn reject(&self, node_id: &Uuid) -> Result<(), EnrollmentError> { + let conn = self.conn.lock().unwrap(); + + let updated = conn.execute( + "UPDATE enrollments SET state = ?1 WHERE node_id = ?2 AND state = ?3", + params![ + EnrollmentState::Rejected.as_str(), + node_id.to_string(), + EnrollmentState::Pending.as_str(), + ], + )?; + + if updated == 0 { + return Err(EnrollmentError::InvalidTransition); + } + + tracing::info!("Node {} rejected", node_id); + Ok(()) + } + + /// Activate approved node (node has successfully connected) + pub fn activate(&self, node_id: &Uuid) -> Result<(), EnrollmentError> { + let conn = self.conn.lock().unwrap(); + + let now = Utc::now().to_rfc3339(); + + let updated = conn.execute( + "UPDATE enrollments + SET state = ?1, activated_at = ?2 + WHERE node_id = ?3 AND state = ?4", + params![ + EnrollmentState::Active.as_str(), + now, + node_id.to_string(), + EnrollmentState::Approved.as_str(), + ], + )?; + + if updated == 0 { + return Err(EnrollmentError::InvalidTransition); + } + + tracing::info!("Node {} activated", node_id); + Ok(()) + } + + /// Suspend active node + pub fn suspend(&self, node_id: &Uuid) -> Result<(), EnrollmentError> { + let conn = self.conn.lock().unwrap(); + + let updated = conn.execute( + "UPDATE enrollments SET state = ?1 WHERE node_id = ?2 AND state = ?3", + params![ + EnrollmentState::Suspended.as_str(), + node_id.to_string(), + EnrollmentState::Active.as_str(), + ], + )?; + + if updated == 0 { + return Err(EnrollmentError::InvalidTransition); + } + + tracing::info!("Node {} suspended", node_id); + Ok(()) + } + + /// Generate secure Node PSK (32 bytes hex) + fn generate_node_psk() -> String { + use rand::RngCore; + let mut psk = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut psk); + hex::encode(psk) + } + + /// Convert SQLite row to EnrolledNode + fn row_to_node(row: &rusqlite::Row) -> SqliteResult { + Ok(EnrolledNode { + node_id: Uuid::parse_str(&row.get::<_, String>(0)?).unwrap(), + name: row.get(1)?, + address: row.get(2)?, + country_code: row.get(3)?, + hardware_id: row.get(4)?, + region: row.get(5)?, + state: EnrollmentState::from_str(&row.get::<_, String>(6)?).unwrap(), + node_psk: row.get(7)?, + requested_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?) + .unwrap() + .into(), + approved_at: row + .get::<_, Option>(9)? + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.into()), + activated_at: row + .get::<_, Option>(10)? + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.into()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_state_transitions() { + let node = EnrolledNode { + node_id: Uuid::new_v4(), + name: "test".into(), + address: "1.2.3.4:8443".into(), + country_code: "US".into(), + hardware_id: "hwid".into(), + region: "us-west".into(), + state: EnrollmentState::Pending, + node_psk: None, + requested_at: Utc::now(), + approved_at: None, + activated_at: None, + }; + + assert!(node.can_transition_to(EnrollmentState::Approved)); + assert!(node.can_transition_to(EnrollmentState::Rejected)); + assert!(!node.can_transition_to(EnrollmentState::Active)); + } + + #[test] + fn test_enrollment_workflow() -> Result<(), EnrollmentError> { + let registry = EnrollmentRegistry::new(":memory:")?; + let node_id = Uuid::new_v4(); + + // Submit request + let req = EnrollmentRequest { + node_id, + name: "test-node".into(), + address: "1.2.3.4:8443".into(), + country_code: "US".into(), + hardware_id: "test-hw".into(), + region: "us-west".into(), + requested_at: Utc::now(), + }; + registry.submit_request(req)?; + + // Check pending + let pending = registry.list_by_state(EnrollmentState::Pending)?; + assert_eq!(pending.len(), 1); + + // Approve + let psk = registry.approve(&node_id)?; + assert_eq!(psk.len(), 64); // 32 bytes hex + + // Check approved + let approved = registry.list_by_state(EnrollmentState::Approved)?; + assert_eq!(approved.len(), 1); + + // Activate + registry.activate(&node_id)?; + + // Check active + let active = registry.list_by_state(EnrollmentState::Active)?; + assert_eq!(active.len(), 1); + + Ok(()) + } +} diff --git a/oncp/src/lib.rs b/oncp/src/lib.rs index 75df91e..305cc20 100644 --- a/oncp/src/lib.rs +++ b/oncp/src/lib.rs @@ -1,11 +1,15 @@ pub mod api; pub mod billing; +pub mod enrollment; pub mod node; pub mod session; pub mod sni; pub use api::{create_router, run_server, AppState}; pub use billing::{BillingError, SqliteRegistry, User, UserRegistry}; +pub use enrollment::{ + EnrolledNode, EnrollmentError, EnrollmentRegistry, EnrollmentRequest, EnrollmentState, +}; pub use node::{NetworkStats, Node, NodeCheckin, NodeRegistry, NodeStatus}; pub use session::{Session, SessionManager}; pub use sni::{SniManager, SniUpdate}; diff --git a/osds/src/dns.rs b/osds/src/dns.rs index 0921ba4..c6a0bcc 100644 --- a/osds/src/dns.rs +++ b/osds/src/dns.rs @@ -62,6 +62,8 @@ pub struct StealthDnsForwarder { listen_addr: SocketAddr, /// Upstream resolver (will be tunneled through OSTP) upstream: SocketAddr, + /// Master node internal IP for *.ospab.internal queries + master_node_ip: std::net::IpAddr, } impl StealthDnsForwarder { @@ -69,9 +71,15 @@ impl StealthDnsForwarder { Self { listen_addr: listen, upstream, + master_node_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 8, 0, 1)), } } + pub fn with_master_ip(mut self, master_ip: std::net::IpAddr) -> Self { + self.master_node_ip = master_ip; + self + } + /// Start DNS listener (intercepts local queries) pub async fn run(&self) -> Result<(), DnsError> { let socket = UdpSocket::bind(self.listen_addr).await?; @@ -82,6 +90,12 @@ impl StealthDnsForwarder { let (len, src) = socket.recv_from(&mut buf).await?; let query = &buf[..len]; + // Check if query is for *.ospab.internal + if let Some(response) = self.handle_internal_query(query) { + let _ = socket.send_to(&response, src).await; + continue; + } + // TODO: Encrypt and forward through OSTP tunnel instead of direct // For now, direct forward (to be replaced with tunnel) match self.forward_query(query).await { @@ -95,6 +109,87 @@ impl StealthDnsForwarder { } } + /// Handle *.ospab.internal queries - resolve to master node IP + fn handle_internal_query(&self, query: &[u8]) -> Option> { + if query.len() < 12 { + return None; + } + + // Parse domain from query (simplified) + let qname_start = 12; + let mut pos = qname_start; + let mut domain = String::new(); + + while pos < query.len() && query[pos] != 0 { + let len = query[pos] as usize; + if len == 0 { + break; + } + if !domain.is_empty() { + domain.push('.'); + } + pos += 1; + if pos + len > query.len() { + return None; + } + domain.push_str(&String::from_utf8_lossy(&query[pos..pos + len])); + pos += len; + } + + // Check if domain ends with .ospab.internal + if !domain.ends_with(".ospab.internal") { + return None; + } + + tracing::debug!("Resolving internal domain: {}", domain); + + // Build DNS response + let mut response = Vec::from(query); + + // Set response flags (QR=1, AA=1, RCODE=0) + response[2] = 0x84; // QR + AA + response[3] = 0x00; // No error + + // Set answer count to 1 + response[6] = 0x00; + response[7] = 0x01; + + // Add answer section (pointer to question + A record) + response.push(0xC0); // Pointer to question name + response.push(0x0C); // Offset 12 + + // Type A + response.push(0x00); + response.push(0x01); + + // Class IN + response.push(0x00); + response.push(0x01); + + // TTL (300 seconds) + response.push(0x00); + response.push(0x00); + response.push(0x01); + response.push(0x2C); + + // Data length (4 bytes for IPv4) + response.push(0x00); + response.push(0x04); + + // IP address + match self.master_node_ip { + std::net::IpAddr::V4(ip) => { + response.extend_from_slice(&ip.octets()); + } + std::net::IpAddr::V6(_) => { + // For IPv6, would need AAAA record - fallback to IPv4 loopback + response.extend_from_slice(&[127, 0, 0, 1]); + } + } + + Some(response) + } + async fn forward_query(&self, query: &[u8]) -> Result, DnsError> { let socket = UdpSocket::bind("0.0.0.0:0").await?; socket.send_to(query, self.upstream).await?; diff --git a/osds/src/lib.rs b/osds/src/lib.rs index 03f2e0e..d36bcf7 100644 --- a/osds/src/lib.rs +++ b/osds/src/lib.rs @@ -1,3 +1,5 @@ pub mod dns; +pub mod system; pub use dns::{build_dns_query, detect_hijack, DnsError, QueryType, StealthDnsForwarder}; +pub use system::{DnsBackup, SystemDnsError, SystemDnsManager}; diff --git a/osds/src/system.rs b/osds/src/system.rs new file mode 100644 index 0000000..3f5f4c8 --- /dev/null +++ b/osds/src/system.rs @@ -0,0 +1,260 @@ +//! System DNS configuration management for Windows/Linux + +use std::process::Command; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SystemDnsError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("command failed: {0}")] + CommandFailed(String), + #[error("unsupported platform")] + UnsupportedPlatform, + #[error("permission denied")] + PermissionDenied, +} + +/// Stores original DNS settings for restoration +#[derive(Debug, Clone)] +pub struct DnsBackup { + #[cfg(target_os = "windows")] + pub interface: String, + #[cfg(target_os = "windows")] + pub original_dns: Vec, + #[cfg(target_os = "linux")] + pub resolv_conf_backup: String, +} + +/// System DNS manager - sets DNS to 127.0.0.1 and restores on drop +pub struct SystemDnsManager { + backup: Option, +} + +impl SystemDnsManager { + pub fn new() -> Self { + Self { backup: None } + } + + /// Set system DNS to 127.0.0.1 (requires admin/root) + pub async fn set_local_dns(&mut self) -> Result<(), SystemDnsError> { + #[cfg(target_os = "windows")] + { + self.set_windows_dns().await + } + + #[cfg(target_os = "linux")] + { + self.set_linux_dns().await + } + + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + Err(SystemDnsError::UnsupportedPlatform) + } + } + + /// Restore original DNS settings + pub async fn restore(&self) -> Result<(), SystemDnsError> { + if self.backup.is_none() { + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + self.restore_windows_dns().await + } + + #[cfg(target_os = "linux")] + { + self.restore_linux_dns().await + } + + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + Err(SystemDnsError::UnsupportedPlatform) + } + } + + #[cfg(target_os = "windows")] + async fn set_windows_dns(&mut self) -> Result<(), SystemDnsError> { + // Get active network interface + let output = Command::new("netsh") + .args(&["interface", "show", "interface"]) + .output()?; + + let interfaces = String::from_utf8_lossy(&output.stdout); + let active_interface = interfaces + .lines() + .find(|line| line.contains("Connected") && !line.contains("Disconnected")) + .and_then(|line| line.split_whitespace().last()) + .ok_or_else(|| SystemDnsError::CommandFailed("No active interface found".into()))?; + + // Backup current DNS settings + let output = Command::new("netsh") + .args(&[ + "interface", + "ipv4", + "show", + "dnsservers", + active_interface, + ]) + .output()?; + + let dns_output = String::from_utf8_lossy(&output.stdout); + let original_dns: Vec = dns_output + .lines() + .filter_map(|line| { + if line.trim().starts_with(|c: char| c.is_numeric()) { + Some(line.trim().to_string()) + } else { + None + } + }) + .collect(); + + self.backup = Some(DnsBackup { + interface: active_interface.to_string(), + original_dns, + }); + + // Set DNS to 127.0.0.1 + let status = Command::new("netsh") + .args(&[ + "interface", + "ipv4", + "set", + "dnsservers", + active_interface, + "static", + "127.0.0.1", + "primary", + ]) + .status()?; + + if !status.success() { + return Err(SystemDnsError::CommandFailed( + "Failed to set DNS".into(), + )); + } + + tracing::info!("System DNS set to 127.0.0.1 on interface {}", active_interface); + Ok(()) + } + + #[cfg(target_os = "windows")] + async fn restore_windows_dns(&self) -> Result<(), SystemDnsError> { + let backup = self.backup.as_ref().unwrap(); + + if backup.original_dns.is_empty() { + // Set to DHCP + let status = Command::new("netsh") + .args(&[ + "interface", + "ipv4", + "set", + "dnsservers", + &backup.interface, + "dhcp", + ]) + .status()?; + + if !status.success() { + return Err(SystemDnsError::CommandFailed( + "Failed to restore DNS to DHCP".into(), + )); + } + } else { + // Restore first DNS as primary + let status = Command::new("netsh") + .args(&[ + "interface", + "ipv4", + "set", + "dnsservers", + &backup.interface, + "static", + &backup.original_dns[0], + "primary", + ]) + .status()?; + + if !status.success() { + return Err(SystemDnsError::CommandFailed( + "Failed to restore primary DNS".into(), + )); + } + + // Add additional DNS servers + for dns in backup.original_dns.iter().skip(1) { + let _ = Command::new("netsh") + .args(&[ + "interface", + "ipv4", + "add", + "dnsservers", + &backup.interface, + dns, + ]) + .status(); + } + } + + tracing::info!("System DNS restored on interface {}", backup.interface); + Ok(()) + } + + #[cfg(target_os = "linux")] + async fn set_linux_dns(&mut self) -> Result<(), SystemDnsError> { + use tokio::fs; + + // Backup /etc/resolv.conf + let resolv_conf = fs::read_to_string("/etc/resolv.conf").await?; + self.backup = Some(DnsBackup { + resolv_conf_backup: resolv_conf, + }); + + // Write new resolv.conf + let new_conf = "# Managed by OSTP\nnameserver 127.0.0.1\n"; + fs::write("/etc/resolv.conf", new_conf).await?; + + tracing::info!("System DNS set to 127.0.0.1 in /etc/resolv.conf"); + Ok(()) + } + + #[cfg(target_os = "linux")] + async fn restore_linux_dns(&self) -> Result<(), SystemDnsError> { + use tokio::fs; + + let backup = self.backup.as_ref().unwrap(); + fs::write("/etc/resolv.conf", &backup.resolv_conf_backup).await?; + + tracing::info!("System DNS restored from backup"); + Ok(()) + } +} + +impl Drop for SystemDnsManager { + fn drop(&mut self) { + if self.backup.is_some() { + // Best effort restore - use blocking runtime + let rt = tokio::runtime::Handle::try_current(); + if let Ok(handle) = rt { + let _ = handle.block_on(self.restore()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] // Requires admin/root + async fn test_set_and_restore_dns() { + let mut manager = SystemDnsManager::new(); + manager.set_local_dns().await.unwrap(); + manager.restore().await.unwrap(); + } +} diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml index eb34e85..44688ac 100644 --- a/ostp-server/Cargo.toml +++ b/ostp-server/Cargo.toml @@ -21,3 +21,4 @@ hex.workspace = true serde.workspace = true serde_json.workspace = true rand.workspace = true +reqwest = { version = "0.11", features = ["json"] } diff --git a/ostp-server/src/main.rs b/ostp-server/src/main.rs index dce50cf..420d867 100644 --- a/ostp-server/src/main.rs +++ b/ostp-server/src/main.rs @@ -55,6 +55,13 @@ struct ConfigFile { psk: String, max_connections: Option, log_level: Option, + + // Node enrollment settings + master_node_url: Option, + node_name: Option, + hardware_id: Option, + region: Option, + country_code: Option, } fn setup_logging(level: &str) { @@ -120,7 +127,7 @@ async fn main() -> Result<()> { } // Load config from file if specified - let (listen, psk, log_level) = if let Some(config_path) = cli.config { + let (listen, psk, log_level, config_path_opt) = if let Some(config_path) = cli.config.clone() { let content = std::fs::read_to_string(&config_path) .with_context(|| format!("Failed to read config file: {:?}", config_path))?; let config: ConfigFile = serde_json::from_str(&content) @@ -131,7 +138,7 @@ async fn main() -> Result<()> { let psk = parse_psk(&config.psk)?; let level = config.log_level.unwrap_or_else(|| cli.log_level.clone()); - (addr, psk, level) + (addr, psk, level, Some(config_path)) } else { // Use CLI args let psk_str = cli.psk.ok_or_else(|| { @@ -139,7 +146,7 @@ async fn main() -> Result<()> { })?; let psk = parse_psk(&psk_str)?; - (cli.listen, psk, cli.log_level) + (cli.listen, psk, cli.log_level, None) }; setup_logging(&log_level); @@ -153,6 +160,47 @@ async fn main() -> Result<()> { tracing::info!(" PSK: {}...{}", &hex::encode(&psk[..4]), &hex::encode(&psk[28..])); tracing::info!(""); + // Check if node enrollment is configured + if let Some(config_path) = config_path_opt { + let content = std::fs::read_to_string(&config_path)?; + let config: ConfigFile = serde_json::from_str(&content)?; + + if let Some(master_url) = config.master_node_url { + if config.psk == "AUTO" { + // Node needs to enroll - request PSK from master + tracing::info!("Node not enrolled - requesting to join network..."); + + let enrollment_result = request_enrollment( + &master_url, + config.node_name.as_deref().unwrap_or("ostp-node"), + &listen.to_string(), + config.country_code.as_deref().unwrap_or("US"), + config.hardware_id.as_deref().unwrap_or("unknown"), + config.region.as_deref().unwrap_or("default"), + ).await; + + match enrollment_result { + Ok(node_id) => { + tracing::info!("✓ Enrollment request submitted"); + tracing::info!(" Node ID: {}", node_id); + tracing::info!(""); + tracing::info!("Waiting for master node approval..."); + tracing::info!("Contact the administrator to approve node: {}", node_id); + tracing::info!("Once approved, update config with provided PSK"); + + // Exit - node must wait for approval + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to submit enrollment: {}", e); + tracing::error!("Server will not start until enrolled"); + return Err(e); + } + } + } + } + } + let config = ServerConfig::new(listen, psk); let server = OstpServer::new(config); @@ -161,3 +209,59 @@ async fn main() -> Result<()> { Ok(()) } + +/// Submit enrollment request to master node +async fn request_enrollment( + master_url: &str, + name: &str, + address: &str, + country_code: &str, + hardware_id: &str, + region: &str, +) -> Result { + #[derive(serde::Serialize)] + struct EnrollmentRequest { + name: String, + address: String, + country_code: String, + hardware_id: String, + region: String, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct EnrollmentResponse { + node_id: String, + state: String, + message: String, + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + let request = EnrollmentRequest { + name: name.to_string(), + address: address.to_string(), + country_code: country_code.to_string(), + hardware_id: hardware_id.to_string(), + region: region.to_string(), + }; + + let url = format!("{}/api/v1/enrollment/request", master_url); + let response = client + .post(&url) + .json(&request) + .send() + .await + .context("Failed to connect to master node")?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + anyhow::bail!("Enrollment request failed: {} - {}", status, text); + } + + let enrollment: EnrollmentResponse = response.json().await?; + Ok(enrollment.node_id) +}