Implement config management API, token generation, and update wiki

This commit is contained in:
ospab 2026-05-26 19:33:45 +03:00
parent 951e597d46
commit cba7be4b75
17 changed files with 789 additions and 215 deletions

2
.gitignore vendored
View File

@ -32,5 +32,5 @@ wintun.dll
# Dev notes (not for repo)
.ai-rules.md
turn-harvesting-idea.md
ostp-wiki/
ostp-control/
ostp-flutter/

View File

@ -1 +0,0 @@
127.0.0.1

1
Cargo.lock generated
View File

@ -1097,6 +1097,7 @@ dependencies = [
"bytes",
"futures-util",
"hmac",
"json_comments",
"ostp-core",
"portable-atomic",
"rand 0.8.5",

View File

@ -365,6 +365,16 @@ async fn start_tun_via_helper(
guard: &mut AppStateInner,
raw: &ClientConfigRaw,
) -> Result<bool, String> {
#[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;

View File

@ -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"

View File

@ -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<RwLock<HashMap<String, ()>>>,
pub access_keys: Arc<RwLock<HashMap<String, UserMeta>>>,
pub user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
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<std::path::PathBuf>,
}
// ── API configuration ────────────────────────────────────────────────────────
@ -65,6 +66,12 @@ impl Default for ApiConfig {
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserMeta {
pub name: Option<String>,
pub limit_bytes: Option<u64>,
}
// ── Request/Response types ───────────────────────────────────────────────────
#[derive(Serialize)]
@ -78,6 +85,13 @@ struct ServerStatus {
#[derive(Deserialize)]
pub struct CreateUserRequest {
pub access_key: Option<String>,
pub name: Option<String>,
pub limit_bytes: Option<u64>,
}
#[derive(Deserialize)]
pub struct UpdateUserRequest {
pub name: Option<String>,
pub limit_bytes: Option<u64>,
}
@ -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<RwLock<HashMap<String, ()>>>,
access_keys: Arc<RwLock<HashMap<String, UserMeta>>>,
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
server_host: String,
server_port: u16,
reality_query: String,
config_path: Option<std::path::PathBuf>,
) {
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<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<serde_json::Value>();
}
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::<serde_json::Value>(&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<ApiState>,
headers: axum::http::HeaderMap,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<bool>();
}
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<ApiState>,
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::<String>("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<ApiState>,
headers: axum::http::HeaderMap,
Path(key): Path<String>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<bool>();
return api_unauthorized::<String>();
}
let removed = {
{
let mut keys = state.access_keys.write().unwrap();
keys.remove(&key).is_some()
};
if keys.remove(&key).is_none() {
return api_error::<String>("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::<String>("failed to save configuration");
}
(StatusCode::OK, ApiResponse::success("User removed".to_string()))
}
async fn update_user(
State(state): State<ApiState>,
Path(key): Path<String>,
headers: axum::http::HeaderMap,
Json(body): Json<UpdateUserRequest>,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<String>();
}
{
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::<String>("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::<String>("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::<bool>();
}
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))
}

View File

@ -74,7 +74,7 @@ pub struct Dispatcher {
peer_machines: HashMap<u32, PeerState>,
addr_to_session: HashMap<SocketAddr, u32>,
machine_config: ProtocolConfig,
access_keys: Arc<RwLock<HashMap<String, ()>>>,
access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>,
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
replay_cache: std::collections::HashMap<Vec<u8>, 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<RwLock<HashMap<String, ()>>>) -> Self {
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>) -> 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<RwLock<HashMap<String, Arc<UserStats>>>>,
access_keys: &Arc<RwLock<HashMap<String, crate::api::UserMeta>>>,
key: &str,
) -> Arc<UserStats> {
{
@ -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<RwLock<HashMap<String, Arc<UserStats>>>>,
access_keys: &Arc<RwLock<HashMap<String, crate::api::UserMeta>>>,
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<RwLock<HashMap<String, Arc<UserStats>>>>,
access_keys: &Arc<RwLock<HashMap<String, crate::api::UserMeta>>>,
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);
}

View File

@ -65,65 +65,21 @@ pub(crate) struct RemoteState {
pub async fn run_server(
bind_addrs: Vec<String>,
access_keys: Vec<String>,
access_keys: Vec<(String, crate::api::UserMeta)>,
outbound: Option<OutboundConfig>,
api_config: Option<ApiConfig>,
fallback_config: Option<FallbackConfig>,
debug: bool,
reality_query: Option<String>,
reality_config: Option<RealityServerConfig>,
config_path: Option<std::path::PathBuf>,
) -> 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<String>,
}
if let Ok(cfg) = serde_json::from_str::<ServerReloadConfig>(&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::<std::net::SocketAddr>()
@ -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<String>, limit_bytes: Option<u64> },
KeyOnly(String),
}
#[derive(serde::Deserialize)]
struct ServerReloadConfig {
mode: String,
#[serde(default)]
access_keys: Vec<ReloadUser>,
}
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::<ServerReloadConfig>(&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<UiCommand>,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, ()>>>,
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
outbound: Option<OutboundConfig>,
debug: bool,
tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
@ -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 => {

View File

@ -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<usize> {
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<usize>
}
#[derive(serde::Deserialize)]
struct KeysResponse {
keys: Vec<String>,
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<Vec<UserStatsSnapshot>>,
}
let body: ApiResponse = resp.json().await?;
if !body.ok {
anyhow::bail!("API returned error ok=false");
}
let keys: Vec<String> = 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)
}

View File

@ -13,7 +13,7 @@ use tracing::info;
pub async fn handle_tcp_connection<S>(
mut stream: S,
peer_addr: SocketAddr,
shared_keys: Arc<StdRwLock<HashMap<String, ()>>>,
shared_keys: Arc<StdRwLock<HashMap<String, crate::api::UserMeta>>>,
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
) -> Result<()>

@ -1 +0,0 @@
Subproject commit 43b4935fd2addc284a5ae8719824652f9063b95d

3
ostp-wiki/README.md Normal file
View File

@ -0,0 +1,3 @@
# OSTP Wiki
This repository contains the documentation and wiki pages for the Ospab Stealth Transport Protocol (OSTP).

149
ostp-wiki/api_endpoints.md Normal file
View File

@ -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://<key>@<host>:<port>?...`
- `Accept: application/json` -> Возвращает полный клиентский JSON-конфиг.

View File

@ -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"`).

View File

@ -191,10 +191,42 @@ impl UnifiedConfig {
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum UserConfig {
Detailed {
access_key: String,
name: Option<String>,
limit_bytes: Option<u64>,
},
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<String> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { name, .. } => name.clone(),
}
}
pub fn limit(&self) -> Option<u64> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { limit_bytes, .. } => limit_bytes.clone(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ServerConfig {
listen: ListenConfig,
access_keys: Vec<String>,
access_keys: Vec<UserConfig>,
reality: Option<RealityServerConfigRaw>,
debug: Option<bool>,
outbound: Option<OutboundConfig>,
@ -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::<serde_json::Value>(&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::<Vec<_>>();
// 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?;

View File

@ -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
}

View File

@ -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
}