From cba7be4b75028628d4ee1790ffdbbb7fc53300b3 Mon Sep 17 00:00:00 2001 From: ospab Date: Tue, 26 May 2026 19:33:45 +0300 Subject: [PATCH] Implement config management API, token generation, and update wiki --- .gitignore | 2 +- .ostp_public_ip | 1 - Cargo.lock | 1 + ostp-gui/src-tauri/src/lib.rs | 10 ++ ostp-server/Cargo.toml | 1 + ostp-server/src/api.rs | 231 ++++++++++++++++++++++++++++--- ostp-server/src/dispatcher.rs | 71 ++++++++-- ostp-server/src/lib.rs | 178 +++++++++++++++++------- ostp-server/src/relay_node.rs | 23 ++- ostp-server/src/transport/uot.rs | 2 +- ostp-wiki | 1 - ostp-wiki/README.md | 3 + ostp-wiki/api_endpoints.md | 149 ++++++++++++++++++++ ostp-wiki/configuration_guide.md | 125 +++++++++++++++++ ostp/src/main.rs | 94 ++++++++++++- test.json | 56 -------- test2.json | 56 -------- 17 files changed, 789 insertions(+), 215 deletions(-) delete mode 100644 .ostp_public_ip delete mode 160000 ostp-wiki create mode 100644 ostp-wiki/README.md create mode 100644 ostp-wiki/api_endpoints.md create mode 100644 ostp-wiki/configuration_guide.md delete mode 100644 test.json delete mode 100644 test2.json diff --git a/.gitignore b/.gitignore index b780b72..c72ca3c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,5 @@ wintun.dll # Dev notes (not for repo) .ai-rules.md turn-harvesting-idea.md -ostp-wiki/ +ostp-control/ ostp-flutter/ diff --git a/.ostp_public_ip b/.ostp_public_ip deleted file mode 100644 index e56ea71..0000000 --- a/.ostp_public_ip +++ /dev/null @@ -1 +0,0 @@ -127.0.0.1 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 70be4a4..a26aa0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1097,6 +1097,7 @@ dependencies = [ "bytes", "futures-util", "hmac", + "json_comments", "ostp-core", "portable-atomic", "rand 0.8.5", diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index eb2dc00..8333f8a 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -365,6 +365,16 @@ async fn start_tun_via_helper( guard: &mut AppStateInner, raw: &ClientConfigRaw, ) -> Result { + #[cfg(target_os = "windows")] + { + // Kill any existing helper processes to prevent os error 10048 (port already in use) + use std::os::windows::process::CommandExt; + let _ = std::process::Command::new("taskkill") + .args(["/F", "/IM", "ostp-tun-helper.exe"]) + .creation_flags(0x08000000) + .output(); + } + let helper_exe = find_helper_exe().ok_or_else(|| "ostp-tun-helper.exe not found.".to_string())?; launch_as_admin(&helper_exe).map_err(|e| format!("Failed to launch helper: {}", e))?; tokio::time::sleep(std::time::Duration::from_millis(1500)).await; diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml index c9bb720..46fefa3 100644 --- a/ostp-server/Cargo.toml +++ b/ostp-server/Cargo.toml @@ -12,6 +12,7 @@ tracing.workspace = true ostp-core = { path = "../ostp-core" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +json_comments = "0.2" rand.workspace = true socket2 = "0.6.3" axum = "0.8" diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 37b983d..918e419 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -36,7 +36,7 @@ use crate::dispatcher::{UserStats, UserStatsSnapshot}; /// API server shared state. Held behind Arc for axum handlers. #[derive(Clone)] pub struct ApiState { - pub access_keys: Arc>>, + pub access_keys: Arc>>, pub user_stats: Arc>>>, pub start_time: Instant, pub api_token: String, @@ -44,6 +44,7 @@ pub struct ApiState { pub server_host: String, pub server_port: u16, pub reality_query: String, + pub config_path: Option, } // ── API configuration ──────────────────────────────────────────────────────── @@ -65,6 +66,12 @@ impl Default for ApiConfig { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UserMeta { + pub name: Option, + pub limit_bytes: Option, +} + // ── Request/Response types ─────────────────────────────────────────────────── #[derive(Serialize)] @@ -78,6 +85,13 @@ struct ServerStatus { #[derive(Deserialize)] pub struct CreateUserRequest { pub access_key: Option, + pub name: Option, + pub limit_bytes: Option, +} + +#[derive(Deserialize)] +pub struct UpdateUserRequest { + pub name: Option, pub limit_bytes: Option, } @@ -119,11 +133,13 @@ pub fn create_api_router(state: ApiState) -> Router { Router::new() .route("/api/server/status", get(handle_status)) + .route("/api/server/config", get(handle_get_config).put(handle_put_config)) .route("/api/users", get(handle_list_users)) .route("/api/users", post(handle_create_user)) - .route("/api/users/{key}", get(handle_get_user)) - .route("/api/users/{key}", delete(handle_delete_user)) - .route("/api/users/{key}/limit", put(handle_set_limit)) + .route("/api/users/:key", get(handle_get_user)) + .route("/api/users/:key", delete(delete_user)) + .route("/api/users/:key", put(update_user)) + .route("/api/users/:key/limit", put(handle_set_limit)) .route("/api/users/{key}/reset", post(handle_reset_stats)) .route("/api/subscribe/{key}", get(handle_subscribe)) .layer(cors) @@ -133,11 +149,12 @@ pub fn create_api_router(state: ApiState) -> Router { /// Start the Management API server on the configured bind address. pub async fn start_api_server( config: ApiConfig, - access_keys: Arc>>, + access_keys: Arc>>, user_stats: Arc>>>, server_host: String, server_port: u16, reality_query: String, + config_path: Option, ) { let state = ApiState { access_keys, @@ -147,6 +164,7 @@ pub async fn start_api_server( server_host, server_port, reality_query, + config_path, }; let app = create_api_router(state); @@ -181,8 +199,116 @@ fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool { } } +fn save_config_keys(state: &ApiState) -> Result<(), String> { + let Some(ref path) = state.config_path else { + return Ok(()); + }; + + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read config file: {}", e))?; + + let mut stripped = json_comments::StripComments::new(content.as_bytes()); + let mut content_str = String::new(); + use std::io::Read; + stripped.read_to_string(&mut content_str) + .map_err(|e| format!("failed to strip comments from config: {}", e))?; + + let mut json_val: serde_json::Value = serde_json::from_str(&content_str) + .map_err(|e| format!("failed to parse config JSON: {}", e))?; + + let keys = state.access_keys.read().unwrap(); + let mut access_keys_json = Vec::new(); + for (k, m) in keys.iter() { + if m.name.is_none() && m.limit_bytes.is_none() { + access_keys_json.push(serde_json::Value::String(k.clone())); + } else { + let mut obj = serde_json::Map::new(); + obj.insert("access_key".to_string(), serde_json::Value::String(k.clone())); + if let Some(ref name) = m.name { + obj.insert("name".to_string(), serde_json::Value::String(name.clone())); + } + if let Some(limit) = m.limit_bytes { + obj.insert("limit_bytes".to_string(), serde_json::Value::Number(limit.into())); + } + access_keys_json.push(serde_json::Value::Object(obj)); + } + } + + if let Some(obj) = json_val.as_object_mut() { + obj.insert("access_keys".to_string(), serde_json::Value::Array(access_keys_json)); + } else { + return Err("config root is not an object".to_string()); + } + + let new_content = serde_json::to_string_pretty(&json_val) + .map_err(|e| format!("failed to serialize config JSON: {}", e))?; + + std::fs::write(path, new_content) + .map_err(|e| format!("failed to write config file: {}", e))?; + + Ok(()) +} + // ── Handlers ───────────────────────────────────────────────────────────────── +async fn handle_get_config( + State(state): State, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + if !check_token(&state, &headers) { + return api_unauthorized::(); + } + + let Some(ref path) = state.config_path else { + return api_error("No config path registered (run-time only)"); + }; + + match std::fs::read_to_string(path) { + Ok(content) => { + let mut stripped = json_comments::StripComments::new(content.as_bytes()); + let mut content_str = String::new(); + use std::io::Read; + if let Err(e) = stripped.read_to_string(&mut content_str) { + return api_error(&format!("Failed to strip comments: {}", e)); + } + match serde_json::from_str::(&content_str) { + Ok(val) => (StatusCode::OK, ApiResponse::success(val)), + Err(e) => api_error(&format!("Failed to parse config JSON: {}", e)), + } + } + Err(e) => api_error(&format!("Failed to read config file: {}", e)), + } +} + +async fn handle_put_config( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + if !check_token(&state, &headers) { + return api_unauthorized::(); + } + + let Some(ref path) = state.config_path else { + return api_error("No config path registered (run-time only)"); + }; + + if body.get("mode").is_none() { + return api_error("Invalid config: missing 'mode' field"); + } + + let new_content = match serde_json::to_string_pretty(&body) { + Ok(s) => s, + Err(e) => return api_error(&format!("Failed to format config JSON: {}", e)), + }; + + if let Err(e) = std::fs::write(path, new_content) { + return api_error(&format!("Failed to write config file: {}", e)); + } + + (StatusCode::OK, ApiResponse::success(true)) +} + async fn handle_status( State(state): State, headers: axum::http::HeaderMap, @@ -304,39 +430,91 @@ async fn handle_create_user( { let mut keys = state.access_keys.write().unwrap(); - keys.insert(key.clone(), ()); + keys.insert(key.clone(), UserMeta { name: body.name.clone(), limit_bytes: body.limit_bytes }); } - if let Some(limit) = body.limit_bytes { - let mut stats = state.user_stats.write().unwrap(); - stats.insert(key.clone(), Arc::new(UserStats::new(Some(limit)))); + let mut stats = state.user_stats.write().unwrap(); + stats.insert(key.clone(), Arc::new(UserStats::new(body.limit_bytes))); + drop(stats); + + if let Err(e) = save_config_keys(&state) { + tracing::error!("Failed to save config after creating user: {}", e); + return api_error::("failed to save configuration"); } tracing::info!("API: created user key {}", &key[..8.min(key.len())]); (StatusCode::OK, ApiResponse::success(key)) } -async fn handle_delete_user( +async fn delete_user( State(state): State, - headers: axum::http::HeaderMap, Path(key): Path, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { if !check_token(&state, &headers) { - return api_unauthorized::(); + return api_unauthorized::(); } - let removed = { + { let mut keys = state.access_keys.write().unwrap(); - keys.remove(&key).is_some() - }; + if keys.remove(&key).is_none() { + return api_error::("User not found"); + } + } - if removed { + { let mut stats = state.user_stats.write().unwrap(); stats.remove(&key); - tracing::info!("API: deleted user key {}", &key[..8.min(key.len())]); } - (StatusCode::OK, ApiResponse::success(removed)) + if let Err(e) = save_config_keys(&state) { + tracing::error!("Failed to save config after removing user: {}", e); + return api_error::("failed to save configuration"); + } + + (StatusCode::OK, ApiResponse::success("User removed".to_string())) +} + +async fn update_user( + State(state): State, + Path(key): Path, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + if !check_token(&state, &headers) { + return api_unauthorized::(); + } + + { + let mut keys = state.access_keys.write().unwrap(); + if let Some(meta) = keys.get_mut(&key) { + meta.name = body.name.clone(); + meta.limit_bytes = body.limit_bytes; + } else { + return api_error::("User not found"); + } + } + + { + let mut stats = state.user_stats.write().unwrap(); + let entry = stats.entry(key.clone()) + .or_insert_with(|| Arc::new(UserStats::new(body.limit_bytes))); + + *entry = Arc::new(UserStats { + bytes_up: AtomicU64::new(entry.bytes_up.load(Ordering::Relaxed)), + bytes_down: AtomicU64::new(entry.bytes_down.load(Ordering::Relaxed)), + connections: AtomicU64::new(entry.connections.load(Ordering::Relaxed)), + limit_bytes: body.limit_bytes, + created_at: entry.created_at, + }); + } + + if let Err(e) = save_config_keys(&state) { + tracing::error!("Failed to save config after updating user: {}", e); + return api_error::("failed to save configuration"); + } + + (StatusCode::OK, ApiResponse::success("User updated".to_string())) } async fn handle_set_limit( @@ -349,11 +527,14 @@ async fn handle_set_limit( return api_unauthorized::(); } - let keys = state.access_keys.read().unwrap(); - if !keys.contains_key(&key) { - return api_error("user not found"); + { + let mut keys = state.access_keys.write().unwrap(); + if let Some(meta) = keys.get_mut(&key) { + meta.limit_bytes = body.limit_bytes; + } else { + return api_error("user not found"); + } } - drop(keys); let mut stats = state.user_stats.write().unwrap(); let entry = stats.entry(key.clone()) @@ -366,6 +547,12 @@ async fn handle_set_limit( limit_bytes: body.limit_bytes, created_at: entry.created_at, }); + drop(stats); + + if let Err(e) = save_config_keys(&state) { + tracing::error!("Failed to save config after setting user limit: {}", e); + return api_error("failed to save configuration"); + } (StatusCode::OK, ApiResponse::success(true)) } diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 7a81a31..db2584f 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -74,7 +74,7 @@ pub struct Dispatcher { peer_machines: HashMap, addr_to_session: HashMap, machine_config: ProtocolConfig, - access_keys: Arc>>, + access_keys: Arc>>, user_stats: Arc>>>, replay_cache: std::collections::HashMap, u64>, roaming_tokens: f64, @@ -83,13 +83,17 @@ pub struct Dispatcher { #[allow(dead_code)] impl Dispatcher { - pub fn new(machine_config: ProtocolConfig, access_keys: Arc>>) -> Self { + pub fn new(machine_config: ProtocolConfig, access_keys: Arc>>) -> Self { + let mut initial_stats = HashMap::new(); + for (key, meta) in access_keys.read().unwrap().iter() { + initial_stats.insert(key.clone(), Arc::new(UserStats::new(meta.limit_bytes))); + } Self { peer_machines: HashMap::new(), addr_to_session: HashMap::new(), machine_config, access_keys, - user_stats: Arc::new(RwLock::new(HashMap::new())), + user_stats: Arc::new(RwLock::new(initial_stats)), replay_cache: std::collections::HashMap::new(), roaming_tokens: 50.0, last_token_regen: std::time::Instant::now(), @@ -124,9 +128,12 @@ impl Dispatcher { return existing.clone(); } drop(stats); + + let limit_bytes = self.access_keys.read().unwrap().get(key).and_then(|m| m.limit_bytes); + let mut stats = self.user_stats.write().unwrap(); stats.entry(key.to_string()) - .or_insert_with(|| Arc::new(UserStats::new(None))) + .or_insert_with(|| Arc::new(UserStats::new(limit_bytes))) .clone() } @@ -200,7 +207,21 @@ impl Dispatcher { } if let Some(session_id) = session_id_opt { + let key_opt = self.peer_machines.get(&session_id).map(|ps| ps.access_key.clone()); + if let Some(access_key) = key_opt { + // Check if key is still valid and not over limit + let key_valid = self.access_keys.read().unwrap().contains_key(&access_key); + let user_stats = self.get_or_create_user_stats(&access_key); + if !key_valid || user_stats.is_over_limit() { + tracing::info!("Dropping session {} for key {} (valid={}, over_limit={})", + session_id, access_key, key_valid, user_stats.is_over_limit()); + self.drop_session(session_id); + return Ok(DispatchOutcome::Unauthorized); + } + } + if let Some(peer_state) = self.peer_machines.get_mut(&session_id) { + if peer_state.last_addr != peer { tracing::info!("Client roamed: session {} from {} to {}", session_id, peer_state.last_addr, peer); self.addr_to_session.remove(&peer_state.last_addr); @@ -211,7 +232,7 @@ impl Dispatcher { // Track inbound bytes per user let key = peer_state.access_key.clone(); - track_user_bytes_up(&self.user_stats, &key, packet.len() as u64); + track_user_bytes_up(&self.user_stats, &self.access_keys, &key, packet.len() as u64); let action = match peer_state.machine.on_event(OstpEvent::Inbound(packet)) { Ok(a) => a, @@ -385,7 +406,7 @@ impl Dispatcher { match peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))? { ProtocolAction::SendDatagram(frame) => { // Track outbound bytes per user - track_user_bytes_down(&self.user_stats, &key, frame.len() as u64); + track_user_bytes_down(&self.user_stats, &self.access_keys, &key, frame.len() as u64); Ok(Some((frame, addr))) } _ => Ok(None), @@ -405,16 +426,34 @@ impl Dispatcher { let now = std::time::Instant::now(); let timeout_dur = std::time::Duration::from_secs(300); // 5 minutes session timeout - // Gather expired sessions + // Gather expired or invalid sessions for (&sid, peer_state) in &self.peer_machines { - if now.duration_since(peer_state.last_seen) > timeout_dur { + let key_valid = self.access_keys.read().unwrap().contains_key(&peer_state.access_key); + let user_stats = self.get_or_create_user_stats(&peer_state.access_key); + if now.duration_since(peer_state.last_seen) > timeout_dur || !key_valid || user_stats.is_over_limit() { expired.push(sid); } } - // Clear expired sessions from internal state + // Clear expired/invalid sessions from internal state for sid in &expired { - tracing::info!("Session {} expired (inactive >5min), releasing", sid); + let peer_state_opt = self.peer_machines.get(sid); + let reason = if let Some(ps) = peer_state_opt { + let key_valid = self.access_keys.read().unwrap().contains_key(&ps.access_key); + let user_stats = self.get_or_create_user_stats(&ps.access_key); + if now.duration_since(ps.last_seen) > timeout_dur { + "inactive >5min" + } else if !key_valid { + "key deleted" + } else if user_stats.is_over_limit() { + "traffic limit exceeded" + } else { + "unknown" + } + } else { + "unknown" + }; + tracing::info!("Session {} closed ({}), releasing", sid, reason); self.drop_session(*sid); } @@ -459,6 +498,7 @@ impl Dispatcher { fn get_or_create_stats( user_stats: &Arc>>>, + access_keys: &Arc>>, key: &str, ) -> Arc { { @@ -467,26 +507,31 @@ fn get_or_create_stats( return existing.clone(); } } + + let limit_bytes = access_keys.read().unwrap().get(key).and_then(|m| m.limit_bytes); + let mut stats = user_stats.write().unwrap(); stats.entry(key.to_string()) - .or_insert_with(|| Arc::new(UserStats::new(None))) + .or_insert_with(|| Arc::new(UserStats::new(limit_bytes))) .clone() } fn track_user_bytes_up( user_stats: &Arc>>>, + access_keys: &Arc>>, key: &str, bytes: u64, ) { - let stats = get_or_create_stats(user_stats, key); + let stats = get_or_create_stats(user_stats, access_keys, key); stats.bytes_up.fetch_add(bytes, Ordering::Relaxed); } fn track_user_bytes_down( user_stats: &Arc>>>, + access_keys: &Arc>>, key: &str, bytes: u64, ) { - let stats = get_or_create_stats(user_stats, key); + let stats = get_or_create_stats(user_stats, access_keys, key); stats.bytes_down.fetch_add(bytes, Ordering::Relaxed); } diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index b9eddc6..162d6dd 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -65,65 +65,21 @@ pub(crate) struct RemoteState { pub async fn run_server( bind_addrs: Vec, - access_keys: Vec, + access_keys: Vec<(String, crate::api::UserMeta)>, outbound: Option, api_config: Option, fallback_config: Option, debug: bool, reality_query: Option, reality_config: Option, + config_path: Option, ) -> Result<()> { let mut keys_map = HashMap::new(); - for key in access_keys { - keys_map.insert(key, ()); + for (key, meta) in access_keys { + keys_map.insert(key, meta); } let shared_keys = std::sync::Arc::new(std::sync::RwLock::new(keys_map)); - // Background config hot-reloader for access keys - let shared_keys_clone = shared_keys.clone(); - tokio::spawn(async move { - let mut last_mtime = None; - let exe = match std::env::current_exe() { - Ok(e) => e, - Err(_) => return, - }; - let dir = match exe.parent() { - Some(d) => d, - None => return, - }; - let config_path = dir.join("config.json"); - - loop { - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - if let Ok(metadata) = std::fs::metadata(&config_path) { - if let Ok(mtime) = metadata.modified() { - if last_mtime != Some(mtime) { - last_mtime = Some(mtime); - if let Ok(content) = std::fs::read_to_string(&config_path) { - #[derive(serde::Deserialize)] - struct ServerReloadConfig { - mode: String, - #[serde(default)] - access_keys: Vec, - } - if let Ok(cfg) = serde_json::from_str::(&content) { - if cfg.mode == "server" { - let mut new_keys = HashMap::new(); - for key in cfg.access_keys { - new_keys.insert(key, ()); - } - let mut keys_lock = shared_keys_clone.write().unwrap(); - *keys_lock = new_keys; - tracing::info!("Hot-reloaded {} access keys from config.json", keys_lock.len()); - } - } - } - } - } - } - } - }); - let mut sockets = Vec::new(); for bind_addr in &bind_addrs { let addr = bind_addr.parse::() @@ -164,6 +120,125 @@ pub async fn run_server( let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone()); + // Background config hot-reloader for access keys + let shared_keys_clone = shared_keys.clone(); + let user_stats_clone = dispatcher.user_stats_ref(); + let config_path_clone = config_path.clone(); + tokio::spawn(async move { + let path_to_watch = if let Some(p) = config_path_clone { + p + } else { + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(_) => return, + }; + let dir = match exe.parent() { + Some(d) => d, + None => return, + }; + dir.join("config.json") + }; + + let path_to_watch = match std::fs::canonicalize(&path_to_watch) { + Ok(p) => p, + Err(_) => path_to_watch, + }; + + tracing::info!("Watching configuration file for hot-reload: {:?}", path_to_watch); + + let mut last_mtime = None; + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + if let Ok(metadata) = std::fs::metadata(&path_to_watch) { + if let Ok(mtime) = metadata.modified() { + if last_mtime != Some(mtime) { + last_mtime = Some(mtime); + match std::fs::read_to_string(&path_to_watch) { + Ok(content) => { + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum ReloadUser { + Detailed { access_key: String, name: Option, limit_bytes: Option }, + KeyOnly(String), + } + #[derive(serde::Deserialize)] + struct ServerReloadConfig { + mode: String, + #[serde(default)] + access_keys: Vec, + } + + let mut stripped = json_comments::StripComments::new(content.as_bytes()); + let mut content_str = String::new(); + use std::io::Read; + if let Err(e) = stripped.read_to_string(&mut content_str) { + tracing::error!("Failed to strip comments from config during hot-reload: {}", e); + continue; + } + + match serde_json::from_str::(&content_str) { + Ok(cfg) => { + if cfg.mode == "server" { + let mut new_keys = HashMap::new(); + for uc in cfg.access_keys { + let (k, m) = match uc { + ReloadUser::Detailed { access_key, name, limit_bytes } => (access_key, crate::api::UserMeta { name, limit_bytes }), + ReloadUser::KeyOnly(k) => (k, crate::api::UserMeta { name: None, limit_bytes: None }), + }; + new_keys.insert(k, m); + } + + // 1. Update shared_keys + let mut keys_lock = shared_keys_clone.write().unwrap(); + *keys_lock = new_keys.clone(); + + // 2. Synchronize user_stats limits & cleanup deleted keys + let mut stats_lock = user_stats_clone.write().unwrap(); + stats_lock.retain(|k, _| new_keys.contains_key(k)); + + for (k, meta) in &new_keys { + let entry_info = stats_lock.get(k).map(|e| { + ( + e.limit_bytes, + e.bytes_up.load(std::sync::atomic::Ordering::Relaxed), + e.bytes_down.load(std::sync::atomic::Ordering::Relaxed), + e.connections.load(std::sync::atomic::Ordering::Relaxed), + e.created_at, + ) + }); + if let Some((limit_bytes, bytes_up, bytes_down, connections, created_at)) = entry_info { + if limit_bytes != meta.limit_bytes { + stats_lock.insert(k.clone(), std::sync::Arc::new(dispatcher::UserStats { + bytes_up: portable_atomic::AtomicU64::new(bytes_up), + bytes_down: portable_atomic::AtomicU64::new(bytes_down), + connections: portable_atomic::AtomicU64::new(connections), + limit_bytes: meta.limit_bytes, + created_at, + })); + } + } else { + stats_lock.insert(k.clone(), std::sync::Arc::new(dispatcher::UserStats::new(meta.limit_bytes))); + } + } + + tracing::info!("Hot-reloaded {} access keys from {:?}", keys_lock.len(), path_to_watch); + } + } + Err(e) => { + tracing::error!("Failed to parse config file during hot-reload: {}", e); + } + } + } + Err(e) => { + tracing::error!("Failed to read config file during hot-reload: {}", e); + } + } + } + } + } + } + }); + // Spawn Management API if configured if let Some(api_cfg) = api_config { if api_cfg.enabled { @@ -175,8 +250,9 @@ pub async fn run_server( let server_port: u16 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(50000); let server_host = parts.get(1).unwrap_or(&"0.0.0.0").to_string(); let rq = reality_query.clone().unwrap_or_default(); + let config_path_api = config_path.clone(); tokio::spawn(async move { - api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq).await; + api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api).await; }); } } @@ -267,7 +343,7 @@ async fn run_server_loop( mut dispatcher: Dispatcher, mut ui_cmd_rx: mpsc::UnboundedReceiver, ui_event_tx: mpsc::UnboundedSender, - shared_keys: std::sync::Arc>>, + shared_keys: std::sync::Arc>>, outbound: Option, debug: bool, tls_config: Option>, @@ -396,7 +472,7 @@ async fn run_server_loop( match cmd { Some(UiCommand::CreateClientKey) => { let key = format!("ostp_key_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()); - shared_keys.write().unwrap().insert(key.clone(), ()); + shared_keys.write().unwrap().insert(key.clone(), crate::api::UserMeta { name: None, limit_bytes: None }); let _ = ui_event_tx.send(UiEvent::KeyCreated { key }); } Some(UiCommand::Shutdown) | None => { diff --git a/ostp-server/src/relay_node.rs b/ostp-server/src/relay_node.rs index fb3f6cb..37975e5 100644 --- a/ostp-server/src/relay_node.rs +++ b/ostp-server/src/relay_node.rs @@ -82,7 +82,7 @@ pub async fn run_relay_node(cfg: RelayConfig) -> Result<()> { /// Синхронизация access_keys с upstream API. async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result { - let url = format!("{}/api/keys", cfg.upstream_api_url.trim_end_matches('/')); + let url = format!("{}/api/users", cfg.upstream_api_url.trim_end_matches('/')); let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) @@ -99,15 +99,26 @@ async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result } #[derive(serde::Deserialize)] - struct KeysResponse { - keys: Vec, + struct UserStatsSnapshot { + access_key: String, } - let body: KeysResponse = resp.json().await?; - let count = body.keys.len(); + #[derive(serde::Deserialize)] + struct ApiResponse { + ok: bool, + data: Option>, + } + + let body: ApiResponse = resp.json().await?; + if !body.ok { + anyhow::bail!("API returned error ok=false"); + } + + let keys: Vec = body.data.unwrap_or_default().into_iter().map(|u| u.access_key).collect(); + let count = keys.len(); { let mut lock = shared_keys.write().unwrap(); - *lock = body.keys; + *lock = keys; } Ok(count) } diff --git a/ostp-server/src/transport/uot.rs b/ostp-server/src/transport/uot.rs index aa9d579..ac00bcb 100644 --- a/ostp-server/src/transport/uot.rs +++ b/ostp-server/src/transport/uot.rs @@ -13,7 +13,7 @@ use tracing::info; pub async fn handle_tcp_connection( mut stream: S, peer_addr: SocketAddr, - shared_keys: Arc>>, + shared_keys: Arc>>, udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, tcp_map: Arc>>>, ) -> Result<()> diff --git a/ostp-wiki b/ostp-wiki deleted file mode 160000 index 43b4935..0000000 --- a/ostp-wiki +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43b4935fd2addc284a5ae8719824652f9063b95d diff --git a/ostp-wiki/README.md b/ostp-wiki/README.md new file mode 100644 index 0000000..39b8182 --- /dev/null +++ b/ostp-wiki/README.md @@ -0,0 +1,3 @@ +# OSTP Wiki + +This repository contains the documentation and wiki pages for the Ospab Stealth Transport Protocol (OSTP). diff --git a/ostp-wiki/api_endpoints.md b/ostp-wiki/api_endpoints.md new file mode 100644 index 0000000..cf4e41a --- /dev/null +++ b/ostp-wiki/api_endpoints.md @@ -0,0 +1,149 @@ +# Справочник API управления OSTP + +Сервер OSTP предоставляет REST API для управления пользователями, просмотра статистики трафика и интерактивного редактирования конфигурации. + +По умолчанию API слушает на порту `9090` (хост настраивается в файле конфигурации). + +--- + +## Авторизация + +Все запросы к API (за исключением подписок) должны содержать заголовок `Authorization` с API-токеном (если токен включен в конфигурационном файле): + +```http +Authorization: Bearer <ваш_api_токен> +``` + +Или в упрощенном виде: +```http +Authorization: <ваш_api_токен> +``` + +--- + +## Формат ответов + +Все ответы API возвращаются в формате JSON следующей структуры: + +```json +{ + "ok": true, + "data": ..., + "error": null +} +``` + +В случае ошибки: +```json +{ + "ok": false, + "data": null, + "error": "Описание ошибки" +} +``` + +--- + +## Список эндпоинтов + +### 1. Статус сервера +Возвращает текущую версию, аптайм и количество пользователей. + +* **URL**: `/api/server/status` +* **Метод**: `GET` +* **Формат `data`**: + ```json + { + "version": "0.2.30", + "uptime_seconds": 12053, + "active_users": 2, + "total_users": 5 + } + ``` + +### 2. Получение текущего конфига +Запрашивает полное содержимое файла `config.json` с удалением комментариев для прямой модификации. + +* **URL**: `/api/server/config` +* **Метод**: `GET` +* **Формат `data`**: Полный JSON-конфиг сервера. + +### 3. Обновление конфига +Записывает новый JSON конфигурации сервера в файл `config.json` на диске. Это автоматически вызывает **hot-reload** ядра (применение ключей доступа и лимитов). + +* **URL**: `/api/server/config` +* **Метод**: `PUT` +* **Тело запроса**: JSON нового конфигурационного файла. +* **Формат `data`**: `true` в случае успешного сохранения. + +### 4. Список клиентов и их статистики +Возвращает список всех зарегистрированных ключей доступа с их текущей загрузкой, скачиванием, активными сессиями и статусом подключения. + +* **URL**: `/api/users` +* **Метод**: `GET` +* **Формат `data`**: + ```json + [ + { + "access_key": "ostp_key_sample1", + "bytes_up": 2405020, + "bytes_down": 491029402, + "connections": 2, + "limit_bytes": 10737418240, + "online": true, + "name": "Ноутбук" + } + ] + ``` + +### 5. Создание клиента +Генерирует новый ключ доступа (или регистрирует пользовательский). + +* **URL**: `/api/users` +* **Метод**: `POST` +* **Тело запроса**: + ```json + { + "access_key": "my_custom_key_optional", + "name": "Имя клиента", + "limit_bytes": 50000000000 + } + ``` +* **Формат `data`**: Строка созданного ключа доступа. + +### 6. Удаление клиента +Отзывает ключ доступа и сбрасывает все связанные активные сессии. + +* **URL**: `/api/users/:key` +* **Метод**: `DELETE` +* **Формат `data`**: `"User removed"` + +### 7. Обновление клиента +Редактирует имя или лимит трафика для клиента. + +* **URL**: `/api/users/:key` +* **Метод**: `PUT` +* **Тело запроса**: + ```json + { + "name": "Новое имя", + "limit_bytes": 100000000000 + } + ``` +* **Формат `data`**: `"User updated"` + +### 8. Сброс счетчиков трафика +Обнуляет показания загрузки и скачивания для определенного пользователя. + +* **URL**: `/api/users/{key}/reset` +* **Метод**: `POST` +* **Формат `data`**: `true` + +### 9. Ссылка подписки клиента +Возвращает ссылку подписки или конфигурационный файл для клиента. Авторизация по Bearer-токену **не требуется** (ключ авторизуется сам через URL). + +* **URL**: `/api/subscribe/:key` +* **Метод**: `GET` +* **Заголовки**: + - `Accept: text/plain` -> Возвращает текстовую ссылку `ostp://@:?...` + - `Accept: application/json` -> Возвращает полный клиентский JSON-конфиг. diff --git a/ostp-wiki/configuration_guide.md b/ostp-wiki/configuration_guide.md new file mode 100644 index 0000000..e9d03dd --- /dev/null +++ b/ostp-wiki/configuration_guide.md @@ -0,0 +1,125 @@ +# Руководство по конфигурации OSTP (`config.json`) + +Файл `config.json` является основным конфигурационным файлом для сервера, клиента и реле. + +Ниже приведено подробное описание структуры для режима работы **Server**. + +--- + +## Полный пример конфигурации + +```json +{ + "mode": "server", + "log_level": "info", + "listen": "0.0.0.0:50000", + "access_keys": [ + "some_simple_key", + { + "access_key": "detailed_key_with_limit", + "name": "Рабочий Ноутбук", + "limit_bytes": 107374182400 + } + ], + "api": { + "enabled": true, + "bind": "127.0.0.1:9090", + "token": "7a3f8b2c4d9e0f1a2b3c4d5e6f7a8b9c" + }, + "fallback": { + "enabled": false, + "listen": "0.0.0.0:443", + "target": "127.0.0.1:8080" + }, + "reality": { + "enabled": false, + "dest": "www.microsoft.com:443", + "private_key": "...", + "pbk": "...", + "sid": "...", + "sni_list": ["www.microsoft.com"] + }, + "outbound": { + "enabled": false, + "protocol": "socks5", + "address": "127.0.0.1", + "port": 9050, + "default_action": "proxy", + "rules": [ + { + "domain_suffix": [".onion"], + "action": "proxy" + } + ] + }, + "debug": false +} +``` + +--- + +## Описание разделов конфигурации + +### 1. Основные параметры +- **`mode`** (строка): Режим работы. Возможные варианты: `"server"`, `"client"`, `"relay"`. +- **`log_level`** (строка): Уровень логирования. Варианты: `"debug"`, `"info"`, `"warn"`, `"error"`. +- **`listen`** (строка или массив строк): Порт и интерфейсы, на которых сервер слушает входящие UDP (и опционально TCP/UoT) соединения. Примеры: + - `"0.0.0.0:50000"` (все IPv4 интерфейсы) + - `["0.0.0.0:50000", "[::]:50000"]` (поддержка IPv4 и IPv6 одновременно) +- **`debug`** (логический): Включает подробное отладочное логирование протокола. + +--- + +### 2. Ключи доступа (`access_keys`) +Раздел содержит массив ключей доступа. Поддерживается два формата записи (для обратной совместимости): +1. **Простая строка**: Текст ключа доступа. Лимит трафика отсутствует. + ```json + "my_secure_key" + ``` +2. **Объект с метаданными**: + - `access_key` (строка, обязательно): Текст ключа для подключения. + - `name` (строка, опционально): Человекочитаемое описание клиента. + - `limit_bytes` (число, опционально): Лимит трафика в байтах (загрузка + скачивание). + +При достижении `limit_bytes` сессия клиента немедленно сбрасывается и подключение блокируется до обнуления счетчика или расширения лимита. + +--- + +### 3. REST API Управления (`api`) +Используется для интеграции с панелью управления `ostp-control`. +- **`enabled`** (логический): Включение встроенного веб-сервера API. +- **`bind`** (строка): Интерфейс и порт для прослушивания (например, `"127.0.0.1:9090"`). +- **`token`** (строка): Bearer-токен для авторизации администратора. Автоматически генерируется сервером при команде `ostp --init server`. + +--- + +### 4. Встроенный TCP Fallback прокси (`fallback`) +Позволяет маскировать порт под веб-сервер при сканировании активными DPI-зондами. +- **`enabled`** (логический): Включить проксирование TCP. +- **`listen`** (строка): Порт прослушивания TCP/TLS (например, `"0.0.0.0:443"`). +- **`target`** (строка): Локальный веб-сервер (например, `"127.0.0.1:8080"` на nginx/caddy), куда будут пересылаться все обычные запросы (не-OSTP трафик). + +--- + +### 5. Reality Маскировка (`reality`) +Реализует спецификацию XTLS-Reality для бесшовной маскировки трафика под легитимный TLS-сервер. +- **`enabled`** (логический): Включение маскировки. +- **`dest`** (строка): Целевой домен маскировки (например, `"www.microsoft.com:443"`). +- **`private_key`** (строка): Приватный ключ Reality сервера (X25519). +- **`pbk`** (строка): Публичный ключ Reality сервера. +- **`sid`** (строка, 8 байт hex): Идентификатор сессии. +- **`sni_list`** (массив строк): Разрешенные SNI заголовки от клиентов. + +--- + +### 6. Правила маршрутизации (`outbound`) +Позволяет пересылать часть исходящего трафика клиентов через прокси-сервер (например, SOCKS5/TOR). +- **`enabled`** (логический): Включить исходящую маршрутизацию. +- **`protocol`** (строка): Протокол прокси. На данный момент поддерживается `"socks5"`. +- **`address`** (строка): Хост прокси-сервера. +- **`port`** (число): Порт прокси-сервера. +- **`default_action`** (строка): Действие для трафика, не попавшего под правила. Варианты: `"direct"` (напрямую с сервера) или `"proxy"` (через прокси). +- **`rules`** (массив объектов): Список правил перенаправления: + - `domain_suffix` (массив строк): Фильтрация по суффиксу домена. + - `ip_cidr` (массив строк): Фильтрация по IP подсетям. + - `action` (строка): Действие при совпадении (`"direct"` или `"proxy"`). diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 442f874..bd259ea 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -191,10 +191,42 @@ impl UnifiedConfig { } } +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum UserConfig { + Detailed { + access_key: String, + name: Option, + limit_bytes: Option, + }, + KeyOnly(String), +} + +impl UserConfig { + pub fn key(&self) -> String { + match self { + UserConfig::KeyOnly(k) => k.clone(), + UserConfig::Detailed { access_key, .. } => access_key.clone(), + } + } + pub fn name(&self) -> Option { + match self { + UserConfig::KeyOnly(_) => None, + UserConfig::Detailed { name, .. } => name.clone(), + } + } + pub fn limit(&self) -> Option { + match self { + UserConfig::KeyOnly(_) => None, + UserConfig::Detailed { limit_bytes, .. } => limit_bytes.clone(), + } + } +} + #[derive(Debug, Deserialize, Serialize)] struct ServerConfig { listen: ListenConfig, - access_keys: Vec, + access_keys: Vec, reality: Option, debug: Option, outbound: Option, @@ -450,8 +482,37 @@ async fn run_app() -> Result<()> { let args = Args::parse(); if args.generate_key { + let mut new_keys = Vec::new(); for _ in 0..args.count { - println!("{}", generate_secure_key(&args.format)); + let key = generate_secure_key(&args.format); + println!("{}", key); + new_keys.push(key); + } + + // Автоматическое добавление ключа в config.json если это сервер + if args.config.exists() { + if let Ok(content) = fs::read_to_string(&args.config) { + let mut stripped = json_comments::StripComments::new(content.as_bytes()); + let mut content_str = String::new(); + use std::io::Read; + if stripped.read_to_string(&mut content_str).is_ok() { + if let Ok(mut json_val) = serde_json::from_str::(&content_str) { + if let Some(mode) = json_val.get("mode").and_then(|m| m.as_str()) { + if mode == "server" { + if let Some(access_keys) = json_val.get_mut("access_keys").and_then(|a| a.as_array_mut()) { + for key in new_keys { + access_keys.push(serde_json::Value::String(key)); + } + if let Ok(new_content) = serde_json::to_string_pretty(&json_val) { + let _ = fs::write(&args.config, new_content); + println!("[ostp] Key(s) automatically added to {:?}", args.config); + } + } + } + } + } + } + } } return Ok(()); } @@ -522,6 +583,7 @@ async fn run_app() -> Result<()> { let key = generate_secure_key("hex"); let content = if is_server { let (priv_key, pub_key, sid) = generate_reality_keys(); + let api_token = generate_secure_key("hex"); format!(r#"{{ // OSTP Server Configuration "mode": "server", @@ -556,7 +618,7 @@ async fn run_app() -> Result<()> { "enabled": false, "bind": "127.0.0.1:9090", // Set a strong token for authentication. Leave empty to disable auth. - "token": "" + "token": "{}" }}, // Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI). @@ -577,7 +639,19 @@ async fn run_app() -> Result<()> { "sni_list": ["www.microsoft.com"] }}, "debug": false -}}"#, key, priv_key, pub_key, sid) +}}"#, key, api_token, priv_key, pub_key, sid) + } else if mode_str == "relay" { + r#"{ + // OSTP Relay Node Configuration + "mode": "relay", + "listen": "0.0.0.0:50000", + "upstream_tcp": "TARGET_SERVER_IP:50000", + "upstream_udp": "TARGET_SERVER_IP:50000", + "upstream_api_url": "http://TARGET_SERVER_IP:9090", + "upstream_api_token": "YOUR_API_TOKEN_HERE", + "sync_interval_secs": 30, + "debug": false +}"#.to_string() } else { format!(r#"{{ // OSTP Client Configuration @@ -645,7 +719,7 @@ async fn run_app() -> Result<()> { if let AppMode::Server(s) = &config.mode { let key = &s.access_keys[0]; let host = get_or_ask_public_ip(&args.config); - let mut link = format!("ostp://{}@{}:50000", key, host); + let mut link = format!("ostp://{}@{}:50000", key.key(), host); let mut query_params = Vec::new(); if let Some(r) = &s.reality { @@ -725,7 +799,7 @@ async fn run_app() -> Result<()> { println!("\n Client share links from {:?}:", args.config); for (idx, key) in server_cfg.access_keys.iter().enumerate() { - let mut link = format!("ostp://{}@{}:{}", key, host, port); + let mut link = format!("ostp://{}@{}:{}", key.key(), host, port); if let Some(r) = &server_cfg.reality { link = format!("{}?security=reality&sni={}&pbk={}&sid={}&type=udp", link, r.sni_list.first().unwrap_or(&String::new()), r.pbk, r.sid); } @@ -792,8 +866,14 @@ async fn run_app() -> Result<()> { }); } } + let access_keys_meta = server_cfg.access_keys.into_iter().map(|uc| { + (uc.key(), ostp_server::api::UserMeta { + name: uc.name(), + limit_bytes: uc.limit(), + }) + }).collect::>(); // Pass all listen addresses for multi-listener support - ostp_server::run_server(listen_addrs, server_cfg.access_keys, outbound, api_config, fallback_config, debug, rq, rc).await?; + ostp_server::run_server(listen_addrs, access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, Some(args.config)).await?; } AppMode::Client(client_cfg) => { run_client_directly(client_cfg).await?; diff --git a/test.json b/test.json deleted file mode 100644 index cab5795..0000000 --- a/test.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - // OSTP Server Configuration - "mode": "server", - "log_level": "info", - - // The address and port the server listens on for incoming OSTP connections. - "listen": "0.0.0.0:50000", - - // List of valid keys. Clients must use one of these to connect. - "access_keys": [ - "07702b35e2062ac40eb4e46a7708dbf6" - ], - - // Optional proxy for outbound traffic. - "outbound": { - "enabled": false, - "protocol": "socks5", - "address": "127.0.0.1", - "port": 9050, - // default_action: 'proxy' (all through proxy) or 'direct' (bypass proxy by default). - "default_action": "proxy", - "rules": [ - { - "domain_suffix": [".onion"], - "action": "proxy" - } - ] - }, - - // Management REST API for third-party panels. - "api": { - "enabled": false, - "bind": "127.0.0.1:9090", - // Set a strong token for authentication. Leave empty to disable auth. - "token": "" - }, - - // Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI). - "fallback": { - "enabled": false, - "listen": "0.0.0.0:443", - // Target web server (e.g., local nginx or caddy) - "target": "127.0.0.1:8080" - }, - - // Reality (XTLS) / UoT Masquerade parameters - "reality": { - "enabled": false, - "dest": "www.microsoft.com:443", - "private_key": "", - "pbk": "", - "sid": "", - "sni_list": ["www.microsoft.com"] - }, - "debug": false -} \ No newline at end of file diff --git a/test2.json b/test2.json deleted file mode 100644 index db5075d..0000000 --- a/test2.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - // OSTP Server Configuration - "mode": "server", - "log_level": "info", - - // The address and port the server listens on for incoming OSTP connections. - "listen": "0.0.0.0:50000", - - // List of valid keys. Clients must use one of these to connect. - "access_keys": [ - "faa4ed2bb00f5df012f5708d86f9541c" - ], - - // Optional proxy for outbound traffic. - "outbound": { - "enabled": false, - "protocol": "socks5", - "address": "127.0.0.1", - "port": 9050, - // default_action: 'proxy' (all through proxy) or 'direct' (bypass proxy by default). - "default_action": "proxy", - "rules": [ - { - "domain_suffix": [".onion"], - "action": "proxy" - } - ] - }, - - // Management REST API for third-party panels. - "api": { - "enabled": false, - "bind": "127.0.0.1:9090", - // Set a strong token for authentication. Leave empty to disable auth. - "token": "" - }, - - // Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI). - "fallback": { - "enabled": false, - "listen": "0.0.0.0:443", - // Target web server (e.g., local nginx or caddy) - "target": "127.0.0.1:8080" - }, - - // Reality (XTLS) / UoT Masquerade parameters - "reality": { - "enabled": false, - "dest": "www.microsoft.com:443", - "private_key": "lAapk0V5dG1xSD3CWZ0Ac3c_GKbJU0hVsRjiQ6VrCEY", - "pbk": "Md5jDEfpPmaMW5ggNSL7jA0LZi4eKsyfgMwseFQ0TyQ", - "sid": "db806ac3c8bf9417", - "sni_list": ["www.microsoft.com"] - }, - "debug": false -} \ No newline at end of file