mirror of https://github.com/ospab/ostp.git
1016 lines
33 KiB
Rust
1016 lines
33 KiB
Rust
//! Management REST API for OSTP server.
|
|
//!
|
|
//! Provides endpoints for third-party panels (like 3x-ui) to manage users,
|
|
//! query traffic statistics, and control the server.
|
|
//!
|
|
//! ## Endpoints
|
|
//!
|
|
//! - `GET /api/server/status` -- Server status (uptime, sessions, version)
|
|
//! - `GET /api/users` -- List all users with traffic stats
|
|
//! - `GET /api/users/:key` -- Single user stats
|
|
//! - `POST /api/users` -- Create new access key
|
|
//! - `DELETE /api/users/:key` -- Remove access key
|
|
//! - `PUT /api/users/:key/limit` -- Set traffic limit for a user
|
|
//! - `POST /api/users/:key/reset` -- Reset user traffic counters
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::sync::atomic::Ordering;
|
|
use portable_atomic::AtomicU64;
|
|
use std::time::Instant;
|
|
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::{header, StatusCode, Uri},
|
|
response::{IntoResponse},
|
|
routing::{get, post, put},
|
|
Json, Router,
|
|
};
|
|
use rust_embed::RustEmbed;
|
|
use sha2::Digest;
|
|
use serde::{Deserialize, Serialize};
|
|
use tower_http::cors::{Any, CorsLayer};
|
|
|
|
use crate::dispatcher::{UserStats, UserStatsSnapshot};
|
|
use crate::outbound::OutboundRule;
|
|
|
|
// ── Shared state for API handlers ────────────────────────────────────────────
|
|
|
|
/// API server shared state. Held behind Arc for axum handlers.
|
|
#[derive(Clone)]
|
|
pub struct ApiState {
|
|
pub access_keys: Arc<RwLock<HashMap<String, UserMeta>>>,
|
|
pub user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
|
pub start_time: Instant,
|
|
pub session_token: Arc<RwLock<Option<String>>>,
|
|
pub api_token: Option<String>,
|
|
pub webpath: String,
|
|
pub username: String,
|
|
pub password_hash: String,
|
|
/// Server address for subscription links (e.g. "example.com")
|
|
pub server_host: String,
|
|
pub server_port: u16,
|
|
pub config_path: Option<std::path::PathBuf>,
|
|
pub dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
|
pub audit_logs: Arc<RwLock<Vec<AuditLogEntry>>>,
|
|
pub router: std::sync::Arc<crate::router::Router>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AuditLogEntry {
|
|
pub id: String,
|
|
pub time: String,
|
|
#[serde(rename = "eventEn")]
|
|
pub event_en: String,
|
|
#[serde(rename = "eventRu")]
|
|
pub event_ru: String,
|
|
pub success: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateAuditLogRequest {
|
|
#[serde(rename = "eventEn")]
|
|
pub event_en: String,
|
|
#[serde(rename = "eventRu")]
|
|
pub event_ru: String,
|
|
pub success: bool,
|
|
}
|
|
|
|
// ── API configuration ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ApiConfig {
|
|
pub enabled: bool,
|
|
pub bind: String,
|
|
pub token: Option<String>,
|
|
#[serde(default)]
|
|
pub webpath: String,
|
|
#[serde(default)]
|
|
pub username: String,
|
|
#[serde(default)]
|
|
pub password_hash: String,
|
|
}
|
|
|
|
impl Default for ApiConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
bind: "127.0.0.1:9090".to_string(),
|
|
token: None,
|
|
webpath: String::new(),
|
|
username: String::new(),
|
|
password_hash: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct UserMeta {
|
|
pub name: Option<String>,
|
|
pub limit_bytes: Option<u64>,
|
|
}
|
|
|
|
// ── Request/Response types ───────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct ServerStatus {
|
|
version: &'static str,
|
|
uptime_seconds: u64,
|
|
active_users: usize,
|
|
total_users: usize,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetLimitRequest {
|
|
pub limit_bytes: Option<u64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LoginRequest {
|
|
pub username: String,
|
|
pub password: Option<String>, // We'll accept raw password and hash it
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LoginResponse {
|
|
pub token: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ApiResponse<T: Serialize> {
|
|
ok: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
data: Option<T>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<String>,
|
|
}
|
|
|
|
impl<T: Serialize> ApiResponse<T> {
|
|
fn success(data: T) -> Json<Self> {
|
|
Json(Self { ok: true, data: Some(data), error: None })
|
|
}
|
|
}
|
|
|
|
fn api_error<T: Serialize>(msg: &str) -> (StatusCode, Json<ApiResponse<T>>) {
|
|
(StatusCode::BAD_REQUEST, Json(ApiResponse { ok: false, data: None, error: Some(msg.to_string()) }))
|
|
}
|
|
|
|
fn api_unauthorized<T: Serialize>() -> (StatusCode, Json<ApiResponse<T>>) {
|
|
(StatusCode::UNAUTHORIZED, Json(ApiResponse { ok: false, data: None, error: Some("unauthorized".to_string()) }))
|
|
}
|
|
|
|
#[derive(RustEmbed)]
|
|
#[folder = "../ostp-control/dist/"]
|
|
struct Assets;
|
|
|
|
async fn static_handler(State(state): State<ApiState>, uri: Uri) -> impl IntoResponse {
|
|
let mut path = uri.path();
|
|
|
|
let webpath = state.webpath.trim_matches('/');
|
|
let prefix = if webpath.is_empty() {
|
|
"/panel".to_string()
|
|
} else {
|
|
format!("/{}", webpath)
|
|
};
|
|
|
|
if path.starts_with(&prefix) {
|
|
path = &path[prefix.len()..];
|
|
}
|
|
path = path.trim_start_matches('/');
|
|
|
|
if path.is_empty() || path == "index.html" {
|
|
path = "index.html";
|
|
}
|
|
|
|
match Assets::get(path) {
|
|
Some(content) => {
|
|
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
|
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
|
|
}
|
|
None => {
|
|
if let Some(index) = Assets::get("index.html") {
|
|
([(header::CONTENT_TYPE, "text/html")], index.data).into_response()
|
|
} else {
|
|
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── API router ───────────────────────────────────────────────────────────────
|
|
|
|
pub fn create_api_router(state: ApiState) -> Router {
|
|
let cors = CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any);
|
|
|
|
let api_router = Router::new()
|
|
.route("/server/status", get(handle_status))
|
|
.route("/server/config", get(handle_get_config).put(handle_put_config))
|
|
.route(
|
|
"/users",
|
|
get(handle_list_users).post(handle_create_user),
|
|
)
|
|
.route(
|
|
"/users/{key}",
|
|
get(handle_get_user)
|
|
.put(update_user)
|
|
.delete(delete_user),
|
|
)
|
|
.route("/users/{key}/limit", put(handle_set_limit))
|
|
.route("/users/{key}/reset", post(handle_reset_stats))
|
|
.route("/subscribe/{key}", get(handle_subscribe))
|
|
.route("/login", post(handle_login))
|
|
.route(
|
|
"/audit",
|
|
get(handle_get_audit)
|
|
.post(handle_create_audit)
|
|
.delete(handle_clear_audit),
|
|
)
|
|
.route("/users/bulk", post(handle_bulk_create_users))
|
|
.route("/router/rules", get(handle_get_rules).put(handle_put_rules));
|
|
|
|
let webpath = state.webpath.clone();
|
|
let webpath = webpath.trim_matches('/');
|
|
|
|
let base_route = if webpath.is_empty() {
|
|
"/panel".to_string()
|
|
} else {
|
|
format!("/{}", webpath)
|
|
};
|
|
|
|
let redirect_target = format!("{}/", base_route);
|
|
let redirect_route = base_route.clone();
|
|
|
|
Router::new()
|
|
// Exact /{webpath} → redirect to /{webpath}/ (so relative asset paths work)
|
|
.route(&redirect_route, get(move || {
|
|
let target = redirect_target.clone();
|
|
async move { axum::response::Redirect::permanent(&target) }
|
|
}))
|
|
// /{webpath}/ and /{webpath}/** → serve embedded static files
|
|
.route(&format!("{}/", base_route), get(static_handler.clone()))
|
|
.route(&format!("{}/{{*path}}", base_route), get(static_handler))
|
|
// /{webpath}/api/* → API handlers
|
|
.nest(&format!("{}/api", base_route), api_router)
|
|
.layer(cors)
|
|
.with_state(state)
|
|
}
|
|
|
|
/// Start the Management API server on the configured bind address.
|
|
pub async fn start_api_server(
|
|
config: ApiConfig,
|
|
access_keys: Arc<RwLock<HashMap<String, UserMeta>>>,
|
|
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
|
server_host: String,
|
|
server_port: u16,
|
|
config_path: Option<std::path::PathBuf>,
|
|
dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
|
router: std::sync::Arc<crate::router::Router>,
|
|
) {
|
|
let state = ApiState {
|
|
access_keys,
|
|
user_stats,
|
|
start_time: Instant::now(),
|
|
session_token: Arc::new(RwLock::new(None)),
|
|
api_token: config.token.clone(),
|
|
webpath: config.webpath.clone(),
|
|
username: config.username.clone(),
|
|
password_hash: config.password_hash.clone(),
|
|
server_host,
|
|
server_port,
|
|
config_path,
|
|
dns_server,
|
|
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
|
router,
|
|
};
|
|
|
|
let app = create_api_router(state);
|
|
|
|
let listener = match tokio::net::TcpListener::bind(&config.bind).await {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
tracing::error!("Management API failed to bind on {}: {}", config.bind, e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
tracing::info!("Management API listening on {}", config.bind);
|
|
|
|
if let Err(e) = axum::serve(listener, app).await {
|
|
tracing::error!("Management API error: {}", e);
|
|
}
|
|
}
|
|
|
|
// ── Middleware: token check ──────────────────────────────────────────────────
|
|
|
|
fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
|
|
// Both session token (for web UI) and static API token (for relays) are checked
|
|
let mut allowed = false;
|
|
|
|
// If no credentials configured, panel is open (unsafe but possible)
|
|
if state.username.is_empty() && state.password_hash.is_empty() && state.api_token.is_none() {
|
|
return true;
|
|
}
|
|
|
|
if let Some(value) = headers.get("authorization") {
|
|
if let Ok(val) = value.to_str() {
|
|
if let Some(token) = val.strip_prefix("Bearer ") {
|
|
let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone();
|
|
if let Some(session) = current_session {
|
|
if token == session {
|
|
allowed = true;
|
|
}
|
|
}
|
|
|
|
if let Some(ref api_tok) = state.api_token {
|
|
if token == api_tok {
|
|
allowed = true;
|
|
}
|
|
}
|
|
} else {
|
|
if let Some(ref api_tok) = state.api_token {
|
|
if val == api_tok {
|
|
allowed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
allowed
|
|
}
|
|
|
|
async fn handle_login(
|
|
State(state): State<ApiState>,
|
|
Json(payload): Json<LoginRequest>,
|
|
) -> impl IntoResponse {
|
|
if state.username.is_empty() || state.password_hash.is_empty() {
|
|
return api_error("Auth not configured");
|
|
}
|
|
|
|
if payload.username != state.username {
|
|
return api_unauthorized::<LoginResponse>();
|
|
}
|
|
|
|
let password = payload.password.unwrap_or_default();
|
|
let hash = sha2::Sha256::digest(password.as_bytes());
|
|
let hash_hex = format!("{:x}", hash);
|
|
|
|
if hash_hex == state.password_hash {
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
*state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone());
|
|
(StatusCode::OK, ApiResponse::success(LoginResponse { token }))
|
|
} else {
|
|
api_unauthorized::<LoginResponse>()
|
|
}
|
|
}
|
|
|
|
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_or_else(|e| e.into_inner());
|
|
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,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<ServerStatus>();
|
|
}
|
|
|
|
let keys = state.access_keys.read().unwrap_or_else(|e| e.into_inner());
|
|
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
|
let online = stats.values()
|
|
.filter(|us| {
|
|
let total = us.bytes_up.load(Ordering::Relaxed) + us.bytes_down.load(Ordering::Relaxed);
|
|
total > 0
|
|
})
|
|
.count();
|
|
|
|
let status = ServerStatus {
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
uptime_seconds: state.start_time.elapsed().as_secs(),
|
|
active_users: online,
|
|
total_users: keys.len(),
|
|
};
|
|
|
|
(StatusCode::OK, ApiResponse::success(status))
|
|
}
|
|
|
|
async fn handle_list_users(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<Vec<UserStatsSnapshot>>();
|
|
}
|
|
|
|
let keys = state.access_keys.read().unwrap_or_else(|e| e.into_inner());
|
|
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
|
|
|
let mut users: Vec<UserStatsSnapshot> = keys.iter().map(|(key, meta)| {
|
|
if let Some(st) = stats.get(key) {
|
|
UserStatsSnapshot {
|
|
access_key: key.clone(),
|
|
name: meta.name.clone(),
|
|
bytes_up: st.bytes_up.load(Ordering::Relaxed),
|
|
bytes_down: st.bytes_down.load(Ordering::Relaxed),
|
|
connections: st.connections.load(Ordering::Relaxed),
|
|
limit_bytes: st.limit_bytes,
|
|
online: st.connections.load(Ordering::Relaxed) > 0,
|
|
last_seen: None,
|
|
}
|
|
} else {
|
|
UserStatsSnapshot {
|
|
access_key: key.clone(),
|
|
name: meta.name.clone(),
|
|
bytes_up: 0,
|
|
bytes_down: 0,
|
|
connections: 0,
|
|
limit_bytes: meta.limit_bytes,
|
|
online: false,
|
|
last_seen: None,
|
|
}
|
|
}
|
|
}).collect();
|
|
|
|
users.sort_by(|a, b| b.bytes_down.cmp(&a.bytes_down));
|
|
|
|
(StatusCode::OK, ApiResponse::success(users))
|
|
}
|
|
|
|
async fn handle_get_user(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
Path(key): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<UserStatsSnapshot>();
|
|
}
|
|
|
|
let keys = state.access_keys.read().unwrap_or_else(|e| e.into_inner());
|
|
let meta = match keys.get(&key) {
|
|
Some(m) => m.clone(),
|
|
None => return api_error("user not found"),
|
|
};
|
|
|
|
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
|
let snapshot = if let Some(st) = stats.get(&key) {
|
|
UserStatsSnapshot {
|
|
access_key: key.clone(),
|
|
name: meta.name.clone(),
|
|
bytes_up: st.bytes_up.load(Ordering::Relaxed),
|
|
bytes_down: st.bytes_down.load(Ordering::Relaxed),
|
|
connections: st.connections.load(Ordering::Relaxed),
|
|
limit_bytes: st.limit_bytes,
|
|
online: st.connections.load(Ordering::Relaxed) > 0,
|
|
last_seen: None,
|
|
}
|
|
} else {
|
|
UserStatsSnapshot {
|
|
access_key: key.clone(),
|
|
name: meta.name.clone(),
|
|
bytes_up: 0,
|
|
bytes_down: 0,
|
|
connections: 0,
|
|
limit_bytes: meta.limit_bytes,
|
|
online: false,
|
|
last_seen: None,
|
|
}
|
|
};
|
|
|
|
(StatusCode::OK, ApiResponse::success(snapshot))
|
|
}
|
|
|
|
async fn handle_create_user(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(body): Json<CreateUserRequest>,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<String>();
|
|
}
|
|
|
|
let key = body.access_key.unwrap_or_else(|| {
|
|
use rand::RngCore;
|
|
let mut buf = [0u8; 16];
|
|
rand::thread_rng().fill_bytes(&mut buf);
|
|
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
|
});
|
|
|
|
{
|
|
let mut keys = state.access_keys.write().unwrap_or_else(|e| e.into_inner());
|
|
keys.insert(key.clone(), UserMeta { name: body.name.clone(), limit_bytes: body.limit_bytes });
|
|
}
|
|
|
|
let mut stats = state.user_stats.write().unwrap_or_else(|e| e.into_inner());
|
|
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 delete_user(
|
|
State(state): State<ApiState>,
|
|
Path(key): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<String>();
|
|
}
|
|
|
|
{
|
|
let mut keys = state.access_keys.write().unwrap_or_else(|e| e.into_inner());
|
|
if keys.remove(&key).is_none() {
|
|
return api_error::<String>("User not found");
|
|
}
|
|
}
|
|
|
|
{
|
|
let mut stats = state.user_stats.write().unwrap_or_else(|e| e.into_inner());
|
|
stats.remove(&key);
|
|
}
|
|
|
|
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_or_else(|e| e.into_inner());
|
|
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_or_else(|e| e.into_inner());
|
|
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(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
Path(key): Path<String>,
|
|
Json(body): Json<SetLimitRequest>,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<bool>();
|
|
}
|
|
|
|
{
|
|
let mut keys = state.access_keys.write().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(meta) = keys.get_mut(&key) {
|
|
meta.limit_bytes = body.limit_bytes;
|
|
} else {
|
|
return api_error("user not found");
|
|
}
|
|
}
|
|
|
|
let mut stats = state.user_stats.write().unwrap_or_else(|e| e.into_inner());
|
|
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,
|
|
});
|
|
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))
|
|
}
|
|
|
|
async fn handle_reset_stats(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
Path(key): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) {
|
|
return api_unauthorized::<bool>();
|
|
}
|
|
|
|
let mut stats = state.user_stats.write().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(us) = stats.get(&key) {
|
|
let limit = us.limit_bytes;
|
|
stats.insert(key.clone(), Arc::new(UserStats::new(limit)));
|
|
(StatusCode::OK, ApiResponse::success(true))
|
|
} else {
|
|
api_error("user not found")
|
|
}
|
|
}
|
|
|
|
// ── Subscription endpoint ────────────────────────────────────────────────────
|
|
|
|
/// Returns a ready-to-use client configuration for the given access key.
|
|
/// No Bearer token required -- the access key itself authenticates the request.
|
|
/// Compatible with subscription managers (sub-store, NekoBox, custom panels).
|
|
///
|
|
/// GET /api/subscribe/{key}
|
|
/// Response: JSON client config or ostp:// share link (via Accept header)
|
|
|
|
async fn handle_subscribe(
|
|
State(state): State<ApiState>,
|
|
Path(key): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> axum::response::Response {
|
|
use axum::response::IntoResponse;
|
|
|
|
// Validate that the key exists in a tightly scoped block to drop the guard
|
|
let key_exists = {
|
|
let keys = state.access_keys.read().unwrap_or_else(|e| e.into_inner());
|
|
keys.contains_key(&key)
|
|
};
|
|
|
|
if !key_exists {
|
|
return (StatusCode::NOT_FOUND, Json(serde_json::json!({
|
|
"ok": false,
|
|
"error": "invalid access key"
|
|
}))).into_response();
|
|
}
|
|
|
|
let accept = headers.get("accept")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("application/json");
|
|
|
|
// If client requests plain text, return ostp:// share link
|
|
if accept.contains("text/plain") {
|
|
let dns_enabled = state.dns_server.config.read().await.enabled;
|
|
let rq = if dns_enabled {
|
|
"?type=udp&owndns=true".to_string()
|
|
} else {
|
|
"?type=udp".to_string()
|
|
};
|
|
let link = format!("ostp://{}@{}:{}{}", key, state.server_host, state.server_port, rq);
|
|
return (StatusCode::OK, Json(serde_json::json!({
|
|
"ok": true,
|
|
"data": link
|
|
}))).into_response();
|
|
}
|
|
|
|
// Default: return full client config JSON
|
|
let config = serde_json::json!({
|
|
"mode": "client",
|
|
"server": format!("{}:{}", state.server_host, state.server_port),
|
|
"access_key": key,
|
|
"socks5_bind": "127.0.0.1:1088",
|
|
"tun": {
|
|
"enable": false,
|
|
"dns": "1.1.1.1"
|
|
},
|
|
"exclude": {
|
|
"domains": [],
|
|
"ips": [],
|
|
"processes": []
|
|
},
|
|
"turn": {
|
|
"enabled": false
|
|
},
|
|
"mux": {
|
|
"enabled": false,
|
|
"sessions": 1
|
|
},
|
|
"debug": false
|
|
});
|
|
|
|
(StatusCode::OK, Json(serde_json::json!({
|
|
"ok": true,
|
|
"data": config
|
|
}))).into_response()
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_test_state(webpath: &str) -> ApiState {
|
|
ApiState {
|
|
access_keys: Arc::new(RwLock::new(HashMap::new())),
|
|
user_stats: Arc::new(RwLock::new(HashMap::new())),
|
|
start_time: std::time::Instant::now(),
|
|
session_token: Arc::new(RwLock::new(None)),
|
|
api_token: Some("test-token".to_string()),
|
|
webpath: webpath.to_string(),
|
|
username: "admin".to_string(),
|
|
password_hash: "hash".to_string(),
|
|
server_host: "127.0.0.1".to_string(),
|
|
server_port: 50000,
|
|
config_path: None,
|
|
dns_server: crate::dns::DnsServer::new(Default::default()),
|
|
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
|
router: Arc::new(crate::router::Router::new(None, crate::dns::DnsServer::new(Default::default()), false)),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_router_creation() {
|
|
let state = make_test_state("bNAzr8Ss");
|
|
let _router = create_api_router(state);
|
|
}
|
|
|
|
#[test]
|
|
fn test_router_creation_empty_webpath() {
|
|
let state = make_test_state("");
|
|
let _router = create_api_router(state);
|
|
}
|
|
}
|
|
|
|
async fn handle_get_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let logs = state.audit_logs.read().unwrap();
|
|
ApiResponse::success(logs.clone())
|
|
}
|
|
|
|
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse {
|
|
let mut logs = state.audit_logs.write().unwrap();
|
|
let id = format!("{:x}", rand::random::<u64>());
|
|
let now = chrono::Local::now();
|
|
let entry = AuditLogEntry {
|
|
id,
|
|
time: now.format("%H:%M:%S").to_string(),
|
|
event_en: req.event_en,
|
|
event_ru: req.event_ru,
|
|
success: req.success,
|
|
};
|
|
logs.insert(0, entry);
|
|
if logs.len() > 100 {
|
|
logs.truncate(100);
|
|
}
|
|
|
|
ApiResponse::success(true)
|
|
}
|
|
|
|
// ── Bulk keys & Router Rules ─────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BulkCreateRequest {
|
|
pub count: usize,
|
|
pub limit_bytes: Option<u64>,
|
|
}
|
|
|
|
async fn handle_bulk_create_users(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(payload): Json<BulkCreateRequest>,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) { return api_unauthorized(); }
|
|
if payload.count > 1000 {
|
|
return api_error("Count too large, max 1000");
|
|
}
|
|
|
|
let mut new_keys = Vec::new();
|
|
let mut keys = match state.access_keys.write() {
|
|
Ok(k) => k,
|
|
Err(e) => e.into_inner(),
|
|
};
|
|
|
|
for _ in 0..payload.count {
|
|
let key = uuid::Uuid::new_v4().to_string().replace("-", "")[..16].to_string();
|
|
keys.insert(key.clone(), UserMeta {
|
|
name: None,
|
|
limit_bytes: payload.limit_bytes,
|
|
});
|
|
new_keys.push(key);
|
|
}
|
|
// Save keys by dropping lock, then saving to config
|
|
drop(keys);
|
|
let _ = save_config_keys(&state);
|
|
|
|
(StatusCode::OK, ApiResponse::success(new_keys))
|
|
}
|
|
|
|
async fn handle_get_rules(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) { return api_unauthorized(); }
|
|
|
|
let lock = state.router.outbound_cfg.read().unwrap();
|
|
let rules = if let Some(cfg) = lock.as_ref() {
|
|
cfg.rules.clone()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
(StatusCode::OK, ApiResponse::success(rules))
|
|
}
|
|
|
|
async fn handle_put_rules(
|
|
State(state): State<ApiState>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(new_rules): Json<Vec<OutboundRule>>,
|
|
) -> impl IntoResponse {
|
|
if !check_token(&state, &headers) { return api_unauthorized(); }
|
|
|
|
// Update memory
|
|
#[allow(unused_assignments)]
|
|
let mut updated_outbound = None;
|
|
{
|
|
let mut lock = state.router.outbound_cfg.write().unwrap();
|
|
if let Some(cfg) = lock.as_mut() {
|
|
cfg.rules = new_rules.clone();
|
|
updated_outbound = Some(cfg.clone());
|
|
} else {
|
|
return api_error("Outbound routing is not enabled in config");
|
|
}
|
|
}
|
|
|
|
if let Some(cfg) = updated_outbound {
|
|
state.dns_server.update_proxy(Some(&cfg)).await;
|
|
}
|
|
|
|
// Save to config.json
|
|
if let Some(path) = &state.config_path {
|
|
if let Ok(content) = std::fs::read_to_string(path) {
|
|
let mut cfg: serde_json::Value = serde_json::from_str(&content).unwrap_or_default();
|
|
if let Some(obj) = cfg.as_object_mut() {
|
|
if let Some(outbound) = obj.get_mut("outbound") {
|
|
if let Some(outbound_obj) = outbound.as_object_mut() {
|
|
if let Ok(rules_json) = serde_json::to_value(&new_rules) {
|
|
outbound_obj.insert("rules".to_string(), rules_json);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = std::fs::write(path, serde_json::to_string_pretty(&cfg).unwrap_or_default());
|
|
}
|
|
}
|
|
|
|
(StatusCode::OK, ApiResponse::success(true))
|
|
}
|
|
|
|
async fn handle_clear_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let mut logs = state.audit_logs.write().unwrap();
|
|
logs.clear();
|
|
ApiResponse::success(())
|
|
}
|
|
|
|
|