mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 18:24:36 +00:00
introduce uptime monitor for easytier public nodes (#1250)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sea_orm::DbErr),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self {
|
||||
ApiError::Database(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", err),
|
||||
),
|
||||
ApiError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||
ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"error": {
|
||||
"code": status.as_u16(),
|
||||
"message": error_message
|
||||
}
|
||||
});
|
||||
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
impl From<validator::ValidationErrors> for ApiError {
|
||||
fn from(err: validator::ValidationErrors) -> Self {
|
||||
let errors: Vec<String> = err
|
||||
.field_errors()
|
||||
.iter()
|
||||
.map(|(field, errors)| {
|
||||
let error_msgs: Vec<String> = errors
|
||||
.iter()
|
||||
.map(|error| {
|
||||
if let Some(msg) = &error.message {
|
||||
msg.to_string()
|
||||
} else {
|
||||
format!("Validation failed for field: {}", field)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
error_msgs.join(", ")
|
||||
})
|
||||
.collect();
|
||||
|
||||
ApiError::Validation(errors.join("; "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
use std::ops::{Div, Mul};
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
use sea_orm::{
|
||||
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
|
||||
QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::api::{
|
||||
error::{ApiError, ApiResult},
|
||||
models::*,
|
||||
};
|
||||
use crate::db::entity::{self, health_records, shared_nodes};
|
||||
use crate::db::{operations::*, Db};
|
||||
use crate::health_checker_manager::HealthCheckerManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Db,
|
||||
pub health_checker_manager: Arc<HealthCheckerManager>,
|
||||
}
|
||||
|
||||
pub async fn health_check() -> Json<ApiResponse<String>> {
|
||||
Json(ApiResponse::message("Service is healthy".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_nodes(
|
||||
State(app_state): State<AppState>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
Query(filters): Query<NodeFilterParams>,
|
||||
) -> ApiResult<Json<ApiResponse<PaginatedResponse<NodeResponse>>>> {
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let per_page = pagination.per_page.unwrap_or(20);
|
||||
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let mut query = entity::shared_nodes::Entity::find();
|
||||
|
||||
// 普通用户只能看到已审核的节点
|
||||
query = query.filter(entity::shared_nodes::Column::IsApproved.eq(true));
|
||||
|
||||
if let Some(is_active) = filters.is_active {
|
||||
query = query.filter(entity::shared_nodes::Column::IsActive.eq(is_active));
|
||||
}
|
||||
|
||||
if let Some(protocol) = filters.protocol {
|
||||
query = query.filter(entity::shared_nodes::Column::Protocol.eq(protocol));
|
||||
}
|
||||
|
||||
if let Some(search) = filters.search {
|
||||
query = query.filter(
|
||||
sea_orm::Condition::any()
|
||||
.add(entity::shared_nodes::Column::Name.contains(&search))
|
||||
.add(entity::shared_nodes::Column::Host.contains(&search))
|
||||
.add(entity::shared_nodes::Column::Description.contains(&search)),
|
||||
);
|
||||
}
|
||||
|
||||
let total = query.clone().count(app_state.db.orm_db()).await?;
|
||||
let nodes = query
|
||||
.order_by_asc(entity::shared_nodes::Column::Id)
|
||||
.limit(Some(per_page as u64))
|
||||
.offset(Some(offset as u64))
|
||||
.all(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
||||
let total_pages = total.div_ceil(per_page as u64);
|
||||
|
||||
// 为每个节点添加健康状态信息
|
||||
for node_response in &mut node_responses {
|
||||
if let Some(mut health_record) = app_state
|
||||
.health_checker_manager
|
||||
.get_node_memory_record(node_response.id)
|
||||
{
|
||||
node_response.current_health_status =
|
||||
Some(health_record.get_current_health_status().to_string());
|
||||
node_response.last_check_time = Some(health_record.get_last_check_time());
|
||||
node_response.last_response_time = health_record.get_last_response_time();
|
||||
|
||||
// 获取24小时健康统计
|
||||
if let Some(stats) = app_state
|
||||
.health_checker_manager
|
||||
.get_node_health_stats(node_response.id, 24)
|
||||
{
|
||||
node_response.health_percentage_24h = Some(stats.health_percentage);
|
||||
}
|
||||
|
||||
let (total_ring, healthy_ring) = health_record.get_counter_ring();
|
||||
node_response.health_record_total_counter_ring = total_ring;
|
||||
node_response.health_record_healthy_counter_ring = healthy_ring;
|
||||
node_response.ring_granularity = health_record.get_ring_granularity();
|
||||
}
|
||||
}
|
||||
|
||||
// remove sensitive information
|
||||
node_responses.iter_mut().for_each(|node| {
|
||||
tracing::info!("node: {:?}", node);
|
||||
node.network_name = None;
|
||||
node.network_secret = None;
|
||||
|
||||
// make cur connection and max conn round to percentage
|
||||
if node.max_connections != 0 {
|
||||
node.current_connections = node.current_connections.mul(100).div(node.max_connections);
|
||||
node.max_connections = 100;
|
||||
} else {
|
||||
node.current_connections = 0;
|
||||
node.max_connections = 0;
|
||||
}
|
||||
|
||||
node.wechat = None;
|
||||
node.qq_number = None;
|
||||
node.mail = None;
|
||||
});
|
||||
|
||||
Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||
items: node_responses,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
total_pages: total_pages as u32,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn create_node(
|
||||
State(app_state): State<AppState>,
|
||||
Json(request): Json<CreateNodeRequest>,
|
||||
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
|
||||
request.validate()?;
|
||||
|
||||
let node = NodeOperations::create_node(&app_state.db, request).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
|
||||
}
|
||||
|
||||
pub async fn test_connection(
|
||||
State(app_state): State<AppState>,
|
||||
Json(request): Json<CreateNodeRequest>,
|
||||
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
|
||||
let mut node = NodeOperations::create_node_model(request);
|
||||
node.id = Set(0);
|
||||
let node = node.try_into_model()?;
|
||||
app_state
|
||||
.health_checker_manager
|
||||
.test_connection(&node, std::time::Duration::from_secs(5))
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
|
||||
}
|
||||
|
||||
pub async fn get_node(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
|
||||
let node = NodeOperations::get_node_by_id(&app_state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
|
||||
}
|
||||
|
||||
pub async fn get_node_health(
|
||||
State(app_state): State<AppState>,
|
||||
Path(node_id): Path<i32>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
Query(filters): Query<HealthFilterParams>,
|
||||
) -> ApiResult<Json<ApiResponse<PaginatedResponse<HealthRecordResponse>>>> {
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let per_page = pagination.per_page.unwrap_or(20);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let mut query = entity::health_records::Entity::find()
|
||||
.filter(entity::health_records::Column::NodeId.eq(node_id));
|
||||
|
||||
if let Some(status) = filters.status {
|
||||
query = query.filter(entity::health_records::Column::Status.eq(status));
|
||||
}
|
||||
|
||||
if let Some(since) = filters.since {
|
||||
query = query.filter(entity::health_records::Column::CheckedAt.gte(since.naive_utc()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(app_state.db.orm_db()).await?;
|
||||
let records = query
|
||||
.order_by_desc(entity::health_records::Column::CheckedAt)
|
||||
.limit(Some(per_page as u64))
|
||||
.offset(Some(offset as u64))
|
||||
.all(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
let record_responses: Vec<HealthRecordResponse> = records
|
||||
.into_iter()
|
||||
.map(HealthRecordResponse::from)
|
||||
.collect();
|
||||
let total_pages = total.div_ceil(per_page as u64);
|
||||
|
||||
Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||
items: record_responses,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
total_pages: total_pages as u32,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_node_health_stats(
|
||||
State(app_state): State<AppState>,
|
||||
Path(node_id): Path<i32>,
|
||||
Query(params): Query<HealthStatsParams>,
|
||||
) -> ApiResult<Json<ApiResponse<HealthStatsResponse>>> {
|
||||
let hours = params.hours.unwrap_or(24);
|
||||
let stats = HealthOperations::get_health_stats(&app_state.db, node_id, hours).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(HealthStatsResponse::from(stats))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HealthStatsParams {
|
||||
pub hours: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InstanceFilterParams {
|
||||
pub node_id: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
// 管理员相关处理器
|
||||
use crate::config::AppConfig;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AdminClaims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
iat: usize,
|
||||
}
|
||||
|
||||
pub async fn get_node_connect_url(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<String> {
|
||||
let node = NodeOperations::get_node_by_id(&app_state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
|
||||
let connect_url = format!("{}://{}:{}", node.protocol, node.host, node.port);
|
||||
Ok(connect_url)
|
||||
}
|
||||
|
||||
pub async fn admin_login(
|
||||
Json(request): Json<AdminLoginRequest>,
|
||||
) -> ApiResult<Json<ApiResponse<AdminLoginResponse>>> {
|
||||
request
|
||||
.validate()
|
||||
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
|
||||
let config = AppConfig::default();
|
||||
|
||||
if request.password != config.security.admin_password {
|
||||
return Err(ApiError::Unauthorized("Invalid password".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let expires_at = now + Duration::hours(24);
|
||||
|
||||
let claims = AdminClaims {
|
||||
sub: "admin".to_string(),
|
||||
exp: expires_at.timestamp() as usize,
|
||||
iat: now.timestamp() as usize,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(config.security.jwt_secret.as_ref()),
|
||||
)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?;
|
||||
|
||||
Ok(Json(ApiResponse::success(AdminLoginResponse {
|
||||
token,
|
||||
expires_at,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn admin_get_nodes(
|
||||
State(app_state): State<AppState>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
Query(filters): Query<AdminNodeFilterParams>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ApiResponse<PaginatedResponse<NodeResponse>>>> {
|
||||
verify_admin_token(&headers)?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let per_page = pagination.per_page.unwrap_or(20);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let mut query = entity::shared_nodes::Entity::find();
|
||||
|
||||
if let Some(is_active) = filters.is_active {
|
||||
query = query.filter(entity::shared_nodes::Column::IsActive.eq(is_active));
|
||||
}
|
||||
|
||||
if let Some(is_approved) = filters.is_approved {
|
||||
query = query.filter(entity::shared_nodes::Column::IsApproved.eq(is_approved));
|
||||
}
|
||||
|
||||
if let Some(protocol) = filters.protocol {
|
||||
query = query.filter(entity::shared_nodes::Column::Protocol.eq(protocol));
|
||||
}
|
||||
|
||||
if let Some(search) = filters.search {
|
||||
query = query.filter(
|
||||
sea_orm::Condition::any()
|
||||
.add(entity::shared_nodes::Column::Name.contains(&search))
|
||||
.add(entity::shared_nodes::Column::Host.contains(&search))
|
||||
.add(entity::shared_nodes::Column::Description.contains(&search)),
|
||||
);
|
||||
}
|
||||
|
||||
let total = query.clone().count(app_state.db.orm_db()).await?;
|
||||
|
||||
let nodes = query
|
||||
.order_by(entity::shared_nodes::Column::CreatedAt, Order::Desc)
|
||||
.offset(offset as u64)
|
||||
.limit(per_page as u64)
|
||||
.all(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
let node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
||||
|
||||
let total_pages = (total as f64 / per_page as f64).ceil() as u32;
|
||||
|
||||
Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||
items: node_responses,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn admin_approve_node(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
|
||||
verify_admin_token(&headers)?;
|
||||
|
||||
let node = entity::shared_nodes::Entity::find_by_id(id)
|
||||
.one(app_state.db.orm_db())
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?;
|
||||
|
||||
let mut active_model = node.into_active_model();
|
||||
active_model.is_approved = sea_orm::Set(true);
|
||||
|
||||
let updated_node = entity::shared_nodes::Entity::update(active_model)
|
||||
.exec(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
||||
}
|
||||
|
||||
pub async fn admin_update_node(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<UpdateNodeRequest>,
|
||||
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
|
||||
verify_admin_token(&headers)?;
|
||||
request.validate()?;
|
||||
|
||||
let mut node = NodeOperations::get_node_by_id(&app_state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
|
||||
|
||||
let mut node = node.into_active_model();
|
||||
|
||||
if let Some(name) = request.name {
|
||||
node.name = Set(name);
|
||||
}
|
||||
if let Some(host) = request.host {
|
||||
node.host = Set(host);
|
||||
}
|
||||
if let Some(port) = request.port {
|
||||
node.port = Set(port);
|
||||
}
|
||||
if let Some(protocol) = request.protocol {
|
||||
node.protocol = Set(protocol);
|
||||
}
|
||||
if let Some(description) = request.description {
|
||||
node.description = Set(description);
|
||||
}
|
||||
if let Some(max_connections) = request.max_connections {
|
||||
node.max_connections = Set(max_connections);
|
||||
}
|
||||
if let Some(is_active) = request.is_active {
|
||||
node.is_active = Set(is_active);
|
||||
}
|
||||
if let Some(allow_relay) = request.allow_relay {
|
||||
node.allow_relay = Set(allow_relay);
|
||||
}
|
||||
if let Some(network_name) = request.network_name {
|
||||
node.network_name = Set(network_name);
|
||||
}
|
||||
if let Some(network_secret) = request.network_secret {
|
||||
node.network_secret = Set(network_secret);
|
||||
}
|
||||
if let Some(wechat) = request.wechat {
|
||||
node.wechat = Set(wechat);
|
||||
}
|
||||
if let Some(mail) = request.mail {
|
||||
node.mail = Set(mail);
|
||||
}
|
||||
if let Some(qq_number) = request.qq_number {
|
||||
node.qq_number = Set(qq_number);
|
||||
}
|
||||
|
||||
node.updated_at = Set(chrono::Utc::now().fixed_offset());
|
||||
|
||||
tracing::info!("updated node: {:?}", node);
|
||||
|
||||
let updated_node = entity::shared_nodes::Entity::update(node)
|
||||
.exec(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
||||
}
|
||||
|
||||
pub async fn admin_revoke_approval(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
|
||||
verify_admin_token(&headers)?;
|
||||
|
||||
let node = entity::shared_nodes::Entity::find_by_id(id)
|
||||
.one(app_state.db.orm_db())
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?;
|
||||
|
||||
let mut active_model = node.into_active_model();
|
||||
active_model.is_approved = sea_orm::Set(false);
|
||||
|
||||
let updated_node = entity::shared_nodes::Entity::update(active_model)
|
||||
.exec(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
||||
}
|
||||
|
||||
pub async fn admin_delete_node(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ApiResponse<String>>> {
|
||||
verify_admin_token(&headers)?;
|
||||
|
||||
let node = entity::shared_nodes::Entity::find_by_id(id)
|
||||
.one(app_state.db.orm_db())
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?;
|
||||
|
||||
node.delete(app_state.db.orm_db()).await?;
|
||||
|
||||
Ok(Json(ApiResponse::message(
|
||||
"Node deleted successfully".to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn admin_verify_token(headers: HeaderMap) -> ApiResult<Json<ApiResponse<String>>> {
|
||||
verify_admin_token(&headers)?;
|
||||
Ok(Json(ApiResponse::message("Token is valid".to_string())))
|
||||
}
|
||||
|
||||
fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> {
|
||||
let config = AppConfig::default();
|
||||
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing authorization header".to_string()))?;
|
||||
|
||||
let auth_str = auth_header
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
|
||||
|
||||
let token = auth_str
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid authorization format".to_string()))?;
|
||||
|
||||
let _claims = decode::<AdminClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.security.jwt_secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
|
||||
pub use error::{ApiError, ApiResult};
|
||||
pub use handlers::*;
|
||||
pub use models::*;
|
||||
@@ -0,0 +1,316 @@
|
||||
use crate::db::entity;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub error: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> ApiResponse<T> {
|
||||
pub fn success(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(error: String) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(message: String) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: None,
|
||||
error: None,
|
||||
message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub per_page: u32,
|
||||
pub total_pages: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<u32>,
|
||||
pub per_page: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for PaginationParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: Some(1),
|
||||
per_page: Some(20),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Validate)]
|
||||
#[validate(schema(function = "validate_contact_info", skip_on_field_errors = false))]
|
||||
pub struct CreateNodeRequest {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: String,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub host: String,
|
||||
|
||||
#[validate(range(min = 1, max = 65535))]
|
||||
pub port: i32,
|
||||
|
||||
#[validate(length(min = 1, max = 20))]
|
||||
pub protocol: String,
|
||||
|
||||
#[validate(length(max = 500))]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[validate(range(min = 1, max = 10000))]
|
||||
pub max_connections: i32,
|
||||
|
||||
pub allow_relay: bool,
|
||||
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub network_name: String,
|
||||
|
||||
#[validate(length(max = 100))]
|
||||
pub network_secret: Option<String>,
|
||||
|
||||
// 联系方式字段
|
||||
#[validate(length(max = 20))]
|
||||
pub qq_number: Option<String>,
|
||||
|
||||
#[validate(length(max = 50))]
|
||||
pub wechat: Option<String>,
|
||||
|
||||
#[validate(email)]
|
||||
pub mail: Option<String>,
|
||||
}
|
||||
|
||||
// 自定义验证函数:确保至少填写一种联系方式
|
||||
fn validate_contact_info(request: &CreateNodeRequest) -> Result<(), validator::ValidationError> {
|
||||
let has_qq = request
|
||||
.qq_number
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.trim().is_empty());
|
||||
let has_wechat = request
|
||||
.wechat
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.trim().is_empty());
|
||||
let has_mail = request.mail.as_ref().is_some_and(|s| !s.trim().is_empty());
|
||||
|
||||
if !has_qq && !has_wechat && !has_mail {
|
||||
return Err(validator::ValidationError::new("contact_required"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Validate)]
|
||||
pub struct UpdateNodeRequest {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub host: Option<String>,
|
||||
|
||||
#[validate(range(min = 1, max = 65535))]
|
||||
pub port: Option<i32>,
|
||||
|
||||
#[validate(length(min = 1, max = 20))]
|
||||
pub protocol: Option<String>,
|
||||
|
||||
#[validate(length(max = 500))]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[validate(range(min = 1, max = 10000))]
|
||||
pub max_connections: Option<i32>,
|
||||
|
||||
pub is_active: Option<bool>,
|
||||
|
||||
pub allow_relay: Option<bool>,
|
||||
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub network_name: Option<String>,
|
||||
|
||||
#[validate(length(max = 100))]
|
||||
pub network_secret: Option<String>,
|
||||
|
||||
// 联系方式字段
|
||||
#[validate(length(max = 20))]
|
||||
pub qq_number: Option<String>,
|
||||
|
||||
#[validate(length(max = 50))]
|
||||
pub wechat: Option<String>,
|
||||
|
||||
#[validate(email)]
|
||||
pub mail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NodeResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub protocol: String,
|
||||
pub version: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub max_connections: i32,
|
||||
pub current_connections: i32,
|
||||
pub is_active: bool,
|
||||
pub is_approved: bool,
|
||||
pub allow_relay: bool,
|
||||
pub network_name: Option<String>,
|
||||
pub network_secret: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub address: String,
|
||||
pub usage_percentage: f64,
|
||||
// 健康状态相关字段
|
||||
pub current_health_status: Option<String>,
|
||||
pub last_check_time: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub last_response_time: Option<i32>,
|
||||
pub health_percentage_24h: Option<f64>,
|
||||
|
||||
pub health_record_total_counter_ring: Vec<u64>,
|
||||
pub health_record_healthy_counter_ring: Vec<u64>,
|
||||
pub ring_granularity: u32,
|
||||
|
||||
// 联系方式字段
|
||||
pub qq_number: Option<String>,
|
||||
pub wechat: Option<String>,
|
||||
pub mail: Option<String>,
|
||||
}
|
||||
|
||||
impl From<entity::shared_nodes::Model> for NodeResponse {
|
||||
fn from(node: entity::shared_nodes::Model) -> Self {
|
||||
Self {
|
||||
id: node.id,
|
||||
name: node.name.clone(),
|
||||
host: node.host.clone(),
|
||||
port: node.port,
|
||||
protocol: node.protocol.clone(),
|
||||
version: Some(node.version.clone()),
|
||||
description: Some(node.description.clone()),
|
||||
max_connections: node.max_connections,
|
||||
current_connections: node.current_connections,
|
||||
is_active: node.is_active,
|
||||
is_approved: node.is_approved,
|
||||
allow_relay: node.allow_relay,
|
||||
network_name: Some(node.network_name.clone()),
|
||||
network_secret: Some(node.network_secret.clone()),
|
||||
created_at: node.created_at.into(),
|
||||
updated_at: node.updated_at.into(),
|
||||
address: format!("{}://{}:{}", node.protocol, node.host, node.port),
|
||||
usage_percentage: node.current_connections as f64 / node.max_connections as f64 * 100.0,
|
||||
// 健康状态字段初始化为 None,将在 handlers 中填充
|
||||
current_health_status: None,
|
||||
last_check_time: None,
|
||||
last_response_time: None,
|
||||
health_percentage_24h: None,
|
||||
|
||||
health_record_healthy_counter_ring: Vec::new(),
|
||||
health_record_total_counter_ring: Vec::new(),
|
||||
ring_granularity: 0,
|
||||
|
||||
// 联系方式字段
|
||||
qq_number: if node.qq_number.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(node.qq_number)
|
||||
},
|
||||
wechat: if node.wechat.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(node.wechat)
|
||||
},
|
||||
mail: if node.mail.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(node.mail)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct HealthRecordResponse {
|
||||
pub id: i32,
|
||||
pub node_id: i32,
|
||||
pub status: String,
|
||||
pub response_time: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
pub checked_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<entity::health_records::Model> for HealthRecordResponse {
|
||||
fn from(record: entity::health_records::Model) -> Self {
|
||||
Self {
|
||||
id: record.id,
|
||||
node_id: record.node_id,
|
||||
status: record.status.to_string(),
|
||||
response_time: Some(record.response_time),
|
||||
error_message: Some(record.error_message),
|
||||
checked_at: record.checked_at.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type HealthStatsResponse = crate::db::HealthStats;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NodeFilterParams {
|
||||
pub is_active: Option<bool>,
|
||||
pub protocol: Option<String>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct HealthFilterParams {
|
||||
pub status: Option<String>,
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// 管理员相关模型
|
||||
#[derive(Debug, Serialize, Deserialize, Validate)]
|
||||
pub struct AdminLoginRequest {
|
||||
#[validate(length(min = 1))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AdminLoginResponse {
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApproveNodeRequest {
|
||||
pub approved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AdminNodeFilterParams {
|
||||
pub is_active: Option<bool>,
|
||||
pub is_approved: Option<bool>,
|
||||
pub protocol: Option<String>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::Router;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use super::handlers::AppState;
|
||||
use super::handlers::{
|
||||
admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval,
|
||||
admin_update_node, admin_verify_token, create_node, get_node, get_node_health,
|
||||
get_node_health_stats, get_nodes, health_check,
|
||||
};
|
||||
use crate::api::{get_node_connect_url, test_connection};
|
||||
use crate::config::AppConfig;
|
||||
use crate::db::Db;
|
||||
|
||||
pub fn create_routes() -> Router<AppState> {
|
||||
let config = AppConfig::default();
|
||||
|
||||
let compression_layer = if config.security.enable_compression {
|
||||
Some(
|
||||
CompressionLayer::new()
|
||||
.br(true)
|
||||
.deflate(true)
|
||||
.gzip(true)
|
||||
.zstd(true),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cors_layer = if config.cors.enabled {
|
||||
Some(CorsLayer::very_permissive())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut router = Router::new()
|
||||
.route("/node/{id}", get(get_node_connect_url))
|
||||
.route("/health", get(health_check))
|
||||
.route("/api/nodes", get(get_nodes).post(create_node))
|
||||
.route("/api/test_connection", post(test_connection))
|
||||
.route("/api/nodes/{id}/health", get(get_node_health))
|
||||
.route("/api/nodes/{id}/health/stats", get(get_node_health_stats))
|
||||
// 管理员路由
|
||||
.route("/api/admin/login", post(admin_login))
|
||||
.route("/api/admin/verify", get(admin_verify_token))
|
||||
.route("/api/admin/nodes", get(admin_get_nodes))
|
||||
.route("/api/admin/nodes/{id}/approve", put(admin_approve_node))
|
||||
.route("/api/admin/nodes/{id}/revoke", put(admin_revoke_approval))
|
||||
.route(
|
||||
"/api/admin/nodes/{id}",
|
||||
put(admin_update_node).delete(admin_delete_node),
|
||||
);
|
||||
|
||||
if let Some(layer) = compression_layer {
|
||||
router = router.layer(layer);
|
||||
}
|
||||
|
||||
if let Some(layer) = cors_layer {
|
||||
router = router.layer(layer);
|
||||
}
|
||||
|
||||
router
|
||||
}
|
||||
Reference in New Issue
Block a user