Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 73 additions & 23 deletions clash-lib/src/app/api/handlers/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ pub fn routes(

async fn get_configs(State(state): State<ConfigState>) -> impl IntoResponse {
let run_mode = state.dispatcher.get_mode().await;
let log_level = {
let (log_level, config_path) = {
let global_state = state.global_state.lock().await;
global_state.log_level
(global_state.log_level, global_state.config_path.clone())
};
let inbound_manager = state.inbound_manager.clone();

Expand Down Expand Up @@ -134,6 +134,7 @@ async fn get_configs(State(state): State<ConfigState>) -> impl IntoResponse {
listeners: Some(listeners),
lan_ips,
dns_listen,
config_path,
})
}

Expand Down Expand Up @@ -162,8 +163,15 @@ async fn update_configs(
let cfg = crate::Config::Str(payload);
match g.reload_tx.send((cfg, done)).await {
Ok(_) => {
wait.await.unwrap();
(StatusCode::NO_CONTENT, msg).into_response()
drop(g);
match wait.await {
Ok(_) => (StatusCode::NO_CONTENT, msg).into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"config reload failed",
)
.into_response(),
}
}
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Expand All @@ -172,39 +180,71 @@ async fn update_configs(
.into_response(),
}
}
(Some(mut path), None) => {
if !PathBuf::from(&path).is_absolute() {
path = PathBuf::from(g.cwd.clone())
.join(path)
.to_string_lossy()
.to_string();
// Empty string path means "reload from the original config file".
(path, None) => {
let resolved = match path.as_deref() {
Some("") | None => {
// Use the path the binary was started with, if available.
match &g.config_path {
Some(p) => p.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
"no config path provided and no original config \
file known",
)
.into_response();
}
}
}
Some(p) => {
let mut resolved = p.to_string();
if !PathBuf::from(&resolved).is_absolute() {
resolved = PathBuf::from(g.cwd.clone())
.join(resolved)
.to_string_lossy()
.to_string();
}
resolved
}
};

if !PathBuf::from(&resolved).exists() {
return (
StatusCode::BAD_REQUEST,
format!("config file {resolved} not found"),
)
.into_response();
}
if !PathBuf::from(&path).exists() {
if !PathBuf::from(&resolved).is_file() {
return (
StatusCode::BAD_REQUEST,
format!("config file {path} not found"),
format!("{resolved} is not a file"),
)
.into_response();
}

let msg = format!("config reloading from file {path}");
let cfg: crate::Config = crate::Config::File(path);
let msg = format!("config reloading from file {resolved}");
let cfg: crate::Config = crate::Config::File(resolved);
match g.reload_tx.send((cfg, done)).await {
Ok(_) => {
wait.await.unwrap();
(StatusCode::NO_CONTENT, msg).into_response()
drop(g);
match wait.await {
Ok(_) => (StatusCode::NO_CONTENT, msg).into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"config reload failed",
)
.into_response(),
}
}

Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"could not signal config reload",
)
.into_response(),
}
}
(None, None) => {
(StatusCode::BAD_REQUEST, "no path or payload provided").into_response()
}
}
}

Expand All @@ -227,6 +267,8 @@ struct GetConfigResponse {
lan_ips: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
dns_listen: Option<DnsListenInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
config_path: Option<String>,
}

#[derive(Serialize, Deserialize)]
Expand Down Expand Up @@ -277,6 +319,7 @@ async fn patch_configs(
}
}

let mut port_changed = false;
if payload.rebuild_listeners() {
let ports = Ports {
port: payload.port,
Expand All @@ -285,8 +328,8 @@ async fn patch_configs(
tproxy_port: payload.tproxy_port,
mixed_port: payload.mixed_port,
};
let changed = inbound_manager.change_ports(ports).await;
need_restart |= changed;
port_changed = inbound_manager.change_ports(ports).await;
need_restart |= port_changed;
}

if let Some(allow_lan) = payload.allow_lan
Expand All @@ -296,6 +339,7 @@ async fn patch_configs(
// TODO: can be done with AtomicBool in each inbound manager, but requires
// more changes
need_restart = true;
port_changed = false; // force full restart
}

// Apply mode change before restarting listeners so that new connections
Expand All @@ -305,7 +349,13 @@ async fn patch_configs(
}

if need_restart {
let _ = inbound_manager.restart().await;
if port_changed {
// Port-only change: restart only the affected listener(s).
// Unchanged listeners keep running — no EADDRINUSE.
let _ = inbound_manager.restart_idle().await;
} else {
let _ = inbound_manager.restart().await;
}
}

if let Some(ipv6) = payload.ipv6 {
Expand Down
30 changes: 19 additions & 11 deletions clash-lib/src/app/api/handlers/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,26 @@ pub async fn handle(
})
.on_upgrade(move |mut socket| async move {
let mut rx = state.log_source_tx.subscribe();
while let Ok(evt) = rx.recv().await {
let res_str = match serde_json::to_string(&evt) {
Ok(s) => s,
Err(e) => {
warn!("Failed to serialize log event: {}", e);
continue; // Skip this event but keep the connection open
loop {
match rx.recv().await {
Ok(evt) => {
let res_str = match serde_json::to_string(&evt) {
Ok(s) => s,
Err(e) => {
warn!("Failed to serialize log event: {}", e);
continue;
}
};
if let Err(e) = socket.send(Message::Text(res_str.into())).await
{
warn!("ws send error: {}", e);
break;
}
}
};

if let Err(e) = socket.send(Message::Text(res_str.into())).await {
warn!("ws send error: {}", e);
break;
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("log ws: receiver lagged {} messages", n);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
Expand Down
154 changes: 152 additions & 2 deletions clash-lib/src/app/api/handlers/provider.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr, SocketAddr},
str::FromStr,
sync::Arc,
time::Duration,
};

use axum::{
Extension, Router,
Expand All @@ -8,14 +14,16 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};

use crate::{
app::{
api::AppState, outbound::manager::ThreadSafeOutboundManager,
remote_content_manager::providers::proxy_provider::ThreadSafeProxyProvider,
router::ArcRouter,
},
proxy::AnyOutboundHandler,
session::{Network, Session, SocksAddr, Type},
};
#[derive(Clone)]
struct ProviderState {
Expand Down Expand Up @@ -198,3 +206,145 @@ async fn get_proxy_delay(
.into_response(),
}
}

// ── Rule provider routes ────────────────────────────────────────────────────

#[derive(Clone)]
struct RuleProviderState {
router: ArcRouter,
}

pub fn rule_routes(router: ArcRouter) -> Router<Arc<AppState>> {
let state = RuleProviderState { router };
Router::new()
.route("/", get(get_rule_providers))
.route(
"/{provider_name}",
get(get_rule_provider).put(update_rule_provider),
)
.route("/{provider_name}/rules", get(get_rule_provider_rules))
.route("/{provider_name}/match", get(match_rule_provider))
.with_state(state)
}

async fn get_rule_providers(
State(state): State<RuleProviderState>,
) -> impl IntoResponse {
let mut providers = HashMap::new();
for (name, p) in state.router.get_rule_providers() {
providers.insert(name.clone(), p.as_map().await);
}
let mut res = HashMap::new();
res.insert("providers", providers);
axum::response::Json(res)
}

#[derive(Deserialize)]
struct RuleProviderNamePath {
provider_name: String,
}

async fn get_rule_provider(
State(state): State<RuleProviderState>,
Path(RuleProviderNamePath { provider_name }): Path<RuleProviderNamePath>,
) -> impl IntoResponse {
match state.router.get_rule_providers().get(&provider_name) {
Some(p) => axum::response::Json(p.as_map().await).into_response(),
None => (
StatusCode::NOT_FOUND,
format!("rule provider {provider_name} not found"),
)
.into_response(),
}
}

async fn update_rule_provider(
State(state): State<RuleProviderState>,
Path(RuleProviderNamePath { provider_name }): Path<RuleProviderNamePath>,
) -> impl IntoResponse {
match state.router.get_rule_providers().get(&provider_name) {
Some(p) => match p.update().await {
Ok(_) => (StatusCode::ACCEPTED, "rule provider update started")
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("update rule provider {provider_name} failed: {err}"),
)
.into_response(),
},
None => (
StatusCode::NOT_FOUND,
format!("rule provider {provider_name} not found"),
)
.into_response(),
}
}

async fn get_rule_provider_rules(
State(state): State<RuleProviderState>,
Path(RuleProviderNamePath { provider_name }): Path<RuleProviderNamePath>,
) -> impl IntoResponse {
match state.router.get_rule_providers().get(&provider_name) {
Some(p) => {
let rules = p.list_rules(500).await;
let mut res = HashMap::new();
res.insert("rules", rules);
axum::response::Json(res).into_response()
}
None => (
StatusCode::NOT_FOUND,
format!("rule provider {provider_name} not found"),
)
.into_response(),
}
}

#[derive(Deserialize)]
struct MatchQuery {
target: String,
}

#[derive(Serialize)]
struct MatchResponse {
#[serde(rename = "match")]
matched: bool,
}

async fn match_rule_provider(
State(state): State<RuleProviderState>,
Path(RuleProviderNamePath { provider_name }): Path<RuleProviderNamePath>,
Query(q): Query<MatchQuery>,
) -> impl IntoResponse {
let Some(p) = state.router.get_rule_providers().get(&provider_name) else {
return (
StatusCode::NOT_FOUND,
format!("rule provider {provider_name} not found"),
)
.into_response();
};

let destination = match SocksAddr::from_str(&q.target) {
Ok(addr) => addr,
Err(_) => {
return (StatusCode::BAD_REQUEST, "invalid target").into_response();
}
};

let sess = Session {
network: Network::Tcp,
typ: Type::Http,
source: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
destination,
resolved_ip: None,
so_mark: None,
iface: None,
asn: None,
traffic_stats: None,
inbound_user: None,
};

axum::response::Json(MatchResponse {
matched: p.search(&sess),
})
.into_response()
}
Loading
Loading