Skip to main content

meow_api/
routes.rs

1use axum::{
2    extract::ws::{Message, WebSocketUpgrade},
3    extract::{Path, Query, Request, State},
4    http::{header, StatusCode},
5    middleware::{self, Next},
6    response::{IntoResponse, Json, Response},
7    routing::{delete, get, post, put},
8    Router,
9};
10use dashmap::DashMap;
11use meow_common::{Proxy, TunnelMode};
12use meow_config::{
13    proxy_provider::ProxyProvider,
14    raw::{RawConfig, RawProxyGroup, RawSubscription},
15    rule_provider::RuleProvider,
16    NamedListener,
17};
18use meow_tunnel::Tunnel;
19use parking_lot::RwLock;
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, HashMap};
22use std::sync::Arc;
23use std::time::Duration;
24use tokio::sync::broadcast;
25use tower_http::cors::CorsLayer;
26use tracing::{debug, info, warn};
27
28use crate::log_stream::{parse_log_level, LogMessage};
29use crate::ui;
30
31pub struct AppState {
32    pub tunnel: Tunnel,
33    /// Optional Bearer token enforced by `require_auth`. `None` or empty disables auth.
34    pub secret: Option<String>,
35    pub config_path: String,
36    pub raw_config: Arc<RwLock<RawConfig>>,
37    /// Fan-out channel for log events. Each WS client subscribes a Receiver.
38    pub log_tx: broadcast::Sender<LogMessage>,
39    /// Live proxy-provider registry — refreshed by background task and PUT endpoint.
40    pub proxy_providers: Arc<DashMap<String, Arc<ProxyProvider>>>,
41    pub rule_providers: Arc<RwLock<HashMap<String, Arc<RuleProvider>>>>,
42    /// Snapshot of active named listeners (read-only, startup-time only in M1).
43    pub listeners: Vec<NamedListener>,
44    /// Validated directory for a third-party web UI. When `Some`, it is served
45    /// at `/ui`; when `None`, the built-in panel is served (issue #223).
46    pub external_ui: Option<std::path::PathBuf>,
47}
48
49impl AppState {
50    fn auth_required(&self) -> bool {
51        self.secret.as_deref().is_some_and(|s| !s.is_empty())
52    }
53}
54
55/// Bearer token middleware. Matches upstream mihomo contract:
56/// `Authorization: Bearer <secret>`. When the configured secret is empty or
57/// unset, the middleware is a no-op. Otherwise, requests without a matching
58/// header return `401 Unauthorized`.
59async fn require_auth(State(state): State<Arc<AppState>>, req: Request, next: Next) -> Response {
60    if !state.auth_required() {
61        return next.run(req).await;
62    }
63
64    let Some(expected) = state.secret.as_deref() else {
65        return next.run(req).await;
66    };
67
68    let provided = req
69        .headers()
70        .get(header::AUTHORIZATION)
71        .and_then(|v| v.to_str().ok())
72        .and_then(|v| {
73            v.strip_prefix("Bearer ")
74                .or_else(|| v.strip_prefix("bearer "))
75        });
76
77    // Constant-time comparison so a byte-by-byte attacker cannot distinguish
78    // "first N bytes matched" from "failed immediately". Length still leaks;
79    // that is acceptable for a config-scoped shared secret.
80    let ok = match provided {
81        Some(token) if token.len() == expected.len() => {
82            use subtle::ConstantTimeEq;
83            token.as_bytes().ct_eq(expected.as_bytes()).into()
84        }
85        _ => false,
86    };
87    if ok {
88        next.run(req).await
89    } else {
90        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
91    }
92}
93
94/// Auth middleware for WebSocket upgrade routes. Accepts `Authorization: Bearer <secret>`
95/// header OR `?token=<secret>` query param (browser WebSocket clients cannot set headers).
96/// `?token=` is accepted ONLY on this middleware — REST routes keep header-only auth.
97async fn require_auth_ws(
98    State(state): State<Arc<AppState>>,
99    Query(query): Query<HashMap<String, String>>,
100    req: Request,
101    next: Next,
102) -> Response {
103    if !state.auth_required() {
104        return next.run(req).await;
105    }
106    let expected = state.secret.as_deref().unwrap_or("");
107
108    let bearer = req
109        .headers()
110        .get(header::AUTHORIZATION)
111        .and_then(|v| v.to_str().ok())
112        .and_then(|v| {
113            v.strip_prefix("Bearer ")
114                .or_else(|| v.strip_prefix("bearer "))
115        });
116
117    let token_param = query.get("token").map(std::string::String::as_str);
118    let provided = bearer.or(token_param);
119
120    let ok = match provided {
121        Some(t) if t.len() == expected.len() => {
122            use subtle::ConstantTimeEq;
123            t.as_bytes().ct_eq(expected.as_bytes()).into()
124        }
125        _ => false,
126    };
127    if ok {
128        next.run(req).await
129    } else {
130        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
131    }
132}
133
134pub fn create_router(state: Arc<AppState>) -> Router {
135    // WS routes — accept header or ?token= query param for browser dashboard compat.
136    let ws_routes = Router::new()
137        .route("/logs", get(get_logs))
138        .route("/memory", get(get_memory))
139        .route_layer(middleware::from_fn_with_state(
140            Arc::clone(&state),
141            require_auth_ws,
142        ));
143
144    // REST API routes gated behind the Bearer middleware (header-only).
145    let api = Router::new()
146        .route("/", get(hello))
147        .route("/version", get(version))
148        .route("/proxies", get(get_proxies))
149        .route("/proxies/{name}", get(get_proxy).put(update_proxy))
150        .route("/proxies/{name}/delay", get(get_proxy_delay))
151        .route("/group/{name}/delay", get(get_group_delay))
152        .route(
153            "/rules",
154            get(get_rules).post(replace_rules).put(update_rule_at_index),
155        )
156        .route("/rules/{index}", delete(delete_rule))
157        .route("/rules/reorder", post(reorder_rules))
158        .route("/connections", get(get_connections))
159        .route("/connections/{id}", delete(close_connection))
160        .route("/connections", delete(close_all_connections))
161        .route(
162            "/configs",
163            get(get_configs).patch(update_configs).put(put_configs),
164        )
165        .route("/metrics", get(get_metrics))
166        .route("/traffic", get(get_traffic))
167        .route("/dns/results", get(get_dns_results))
168        .route("/dns/query", get(dns_query_get).post(dns_query))
169        .route("/cache/dns/flush", post(flush_dns_cache))
170        .route("/cache/fakeip/flush", post(flush_fakeip_cache))
171        // Config save
172        .route("/api/config/save", post(save_config))
173        // Subscriptions
174        .route(
175            "/api/subscriptions",
176            get(get_subscriptions).post(add_subscription),
177        )
178        .route("/api/subscriptions/{name}", delete(delete_subscription))
179        .route(
180            "/api/subscriptions/{name}/refresh",
181            post(refresh_subscription),
182        )
183        // Proxy groups
184        .route(
185            "/api/proxy-groups",
186            get(get_proxy_groups).post(create_proxy_group),
187        )
188        .route(
189            "/api/proxy-groups/{name}",
190            put(update_proxy_group).delete(delete_proxy_group),
191        )
192        .route(
193            "/api/proxy-groups/{name}/select",
194            put(select_proxy_in_group),
195        )
196        // Proxy providers
197        .route("/providers/proxies", get(get_providers))
198        .route(
199            "/providers/proxies/{name}",
200            get(get_provider).put(refresh_provider),
201        )
202        .route(
203            "/providers/proxies/{name}/healthcheck",
204            get(provider_healthcheck),
205        )
206        // Rule providers
207        .route("/providers/rules", get(get_rule_providers))
208        .route(
209            "/providers/rules/{name}",
210            get(get_rule_provider).put(refresh_rule_provider),
211        )
212        // Listeners (read-only list)
213        .route("/listeners", get(get_listeners))
214        .route_layer(middleware::from_fn_with_state(
215            Arc::clone(&state),
216            require_auth,
217        ));
218
219    // Web UI is intentionally unauthenticated so dashboards can load and then
220    // present a token prompt; this matches upstream mihomo behaviour.
221    //
222    // When `external-ui` is configured (issue #223) the static directory is
223    // served at `/ui` via tower-http's `ServeDir`; otherwise the built-in
224    // single-page panel is served.
225    let router = api.merge(ws_routes);
226    let router = if let Some(dir) = state.external_ui.clone() {
227        // `ServeDir` resolves `index.html` for the directory root and serves
228        // any nested asset; `nest_service("/ui", …)` strips the `/ui` prefix so
229        // both `/ui` and `/ui/<asset>` resolve. Dashboards (metacubexd, yacd)
230        // use hash routing, so no server-side SPA fallback is required.
231        router.nest_service("/ui", tower_http::services::ServeDir::new(dir))
232    } else {
233        router
234            .route("/ui", get(ui::serve_ui))
235            .route("/ui/{*rest}", get(ui::serve_ui))
236    };
237
238    router.layer(CorsLayer::permissive()).with_state(state)
239}
240
241// ── Basic endpoints ──────────────────────────────────────────────────
242
243async fn hello() -> &'static str {
244    "meow-rs"
245}
246
247#[derive(Serialize)]
248struct VersionResponse {
249    version: String,
250    meta: bool,
251}
252
253async fn version() -> Json<VersionResponse> {
254    Json(VersionResponse {
255        version: env!("CARGO_PKG_VERSION").to_string(),
256        meta: true,
257    })
258}
259
260#[derive(Serialize)]
261struct ProxyInfo {
262    name: String,
263    #[serde(rename = "type")]
264    proxy_type: String,
265    alive: bool,
266    history: Vec<meow_common::DelayHistory>,
267    udp: bool,
268    /// Group-only: ordered list of member proxy names.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    all: Option<Vec<String>>,
271    /// Group-only: name of the currently active member.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    now: Option<String>,
274}
275
276impl ProxyInfo {
277    fn from_proxy(proxy: &Arc<dyn meow_common::Proxy>) -> Self {
278        let members = proxy.members();
279        let current = proxy.current();
280        debug!(
281            name = proxy.name(),
282            proxy_type = %proxy.adapter_type(),
283            member_count = members.as_ref().map(std::vec::Vec::len),
284            current = ?current,
285            "building ProxyInfo",
286        );
287        Self {
288            name: proxy.name().to_string(),
289            proxy_type: proxy.adapter_type().to_string(),
290            alive: proxy.alive(),
291            history: proxy.delay_history(),
292            udp: proxy.support_udp(),
293            all: members,
294            now: current,
295        }
296    }
297}
298
299#[derive(Serialize)]
300struct ProxiesResponse {
301    proxies: std::collections::HashMap<String, ProxyInfo>,
302}
303
304async fn get_proxies(State(state): State<Arc<AppState>>) -> Json<ProxiesResponse> {
305    let route = state.tunnel.route_snapshot();
306    let mut result = std::collections::HashMap::new();
307    for (name, proxy) in &route.proxies {
308        result.insert(name.to_string(), ProxyInfo::from_proxy(proxy));
309    }
310    Json(ProxiesResponse { proxies: result })
311}
312
313async fn get_proxy(
314    State(state): State<Arc<AppState>>,
315    Path(name): Path<String>,
316) -> Result<Json<ProxyInfo>, StatusCode> {
317    let route = state.tunnel.route_snapshot();
318    let proxy = route
319        .proxies
320        .get(name.as_str())
321        .ok_or(StatusCode::NOT_FOUND)?;
322    Ok(Json(ProxyInfo::from_proxy(proxy)))
323}
324
325#[derive(Deserialize)]
326struct UpdateProxyRequest {
327    name: String,
328}
329
330async fn update_proxy(
331    State(state): State<Arc<AppState>>,
332    Path(group_name): Path<String>,
333    Json(body): Json<UpdateProxyRequest>,
334) -> StatusCode {
335    let route = state.tunnel.route_snapshot();
336    let Some(proxy) = route.proxies.get(group_name.as_str()).cloned() else {
337        return StatusCode::NOT_FOUND;
338    };
339    match select_proxy_member_async(proxy, body.name.clone()).await {
340        Ok(Some(true)) => {
341            info!("Selector '{}' switched to '{}'", group_name, body.name);
342            StatusCode::NO_CONTENT
343        }
344        Ok(Some(false)) => StatusCode::BAD_REQUEST,
345        Ok(None) => StatusCode::NOT_FOUND,
346        Err(e) => {
347            warn!("Selector '{}' update task failed: {}", group_name, e);
348            StatusCode::INTERNAL_SERVER_ERROR
349        }
350    }
351}
352
353#[derive(Serialize)]
354struct RuleInfo<'a> {
355    #[serde(rename = "type")]
356    rule_type: &'static str,
357    payload: &'a str,
358    proxy: &'a str,
359}
360
361#[derive(Serialize)]
362struct RulesResponse<'a> {
363    rules: Vec<RuleInfo<'a>>,
364}
365
366async fn get_rules(State(state): State<Arc<AppState>>) -> Response {
367    // Serialise straight off the route snapshot — the old rules_info()
368    // accessor built 3 Strings per rule per call (audit #182).
369    let route = state.tunnel.route_snapshot();
370    let result: Vec<RuleInfo> = route
371        .rules
372        .iter()
373        .map(|r| RuleInfo {
374            rule_type: r.rule_type().as_str(),
375            payload: r.payload(),
376            proxy: r.adapter(),
377        })
378        .collect();
379    Json(RulesResponse { rules: result }).into_response()
380}
381
382#[derive(Serialize)]
383struct ConnectionsResponse<'a> {
384    upload_total: i64,
385    download_total: i64,
386    /// Serialised straight from the live table — no per-connection
387    /// `serde_json::Value` tree, no cloned snapshot Vec (audit M8). The
388    /// JSON shape (id/upload/download/start/chains/rule/rulePayload) comes
389    /// from `ConnectionInfo`'s `Serialize` derive.
390    connections: meow_tunnel::statistics::ActiveConnectionsView<'a>,
391}
392
393async fn get_connections(State(state): State<Arc<AppState>>) -> Response {
394    let stats = state.tunnel.statistics();
395    let (up, down) = stats.snapshot();
396    Json(ConnectionsResponse {
397        upload_total: up,
398        download_total: down,
399        connections: stats.active_connections_view(),
400    })
401    .into_response()
402}
403
404async fn close_connection(
405    State(state): State<Arc<AppState>>,
406    Path(id): Path<String>,
407) -> StatusCode {
408    match uuid::Uuid::parse_str(&id) {
409        Ok(uuid) => {
410            state.tunnel.statistics().close_connection(uuid);
411            StatusCode::NO_CONTENT
412        }
413        Err(_) => StatusCode::BAD_REQUEST,
414    }
415}
416
417#[derive(Serialize)]
418struct ConfigResponse {
419    mode: String,
420    #[serde(rename = "log-level")]
421    log_level: String,
422    #[serde(rename = "mixed-port", skip_serializing_if = "Option::is_none")]
423    mixed_port: Option<u16>,
424    #[serde(rename = "socks-port", skip_serializing_if = "Option::is_none")]
425    socks_port: Option<u16>,
426    #[serde(rename = "port", skip_serializing_if = "Option::is_none")]
427    http_port: Option<u16>,
428    #[serde(
429        rename = "external-controller",
430        skip_serializing_if = "Option::is_none"
431    )]
432    external_controller: Option<String>,
433}
434
435async fn get_configs(State(state): State<Arc<AppState>>) -> Json<ConfigResponse> {
436    let raw = state.raw_config.read();
437    Json(ConfigResponse {
438        mode: state.tunnel.mode().to_string(),
439        log_level: "info".to_string(),
440        mixed_port: raw.mixed_port,
441        socks_port: raw.socks_port,
442        http_port: raw.port,
443        external_controller: raw.external_controller.clone(),
444    })
445}
446
447#[derive(Deserialize)]
448struct UpdateConfigRequest {
449    mode: Option<String>,
450    #[serde(rename = "log-level")]
451    log_level: Option<String>,
452}
453
454async fn update_configs(
455    State(state): State<Arc<AppState>>,
456    Json(body): Json<UpdateConfigRequest>,
457) -> StatusCode {
458    if let Some(mode_str) = body.mode {
459        match mode_str.parse::<TunnelMode>() {
460            Ok(mode) => {
461                state.tunnel.set_mode(mode);
462                info!("Mode changed to {}", mode);
463            }
464            Err(_) => return StatusCode::BAD_REQUEST,
465        }
466    }
467    let _ = body.log_level;
468    StatusCode::NO_CONTENT
469}
470
471#[derive(Serialize)]
472struct TrafficResponse {
473    up: i64,
474    down: i64,
475}
476
477async fn get_traffic(State(state): State<Arc<AppState>>) -> Json<TrafficResponse> {
478    let (up, down) = state.tunnel.statistics().snapshot();
479    Json(TrafficResponse { up, down })
480}
481
482#[derive(Deserialize)]
483struct DnsQueryRequest {
484    name: String,
485    #[serde(rename = "type")]
486    qtype: Option<String>,
487}
488
489#[derive(Deserialize)]
490struct DnsResultsQuery {
491    search: Option<String>,
492    limit: Option<usize>,
493}
494
495#[derive(Serialize)]
496struct DnsResultEntry {
497    name: String,
498    ips: Vec<String>,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    from_server: Option<String>,
501    ttl: u64,
502}
503
504async fn get_dns_results(
505    State(state): State<Arc<AppState>>,
506    Query(params): Query<DnsResultsQuery>,
507) -> Json<Vec<DnsResultEntry>> {
508    let limit = params.limit.unwrap_or(256).min(1024);
509    let results = state
510        .tunnel
511        .resolver()
512        .dns_results(params.search.as_deref(), limit)
513        .into_iter()
514        .map(|entry| DnsResultEntry {
515            name: entry.name,
516            ips: entry.ips.into_iter().map(|ip| ip.to_string()).collect(),
517            from_server: entry.source,
518            ttl: entry.ttl.as_secs(),
519        })
520        .collect();
521    Json(results)
522}
523
524async fn dns_query(
525    State(state): State<Arc<AppState>>,
526    Json(body): Json<DnsQueryRequest>,
527) -> Json<serde_json::Value> {
528    let resolver = state.tunnel.resolver();
529    let result = resolver.resolve_ip(&body.name).await;
530    let _ = body.qtype;
531    Json(serde_json::json!({ "name": body.name, "answer": result.map(|ip| ip.to_string()) }))
532}
533
534// upstream: hub/route/dns.go — GET alias added alongside existing POST.
535// Class B per ADR-0002: POST kept for back-compat; GET matches upstream's current form.
536async fn dns_query_get(
537    State(state): State<Arc<AppState>>,
538    Query(params): Query<DnsQueryRequest>,
539) -> Json<serde_json::Value> {
540    let resolver = state.tunnel.resolver();
541    let result = resolver.resolve_ip(&params.name).await;
542    Json(serde_json::json!({ "name": params.name, "answer": result.map(|ip| ip.to_string()) }))
543}
544
545async fn flush_dns_cache(State(state): State<Arc<AppState>>) -> StatusCode {
546    state.tunnel.resolver().clear_cache();
547    StatusCode::NO_CONTENT
548}
549
550/// `POST /cache/fakeip/flush` — clear every fake-IP allocation. Mirrors
551/// upstream `hub/route/cache.go::flushFakeIPPool`. Returns 204 on success,
552/// 400 with a JSON `{message: ...}` body if persistence flushing fails.
553async fn flush_fakeip_cache(
554    State(state): State<Arc<AppState>>,
555) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
556    match state.tunnel.resolver().flush_fake_ip() {
557        Ok(()) => Ok(StatusCode::NO_CONTENT),
558        Err(e) => Err((
559            StatusCode::BAD_REQUEST,
560            Json(serde_json::json!({ "message": e.to_string() })),
561        )),
562    }
563}
564
565async fn close_all_connections(State(state): State<Arc<AppState>>) -> StatusCode {
566    state.tunnel.statistics().close_all_connections();
567    StatusCode::NO_CONTENT
568}
569
570// ── Config save ──────────────────────────────────────────────────────
571
572async fn save_config(
573    State(state): State<Arc<AppState>>,
574) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
575    let raw = state.raw_config.read().clone();
576    meow_config::save_raw_config_async(&state.config_path, &raw)
577        .await
578        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
579    Ok(Json(serde_json::json!({"message": "config saved"})))
580}
581
582// ── Helper: rebuild proxies/rules from raw and apply to tunnel ───────
583
584/// Pre-resolve DNS-sourced ECH then rebuild proxies/rules from `raw` and
585/// apply to the live tunnel. Takes the config *by value* so callers
586/// clone-and-drop their `parking_lot` guard before awaiting — those guards
587/// are not Send and would otherwise break the axum Handler bound.
588async fn apply_raw_to_tunnel(
589    mut raw: RawConfig,
590    tunnel: &Tunnel,
591) -> Result<(), (StatusCode, String)> {
592    if let Some(ps) = raw.proxies.as_mut() {
593        meow_config::ech_dns::preresolve_ech(ps).await;
594    }
595    let (proxies, rules) = rebuild_from_raw_with_resolver_async(raw, Arc::clone(tunnel.resolver()))
596        .await
597        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
598    tunnel.update_proxies(proxies);
599    tunnel.update_rules(rules);
600    Ok(())
601}
602
603async fn rebuild_from_raw_with_resolver_async(
604    raw: RawConfig,
605    resolver: Arc<meow_dns::Resolver>,
606) -> Result<meow_config::RebuildResult, String> {
607    tokio::task::spawn_blocking(move || {
608        meow_config::rebuild_from_raw_with_resolver(&raw, Some(resolver))
609    })
610    .await
611    .map_err(|e| format!("config rebuild task failed: {e}"))?
612    .map_err(|e| e.to_string())
613}
614
615async fn select_proxy_member_async(
616    proxy: Arc<dyn Proxy>,
617    member: String,
618) -> Result<Option<bool>, tokio::task::JoinError> {
619    tokio::task::spawn_blocking(move || {
620        use meow_proxy::SelectorGroup;
621        proxy
622            .as_any()
623            .and_then(|a| a.downcast_ref::<SelectorGroup>())
624            .map(|selector| selector.select(&member))
625    })
626    .await
627}
628
629// ── Subscriptions ────────────────────────────────────────────────────
630// Subscriptions replace local proxies/groups/rules with the remote data as-is.
631
632#[derive(Serialize)]
633struct SubscriptionInfo {
634    name: String,
635    url: String,
636    interval: Option<u64>,
637    last_updated: Option<i64>,
638    proxy_count: usize,
639    group_count: usize,
640    rule_count: usize,
641}
642
643async fn get_subscriptions(State(state): State<Arc<AppState>>) -> Json<Vec<SubscriptionInfo>> {
644    let raw = state.raw_config.read();
645    let subs = raw.subscriptions.as_deref().unwrap_or(&[]);
646    let result: Vec<SubscriptionInfo> = subs
647        .iter()
648        .map(|s| SubscriptionInfo {
649            name: s.name.clone(),
650            url: s.url.clone(),
651            interval: s.interval,
652            last_updated: s.last_updated,
653            proxy_count: raw.proxies.as_ref().map_or(0, std::vec::Vec::len),
654            group_count: raw.proxy_groups.as_ref().map_or(0, std::vec::Vec::len),
655            rule_count: raw.rules.as_ref().map_or(0, std::vec::Vec::len),
656        })
657        .collect();
658    Json(result)
659}
660
661#[derive(Deserialize)]
662struct AddSubscriptionRequest {
663    name: String,
664    url: String,
665    interval: Option<u64>,
666}
667
668async fn add_subscription(
669    State(state): State<Arc<AppState>>,
670    Json(body): Json<AddSubscriptionRequest>,
671) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
672    let fetched = meow_config::subscription::fetch_subscription(&body.url)
673        .await
674        .map_err(|e| (StatusCode::BAD_REQUEST, format!("fetch failed: {e}")))?;
675
676    let now = std::time::SystemTime::now()
677        .duration_since(std::time::UNIX_EPOCH)
678        .unwrap_or_default()
679        .as_secs() as i64;
680
681    let pc = fetched.proxies.len();
682    let gc = fetched.proxy_groups.len();
683    let rc = fetched.rules.len();
684
685    let snapshot = {
686        let mut raw = state.raw_config.write();
687
688        if let Some(ref subs) = raw.subscriptions {
689            if subs.iter().any(|s| s.name == body.name) {
690                return Err((
691                    StatusCode::CONFLICT,
692                    "subscription name already exists".into(),
693                ));
694            }
695        }
696
697        let sub = RawSubscription {
698            name: body.name.clone(),
699            url: body.url.clone(),
700            interval: body.interval,
701            last_updated: Some(now),
702        };
703        raw.subscriptions.get_or_insert_with(Vec::new).push(sub);
704
705        // Replace proxies, groups, and rules with remote data as-is
706        raw.proxies = Some(fetched.proxies);
707        raw.proxy_groups = Some(fetched.proxy_groups);
708        raw.rules = Some(fetched.rules);
709
710        raw.clone()
711    };
712    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
713
714    // Auto-save so subscription data is cached on disk
715    let raw = state.raw_config.read().clone();
716    let _ = meow_config::save_raw_config_async(&state.config_path, &raw).await;
717
718    Ok(Json(serde_json::json!({
719        "message": "subscription added",
720        "proxy_count": pc, "group_count": gc, "rule_count": rc
721    })))
722}
723
724async fn delete_subscription(
725    State(state): State<Arc<AppState>>,
726    Path(name): Path<String>,
727) -> Result<StatusCode, (StatusCode, String)> {
728    let snapshot = {
729        let mut raw = state.raw_config.write();
730
731        if let Some(ref mut subs) = raw.subscriptions {
732            let before = subs.len();
733            subs.retain(|s| s.name != name);
734            if subs.len() == before {
735                return Err((StatusCode::NOT_FOUND, "subscription not found".into()));
736            }
737        } else {
738            return Err((StatusCode::NOT_FOUND, "no subscriptions".into()));
739        }
740
741        // Clear everything from the remote subscription
742        raw.proxies = Some(Vec::new());
743        raw.proxy_groups = Some(Vec::new());
744        raw.rules = Some(Vec::new());
745
746        raw.clone()
747    };
748    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
749    let raw = state.raw_config.read().clone();
750    let _ = meow_config::save_raw_config_async(&state.config_path, &raw).await;
751    Ok(StatusCode::NO_CONTENT)
752}
753
754async fn refresh_subscription(
755    State(state): State<Arc<AppState>>,
756    Path(name): Path<String>,
757) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
758    let url = {
759        let raw = state.raw_config.read();
760        raw.subscriptions
761            .as_ref()
762            .and_then(|subs| subs.iter().find(|s| s.name == name))
763            .map(|s| s.url.clone())
764            .ok_or_else(|| (StatusCode::NOT_FOUND, "subscription not found".into()))?
765    };
766
767    let fetched = meow_config::subscription::fetch_subscription(&url)
768        .await
769        .map_err(|e| (StatusCode::BAD_REQUEST, format!("fetch failed: {e}")))?;
770
771    let now = std::time::SystemTime::now()
772        .duration_since(std::time::UNIX_EPOCH)
773        .unwrap_or_default()
774        .as_secs() as i64;
775
776    let pc = fetched.proxies.len();
777    let gc = fetched.proxy_groups.len();
778    let rc = fetched.rules.len();
779
780    let snapshot = {
781        let mut raw = state.raw_config.write();
782
783        if let Some(ref mut subs) = raw.subscriptions {
784            if let Some(sub) = subs.iter_mut().find(|s| s.name == name) {
785                sub.last_updated = Some(now);
786            }
787        }
788
789        raw.proxies = Some(fetched.proxies);
790        raw.proxy_groups = Some(fetched.proxy_groups);
791        raw.rules = Some(fetched.rules);
792
793        raw.clone()
794    };
795    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
796
797    // Auto-save so subscription data is cached on disk
798    let raw = state.raw_config.read().clone();
799    let _ = meow_config::save_raw_config_async(&state.config_path, &raw).await;
800
801    Ok(Json(serde_json::json!({
802        "message": "subscription refreshed",
803        "proxy_count": pc, "group_count": gc, "rule_count": rc
804    })))
805}
806
807// ── Proxy Groups ─────────────────────────────────────────────────────
808
809#[derive(Serialize)]
810struct ProxyGroupInfo {
811    name: String,
812    #[serde(rename = "type")]
813    group_type: String,
814    proxies: Vec<String>,
815    now: Option<String>,
816    url: Option<String>,
817    interval: Option<u64>,
818    tolerance: Option<u16>,
819}
820
821async fn get_proxy_groups(State(state): State<Arc<AppState>>) -> Json<Vec<ProxyGroupInfo>> {
822    let raw = state.raw_config.read();
823    let groups = raw.proxy_groups.as_deref().unwrap_or(&[]);
824    let route = state.tunnel.route_snapshot();
825    let tunnel_proxies = &route.proxies;
826
827    let result: Vec<ProxyGroupInfo> = groups
828        .iter()
829        .map(|g| {
830            let runtime = tunnel_proxies.get(g.name.as_str());
831            let now = runtime.and_then(|p| p.current());
832            let proxies = runtime
833                .and_then(|p| p.members())
834                .unwrap_or_else(|| g.proxies.clone().unwrap_or_default());
835            ProxyGroupInfo {
836                name: g.name.clone(),
837                group_type: g.group_type.clone(),
838                proxies,
839                now,
840                url: g.url.clone(),
841                interval: g.interval,
842                tolerance: g.tolerance,
843            }
844        })
845        .collect();
846    Json(result)
847}
848
849#[derive(Deserialize)]
850struct CreateProxyGroupRequest {
851    name: String,
852    #[serde(rename = "type")]
853    group_type: String,
854    proxies: Vec<String>,
855    url: Option<String>,
856    interval: Option<u64>,
857    tolerance: Option<u16>,
858}
859
860async fn create_proxy_group(
861    State(state): State<Arc<AppState>>,
862    Json(body): Json<CreateProxyGroupRequest>,
863) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
864    let group_name = body.name.clone();
865    let snapshot = {
866        let mut raw = state.raw_config.write();
867        if let Some(ref groups) = raw.proxy_groups {
868            if groups.iter().any(|g| g.name == body.name) {
869                return Err((StatusCode::CONFLICT, "group name already exists".into()));
870            }
871        }
872        let group = RawProxyGroup {
873            name: body.name,
874            group_type: body.group_type,
875            proxies: Some(body.proxies),
876            url: body.url,
877            interval: body.interval,
878            tolerance: body.tolerance,
879            ..Default::default()
880        };
881        raw.proxy_groups.get_or_insert_with(Vec::new).push(group);
882        raw.clone()
883    };
884    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
885    Ok(Json(
886        serde_json::json!({"message": "group created", "name": group_name}),
887    ))
888}
889
890async fn update_proxy_group(
891    State(state): State<Arc<AppState>>,
892    Path(name): Path<String>,
893    Json(body): Json<CreateProxyGroupRequest>,
894) -> Result<StatusCode, (StatusCode, String)> {
895    let snapshot = {
896        let mut raw = state.raw_config.write();
897        let group = raw
898            .proxy_groups
899            .as_mut()
900            .and_then(|groups| groups.iter_mut().find(|g| g.name == name))
901            .ok_or_else(|| (StatusCode::NOT_FOUND, "group not found".into()))?;
902        group.group_type = body.group_type;
903        group.proxies = Some(body.proxies);
904        group.url = body.url;
905        group.interval = body.interval;
906        group.tolerance = body.tolerance;
907        raw.clone()
908    };
909    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
910    Ok(StatusCode::NO_CONTENT)
911}
912
913async fn delete_proxy_group(
914    State(state): State<Arc<AppState>>,
915    Path(name): Path<String>,
916) -> Result<StatusCode, (StatusCode, String)> {
917    let snapshot = {
918        let mut raw = state.raw_config.write();
919        if let Some(ref mut groups) = raw.proxy_groups {
920            let before = groups.len();
921            groups.retain(|g| g.name != name);
922            if groups.len() == before {
923                return Err((StatusCode::NOT_FOUND, "group not found".into()));
924            }
925        } else {
926            return Err((StatusCode::NOT_FOUND, "no groups".into()));
927        }
928        if let Some(ref mut rules) = raw.rules {
929            rules.retain(|r| {
930                let parts: Vec<&str> = r.split(',').collect();
931                parts.last().is_none_or(|target| target.trim() != name)
932            });
933        }
934        raw.clone()
935    };
936    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
937    Ok(StatusCode::NO_CONTENT)
938}
939
940#[derive(Deserialize)]
941struct SelectProxyRequest {
942    name: String,
943}
944
945async fn select_proxy_in_group(
946    State(state): State<Arc<AppState>>,
947    Path(group_name): Path<String>,
948    Json(body): Json<SelectProxyRequest>,
949) -> StatusCode {
950    let route = state.tunnel.route_snapshot();
951    let Some(proxy) = route.proxies.get(group_name.as_str()).cloned() else {
952        return StatusCode::NOT_FOUND;
953    };
954    match select_proxy_member_async(proxy, body.name.clone()).await {
955        Ok(Some(true)) => {
956            info!("Selector '{}' switched to '{}'", group_name, body.name);
957            StatusCode::NO_CONTENT
958        }
959        Ok(Some(false)) => StatusCode::BAD_REQUEST,
960        Ok(None) => StatusCode::NOT_FOUND,
961        Err(e) => {
962            warn!("Selector '{}' update task failed: {}", group_name, e);
963            StatusCode::INTERNAL_SERVER_ERROR
964        }
965    }
966}
967
968// ── Rules CRUD ───────────────────────────────────────────────────────
969
970#[derive(Deserialize)]
971struct ReplaceRulesRequest {
972    rules: Vec<String>,
973}
974
975async fn replace_rules(
976    State(state): State<Arc<AppState>>,
977    Json(body): Json<ReplaceRulesRequest>,
978) -> Result<StatusCode, (StatusCode, String)> {
979    let snapshot = {
980        let mut raw = state.raw_config.write();
981        raw.rules = Some(body.rules);
982        raw.clone()
983    };
984    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
985    Ok(StatusCode::NO_CONTENT)
986}
987
988#[derive(Deserialize)]
989struct UpdateRuleRequest {
990    index: usize,
991    rule: String,
992}
993
994async fn update_rule_at_index(
995    State(state): State<Arc<AppState>>,
996    Json(body): Json<UpdateRuleRequest>,
997) -> Result<StatusCode, (StatusCode, String)> {
998    let snapshot = {
999        let mut raw = state.raw_config.write();
1000        let rules = raw.rules.get_or_insert_with(Vec::new);
1001        if body.index >= rules.len() {
1002            return Err((StatusCode::BAD_REQUEST, "index out of range".into()));
1003        }
1004        rules[body.index] = body.rule;
1005        raw.clone()
1006    };
1007    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
1008    Ok(StatusCode::NO_CONTENT)
1009}
1010
1011async fn delete_rule(
1012    State(state): State<Arc<AppState>>,
1013    Path(index): Path<usize>,
1014) -> Result<StatusCode, (StatusCode, String)> {
1015    let snapshot = {
1016        let mut raw = state.raw_config.write();
1017        let rules = raw.rules.get_or_insert_with(Vec::new);
1018        if index >= rules.len() {
1019            return Err((StatusCode::BAD_REQUEST, "index out of range".into()));
1020        }
1021        rules.remove(index);
1022        raw.clone()
1023    };
1024    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
1025    Ok(StatusCode::NO_CONTENT)
1026}
1027
1028#[derive(Deserialize)]
1029struct ReorderRulesRequest {
1030    from: usize,
1031    to: usize,
1032}
1033
1034async fn reorder_rules(
1035    State(state): State<Arc<AppState>>,
1036    Json(body): Json<ReorderRulesRequest>,
1037) -> Result<StatusCode, (StatusCode, String)> {
1038    let snapshot = {
1039        let mut raw = state.raw_config.write();
1040        let rules = raw.rules.get_or_insert_with(Vec::new);
1041        if body.from >= rules.len() || body.to >= rules.len() {
1042            return Err((StatusCode::BAD_REQUEST, "index out of range".into()));
1043        }
1044        let rule = rules.remove(body.from);
1045        rules.insert(body.to, rule);
1046        raw.clone()
1047    };
1048    apply_raw_to_tunnel(snapshot, &state.tunnel).await?;
1049    Ok(StatusCode::NO_CONTENT)
1050}
1051
1052// ── Delay probe endpoints ────────────────────────────────────────────
1053//
1054// Matches upstream mihomo `hub/route/proxies.go::getProxyDelay` and
1055// `hub/route/groups.go::getGroupDelay`. Error bodies are byte-exact copies
1056// of upstream's `ErrBadRequest` / `ErrNotFound` / `ErrRequestTimeout` /
1057// `newError("An error occurred in the delay test")`.
1058
1059#[derive(Deserialize)]
1060struct DelayParams {
1061    url: Option<String>,
1062    timeout: Option<String>,
1063    expected: Option<String>,
1064}
1065
1066#[derive(Serialize)]
1067struct DelayResp {
1068    delay: u16,
1069}
1070
1071/// `{"message": "..."}` body matching upstream's error render.
1072fn msg_err(status: StatusCode, message: &'static str) -> Response {
1073    (status, Json(serde_json::json!({ "message": message }))).into_response()
1074}
1075
1076/// Validate `url` and `timeout`. Returns `timeout` as `Duration` on success,
1077/// or the `400 Body invalid` response on any validation failure — matching
1078/// upstream's single "ErrBadRequest" shape for all parse errors.
1079fn parse_delay_params(params: &DelayParams) -> Result<Duration, Box<Response>> {
1080    // upstream: hub/route/proxies.go::getProxyDelay — url is not strictly
1081    // validated upstream, but an empty host would panic our prober.
1082    let url = params.url.as_deref().unwrap_or("").trim();
1083    if url.is_empty() {
1084        return Err(Box::new(msg_err(StatusCode::BAD_REQUEST, "Body invalid")));
1085    }
1086
1087    // upstream parses `timeout` as int16 and treats parse failure as
1088    // ErrBadRequest. We reject 0 as well (a zero-budget probe is never useful).
1089    let timeout_str = params
1090        .timeout
1091        .as_deref()
1092        .ok_or_else(|| Box::new(msg_err(StatusCode::BAD_REQUEST, "Body invalid")))?;
1093    let timeout_ms: u16 = timeout_str
1094        .trim()
1095        .parse()
1096        .map_err(|_| Box::new(msg_err(StatusCode::BAD_REQUEST, "Body invalid")))?;
1097    if timeout_ms == 0 {
1098        return Err(Box::new(msg_err(StatusCode::BAD_REQUEST, "Body invalid")));
1099    }
1100    Ok(Duration::from_millis(timeout_ms as u64))
1101}
1102
1103/// Probe a single adapter and record the result into its health handle.
1104/// On success records the measured delay; on any failure records `0` so
1105/// the proxy's `last_delay` tracks the most recent outcome.
1106async fn probe_and_record(
1107    proxy: &Arc<dyn meow_common::Proxy>,
1108    url: &str,
1109    expected: Option<&str>,
1110    timeout: Duration,
1111) -> Result<u16, meow_proxy::health::UrlTestError> {
1112    meow_proxy::health::probe_and_record(proxy, url, expected, timeout).await
1113}
1114
1115async fn get_proxy_delay(
1116    State(state): State<Arc<AppState>>,
1117    Path(name): Path<String>,
1118    Query(params): Query<DelayParams>,
1119) -> Response {
1120    let timeout = match parse_delay_params(&params) {
1121        Ok(t) => t,
1122        Err(resp) => return *resp,
1123    };
1124    let url = params.url.as_deref().unwrap_or("").to_string();
1125    let expected = params.expected.clone();
1126
1127    let route = state.tunnel.route_snapshot();
1128    // upstream: hub/route/proxies.go::getProxyDelay — findProxyByName middleware
1129    let Some(proxy) = route.proxies.get(name.as_str()).cloned() else {
1130        return msg_err(StatusCode::NOT_FOUND, "resource not found");
1131    };
1132    drop(route);
1133
1134    match probe_and_record(&proxy, &url, expected.as_deref(), timeout).await {
1135        Ok(delay) => Json(DelayResp { delay }).into_response(),
1136        // upstream: `render.Status(r, http.StatusGatewayTimeout)` → 504.
1137        Err(meow_proxy::health::UrlTestError::Timeout) => {
1138            msg_err(StatusCode::GATEWAY_TIMEOUT, "Timeout")
1139        }
1140        // upstream: `newError("An error occurred in the delay test")` → 503.
1141        Err(meow_proxy::health::UrlTestError::Transport(_)) => msg_err(
1142            StatusCode::SERVICE_UNAVAILABLE,
1143            "An error occurred in the delay test",
1144        ),
1145    }
1146}
1147
1148async fn get_group_delay(
1149    State(state): State<Arc<AppState>>,
1150    Path(name): Path<String>,
1151    Query(params): Query<DelayParams>,
1152) -> Response {
1153    let timeout = match parse_delay_params(&params) {
1154        Ok(t) => t,
1155        Err(resp) => return *resp,
1156    };
1157    let url = params.url.as_deref().unwrap_or("").to_string();
1158    let expected = params.expected.clone();
1159
1160    let route = state.tunnel.route_snapshot();
1161    let Some(group) = route.proxies.get(name.as_str()).cloned() else {
1162        return msg_err(StatusCode::NOT_FOUND, "resource not found");
1163    };
1164    // upstream: findProxyByName rejects non-groups with 404 for this route.
1165    let Some(member_names) = group.members() else {
1166        return msg_err(StatusCode::NOT_FOUND, "resource not found");
1167    };
1168
1169    // Resolve each member name to an `Arc<dyn Proxy>` *before* dropping the
1170    // proxies map so the spawned tasks hold their own Arc clones.
1171    let members: Vec<(String, Arc<dyn meow_common::Proxy>)> = member_names
1172        .into_iter()
1173        .filter_map(|n| route.proxies.get(n.as_str()).cloned().map(|p| (n, p)))
1174        .collect();
1175    drop(route);
1176
1177    // upstream: group probe wraps the whole batch in one context.WithTimeout,
1178    // not per-member. A slow member does not get its own budget.
1179    let collected = tokio::time::timeout(
1180        timeout,
1181        meow_proxy::health::probe_many_bounded_detailed(
1182            members,
1183            &url,
1184            expected.as_deref(),
1185            timeout,
1186            meow_proxy::health::GROUP_DELAY_CONCURRENCY,
1187        ),
1188    )
1189    .await;
1190
1191    let Ok(pairs) = collected else {
1192        // upstream: 504 "Timeout". Even if some members completed before the
1193        // deadline, upstream still returns the timeout error — we match.
1194        return msg_err(StatusCode::GATEWAY_TIMEOUT, "Timeout");
1195    };
1196
1197    let mut result: BTreeMap<String, u16> = BTreeMap::new();
1198    for pair in pairs {
1199        if matches!(pair.error, Some(meow_proxy::health::UrlTestError::Timeout)) {
1200            return msg_err(StatusCode::GATEWAY_TIMEOUT, "Timeout");
1201        }
1202        result.insert(pair.name, pair.delay);
1203    }
1204    Json(result).into_response()
1205}
1206
1207// ── Config reload (M1.G-10) ──────────────────────────────────────────
1208// upstream: hub/server.go::patchConfig
1209// Class B per ADR-0002: payload must be base64 (upstream inconsistent); YAML parse errors
1210// always return 400 even with force=true; NOT upstream silent broken-config apply.
1211
1212#[derive(Deserialize)]
1213struct PutConfigsBody {
1214    path: Option<String>,
1215    payload: Option<String>,
1216}
1217
1218async fn put_configs(
1219    State(state): State<Arc<AppState>>,
1220    Query(params): Query<HashMap<String, String>>,
1221    Json(body): Json<PutConfigsBody>,
1222) -> Response {
1223    let force = params.get("force").is_some_and(|v| v == "true");
1224
1225    let yaml =
1226        match (body.path, body.payload) {
1227            (Some(p), _) => match tokio::fs::read_to_string(&p).await {
1228                Ok(s) => s,
1229                Err(e) => {
1230                    return (
1231                        StatusCode::BAD_REQUEST,
1232                        Json(serde_json::json!({"message": e.to_string()})),
1233                    )
1234                        .into_response()
1235                }
1236            },
1237            (_, Some(b64)) => {
1238                use base64::engine::general_purpose::STANDARD;
1239                use base64::Engine as _;
1240                let Ok(bytes) = STANDARD.decode(&b64) else {
1241                    return (
1242                        StatusCode::BAD_REQUEST,
1243                        Json(serde_json::json!({"message": "payload is not valid base64"})),
1244                    )
1245                        .into_response();
1246                };
1247                match String::from_utf8(bytes) {
1248                    Ok(s) => s,
1249                    Err(_) => {
1250                        return (
1251                            StatusCode::BAD_REQUEST,
1252                            Json(serde_json::json!({"message": "payload is not valid UTF-8"})),
1253                        )
1254                            .into_response()
1255                    }
1256                }
1257            }
1258            _ => return (
1259                StatusCode::BAD_REQUEST,
1260                Json(
1261                    serde_json::json!({"message": "request body must contain 'path' or 'payload'"}),
1262                ),
1263            )
1264                .into_response(),
1265        };
1266
1267    // YAML syntax check — always 400 even with force=true (per spec)
1268    let mut raw_config: RawConfig = match serde_yaml::from_str(&yaml) {
1269        Ok(c) => c,
1270        Err(e) => {
1271            return (
1272                StatusCode::BAD_REQUEST,
1273                Json(serde_json::json!({"message": format!("config parse error: {e}")})),
1274            )
1275                .into_response()
1276        }
1277    };
1278
1279    // Pre-resolve any DNS-sourced ECH configs into inline base64.
1280    if let Some(ps) = raw_config.proxies.as_mut() {
1281        meow_config::ech_dns::preresolve_ech(ps).await;
1282    }
1283
1284    // Semantic rebuild (proxy/rule parsing)
1285    let resolver = Arc::clone(state.tunnel.resolver());
1286    let (proxies, rules) = match rebuild_from_raw_with_resolver_async(raw_config.clone(), resolver)
1287        .await
1288    {
1289        Ok(r) => r,
1290        Err(e) => {
1291            if force {
1292                tracing::error!("config reload forced despite validation error: {e}");
1293                (Default::default(), Vec::new())
1294            } else {
1295                return (
1296                    StatusCode::BAD_REQUEST,
1297                    Json(serde_json::json!({"message": format!("config validation error: {e}")})),
1298                )
1299                    .into_response();
1300            }
1301        }
1302    };
1303
1304    // Cold reload: close all connections with structured log (Class A divergence from upstream)
1305    let stats = state.tunnel.statistics();
1306    let dropped = stats.active_connection_count();
1307    stats.close_all_connections();
1308    if dropped > 0 {
1309        tracing::warn!(
1310            connections_dropped = dropped,
1311            "connections force-closed after reload drain timeout"
1312        );
1313    }
1314
1315    state.tunnel.update_proxies(proxies);
1316    state.tunnel.update_rules(rules);
1317    if let Some(mode_str) = &raw_config.mode {
1318        if let Ok(mode) = mode_str.parse::<TunnelMode>() {
1319            state.tunnel.set_mode(mode);
1320        }
1321    }
1322    *state.raw_config.write() = raw_config;
1323
1324    StatusCode::NO_CONTENT.into_response()
1325}
1326
1327// ── Prometheus metrics (M1.H-2) ──────────────────────────────────────
1328// upstream: N/A — meow-rs enhancement; Go mihomo has no native /metrics endpoint.
1329
1330async fn get_metrics(State(state): State<Arc<AppState>>) -> Response {
1331    use prometheus_client::encoding::text::encode;
1332    use prometheus_client::metrics::counter::Counter;
1333    use prometheus_client::metrics::family::Family;
1334    use prometheus_client::metrics::gauge::Gauge;
1335    use prometheus_client::registry::Registry;
1336    use std::sync::atomic::{AtomicI64, AtomicU64};
1337
1338    let mut registry = Registry::default();
1339    let stats = state.tunnel.statistics();
1340    let (upload_total, download_total) = stats.snapshot();
1341
1342    // meow_traffic_bytes — counter{direction}
1343    let traffic = Family::<Vec<(String, String)>, Counter<u64, AtomicU64>>::default();
1344    traffic
1345        .get_or_create(&vec![("direction".to_string(), "upload".to_string())])
1346        .inc_by(upload_total.max(0) as u64);
1347    traffic
1348        .get_or_create(&vec![("direction".to_string(), "download".to_string())])
1349        .inc_by(download_total.max(0) as u64);
1350    registry.register(
1351        "meow_traffic_bytes",
1352        "Cumulative bytes transferred since process start",
1353        traffic,
1354    );
1355
1356    // meow_connections_active — gauge
1357    let connections_active = Gauge::<i64, AtomicI64>::default();
1358    connections_active.set(stats.active_connection_count() as i64);
1359    registry.register(
1360        "meow_connections_active",
1361        "Number of currently open connections",
1362        connections_active,
1363    );
1364
1365    // meow_proxy_alive and meow_proxy_delay_ms — gauge{proxy_name,adapter_type}
1366    let proxy_alive = Family::<Vec<(String, String)>, Gauge<i64, AtomicI64>>::default();
1367    let proxy_delay = Family::<Vec<(String, String)>, Gauge<i64, AtomicI64>>::default();
1368    let route = state.tunnel.route_snapshot();
1369    for (name, proxy) in &route.proxies {
1370        let labels = vec![
1371            ("proxy_name".to_string(), name.to_string()),
1372            ("adapter_type".to_string(), proxy.adapter_type().to_string()),
1373        ];
1374        proxy_alive
1375            .get_or_create(&labels)
1376            .set(if proxy.alive() { 1 } else { 0 });
1377        // Omit delay series entirely when no health check has run (empty history).
1378        // NOT -1, NOT 0 — absence is the correct Prometheus signal for "unknown".
1379        if !proxy.delay_history().is_empty() {
1380            proxy_delay
1381                .get_or_create(&labels)
1382                .set(proxy.last_delay() as i64);
1383        }
1384    }
1385    registry.register(
1386        "meow_proxy_alive",
1387        "Proxy alive status (1=alive, 0=dead)",
1388        proxy_alive,
1389    );
1390    registry.register(
1391        "meow_proxy_delay_ms",
1392        "Last measured proxy round-trip delay in milliseconds",
1393        proxy_delay,
1394    );
1395
1396    // meow_rules_matched — counter{rule_type,action}
1397    let rules_matched = Family::<Vec<(String, String)>, Counter<u64, AtomicU64>>::default();
1398    for ((rule_type, action), count) in stats.rule_match.snapshot() {
1399        rules_matched
1400            .get_or_create(&vec![
1401                ("rule_type".to_string(), rule_type.to_string()),
1402                ("action".to_string(), action.to_string()),
1403            ])
1404            .inc_by(count);
1405    }
1406    registry.register(
1407        "meow_rules_matched",
1408        "Cumulative rule matches by type and action",
1409        rules_matched,
1410    );
1411
1412    // meow_memory_rss_bytes — gauge
1413    let memory_rss = Gauge::<i64, AtomicI64>::default();
1414    memory_rss.set(read_rss_bytes().await as i64);
1415    registry.register(
1416        "meow_memory_rss_bytes",
1417        "Current process RSS in bytes",
1418        memory_rss,
1419    );
1420
1421    // meow_info — gauge{version,mode} always = 1
1422    let info = Family::<Vec<(String, String)>, Gauge<i64, AtomicI64>>::default();
1423    info.get_or_create(&vec![
1424        ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
1425        ("mode".to_string(), state.tunnel.mode().to_string()),
1426    ])
1427    .set(1);
1428    registry.register("meow_info", "meow-rs runtime info", info);
1429
1430    let mut body = String::new();
1431    encode(&mut body, &registry).expect("prometheus text encoding is infallible");
1432    (
1433        StatusCode::OK,
1434        [(
1435            header::CONTENT_TYPE,
1436            "text/plain; version=0.0.4; charset=utf-8",
1437        )],
1438        body,
1439    )
1440        .into_response()
1441}
1442
1443// ── WebSocket: log stream ────────────────────────────────────────────
1444
1445#[derive(Deserialize)]
1446struct LogsParams {
1447    level: Option<String>,
1448}
1449
1450// upstream: hub/route/logs.go::getLogs
1451async fn get_logs(
1452    State(state): State<Arc<AppState>>,
1453    Query(params): Query<LogsParams>,
1454    ws: WebSocketUpgrade,
1455) -> Response {
1456    let level = parse_log_level(params.level.as_deref().unwrap_or("info"));
1457    let mut rx = state.log_tx.subscribe();
1458    ws.on_upgrade(move |mut socket| async move {
1459        loop {
1460            match rx.recv().await {
1461                Ok(msg) if msg.level >= level => {
1462                    let json = serde_json::to_string(&msg).unwrap_or_default();
1463                    if socket.send(Message::Text(json.into())).await.is_err() {
1464                        break;
1465                    }
1466                }
1467                Ok(_) => {}
1468                Err(broadcast::error::RecvError::Lagged(n)) => {
1469                    let lag_msg = format!("{{\"type\":\"lagged\",\"missed\":{n}}}");
1470                    if socket.send(Message::Text(lag_msg.into())).await.is_err() {
1471                        break;
1472                    }
1473                }
1474                Err(broadcast::error::RecvError::Closed) => break,
1475            }
1476        }
1477    })
1478}
1479
1480// ── WebSocket: memory stream ─────────────────────────────────────────
1481
1482// upstream: hub/route/memory.go
1483//
1484// One process-wide sampler task reads RSS + limit and serialises the JSON
1485// frame once per tick; every connected socket forwards the shared string
1486// (audit M8 — previously each socket sampled and serialised independently,
1487// per-socket per-tick). The sampler starts with the first subscriber and
1488// exits once the last socket disconnects, so an idle API server pays nothing.
1489// Model: the log websocket's single-serialisation broadcast fan-out.
1490static MEMORY_FEED: std::sync::Mutex<Option<broadcast::Sender<Arc<str>>>> =
1491    std::sync::Mutex::new(None);
1492
1493fn subscribe_memory_feed() -> broadcast::Receiver<Arc<str>> {
1494    let mut guard = MEMORY_FEED.lock().expect("memory feed lock poisoned");
1495    if let Some(tx) = guard.as_ref() {
1496        // Sampler still alive (it clears the slot under this lock on exit).
1497        return tx.subscribe();
1498    }
1499    let (tx, rx) = broadcast::channel(2);
1500    *guard = Some(tx.clone());
1501    tokio::spawn(async move {
1502        let mut interval = tokio::time::interval(Duration::from_secs(1));
1503        loop {
1504            interval.tick().await;
1505            if tx.receiver_count() == 0 {
1506                // Re-check under the lock so a subscriber arriving right now
1507                // either sees the live sender or a cleared slot — never a
1508                // sender whose sampler has already exited.
1509                let mut guard = MEMORY_FEED.lock().expect("memory feed lock poisoned");
1510                if tx.receiver_count() == 0 {
1511                    *guard = None;
1512                    break;
1513                }
1514            }
1515            let inuse = read_rss_bytes().await;
1516            let oslimit = read_os_memory_limit().await;
1517            let msg: Arc<str> = Arc::from(format!("{{\"inuse\":{inuse},\"oslimit\":{oslimit}}}"));
1518            let _ = tx.send(msg);
1519        }
1520    });
1521    rx
1522}
1523
1524async fn get_memory(State(_state): State<Arc<AppState>>, ws: WebSocketUpgrade) -> Response {
1525    ws.on_upgrade(|mut socket| async move {
1526        let mut feed = subscribe_memory_feed();
1527        loop {
1528            let msg = match feed.recv().await {
1529                Ok(msg) => msg,
1530                // Slow consumer skipped a tick — just continue with the next.
1531                Err(broadcast::error::RecvError::Lagged(_)) => continue,
1532                Err(broadcast::error::RecvError::Closed) => break,
1533            };
1534            if socket
1535                .send(Message::Text(msg.as_ref().into()))
1536                .await
1537                .is_err()
1538            {
1539                break;
1540            }
1541        }
1542    })
1543}
1544
1545async fn read_rss_bytes() -> u64 {
1546    tokio::task::spawn_blocking(|| {
1547        use sysinfo::{Pid, ProcessesToUpdate, System};
1548        let pid = Pid::from_u32(std::process::id());
1549        let mut sys = System::new();
1550        sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), false);
1551        sys.process(pid).map_or(0, sysinfo::Process::memory)
1552    })
1553    .await
1554    .unwrap_or(0)
1555}
1556
1557async fn read_os_memory_limit() -> u64 {
1558    #[cfg(target_os = "linux")]
1559    {
1560        read_os_memory_limit_linux().await
1561    }
1562    #[cfg(not(target_os = "linux"))]
1563    {
1564        0
1565    }
1566}
1567
1568#[cfg(target_os = "linux")]
1569async fn read_os_memory_limit_linux() -> u64 {
1570    // Try cgroup v2 memory limit first, fall back to rlimit.
1571    if let Ok(s) = tokio::fs::read_to_string("/sys/fs/cgroup/memory.max").await {
1572        if let Ok(n) = s.trim().parse::<u64>() {
1573            return n;
1574        }
1575    }
1576    // rlimit RLIMIT_AS (virtual address space) as a proxy; RLIMIT_RSS is deprecated.
1577    unsafe {
1578        let mut rl = libc::rlimit {
1579            rlim_cur: 0,
1580            rlim_max: 0,
1581        };
1582        if libc::getrlimit(libc::RLIMIT_AS, &mut rl) == 0 && rl.rlim_cur != libc::RLIM_INFINITY {
1583            return rl.rlim_cur;
1584        }
1585    }
1586    0
1587}
1588
1589// ── Proxy providers ───────────────────────────────────────────────────
1590
1591#[derive(Serialize)]
1592#[serde(rename_all = "camelCase")]
1593struct ProviderInfo {
1594    name: String,
1595    #[serde(rename = "type")]
1596    provider_type: String,
1597    vehicle_type: String,
1598    proxies: Vec<ProxyInfo>,
1599}
1600
1601fn provider_to_info(name: &str, provider: &ProxyProvider) -> ProviderInfo {
1602    let proxies = provider
1603        .proxies()
1604        .iter()
1605        .map(ProxyInfo::from_proxy)
1606        .collect();
1607    ProviderInfo {
1608        name: name.to_string(),
1609        provider_type: "Proxy".to_string(),
1610        vehicle_type: provider.vehicle_type.to_string(),
1611        proxies,
1612    }
1613}
1614
1615async fn get_providers(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
1616    let mut map = serde_json::Map::new();
1617    for entry in state.proxy_providers.iter() {
1618        let info = provider_to_info(entry.key(), entry.value());
1619        map.insert(
1620            entry.key().clone(),
1621            serde_json::to_value(info).unwrap_or_default(),
1622        );
1623    }
1624    Json(serde_json::json!({ "providers": map }))
1625}
1626
1627async fn get_provider(State(state): State<Arc<AppState>>, Path(name): Path<String>) -> Response {
1628    match state.proxy_providers.get(&name) {
1629        Some(entry) => Json(provider_to_info(&name, entry.value())).into_response(),
1630        None => msg_err(StatusCode::NOT_FOUND, "resource not found"),
1631    }
1632}
1633
1634async fn refresh_provider(
1635    State(state): State<Arc<AppState>>,
1636    Path(name): Path<String>,
1637) -> Response {
1638    let provider = match state.proxy_providers.get(&name) {
1639        Some(entry) => Arc::clone(entry.value()),
1640        None => return msg_err(StatusCode::NOT_FOUND, "resource not found"),
1641    };
1642    provider.refresh().await;
1643    StatusCode::NO_CONTENT.into_response()
1644}
1645
1646/// Trigger a health check for all proxies in the named provider.
1647/// Accepts the same `url` and `timeout` query params as `GET /proxies/:name/delay`.
1648async fn provider_healthcheck(
1649    State(state): State<Arc<AppState>>,
1650    Path(name): Path<String>,
1651    Query(params): Query<DelayParams>,
1652) -> Response {
1653    let timeout = match parse_delay_params(&params) {
1654        Ok(t) => t,
1655        Err(resp) => return *resp,
1656    };
1657    let url = params.url.as_deref().unwrap_or("").to_string();
1658    let expected = params.expected.clone();
1659
1660    let provider = match state.proxy_providers.get(&name) {
1661        Some(entry) => Arc::clone(entry.value()),
1662        None => return msg_err(StatusCode::NOT_FOUND, "resource not found"),
1663    };
1664
1665    let members = provider
1666        .proxies()
1667        .into_iter()
1668        .map(|proxy| (proxy.name().to_string(), proxy))
1669        .collect();
1670
1671    let mut results = serde_json::Map::new();
1672    for (pname, delay) in meow_proxy::health::probe_many_bounded(
1673        members,
1674        &url,
1675        expected.as_deref(),
1676        timeout,
1677        meow_proxy::health::PROVIDER_HEALTHCHECK_CONCURRENCY,
1678    )
1679    .await
1680    {
1681        results.insert(pname, serde_json::Value::Number(delay.into()));
1682    }
1683
1684    Json(serde_json::Value::Object(results)).into_response()
1685}
1686
1687// ── Rule Providers ────────────────────────────────────────────────────
1688
1689#[derive(Serialize)]
1690struct RuleProviderInfo {
1691    name: String,
1692    #[serde(rename = "type")]
1693    provider_type: String,
1694    behavior: String,
1695    #[serde(rename = "ruleCount")]
1696    rule_count: usize,
1697    #[serde(rename = "updatedAt")]
1698    updated_at: u64,
1699    #[serde(rename = "vehicleType")]
1700    vehicle_type: String,
1701}
1702
1703impl RuleProviderInfo {
1704    fn from_provider(p: &Arc<RuleProvider>) -> Self {
1705        Self {
1706            name: p.name.clone(),
1707            provider_type: p.provider_type.to_string(),
1708            behavior: p.behavior.to_string(),
1709            rule_count: p.rule_count(),
1710            updated_at: p.updated_at_secs(),
1711            vehicle_type: p.vehicle.clone(),
1712        }
1713    }
1714}
1715
1716#[derive(Serialize)]
1717struct RuleProvidersResponse {
1718    providers: HashMap<String, RuleProviderInfo>,
1719}
1720
1721async fn get_rule_providers(State(state): State<Arc<AppState>>) -> Json<RuleProvidersResponse> {
1722    let providers = state.rule_providers.read();
1723    let map: HashMap<String, RuleProviderInfo> = providers
1724        .iter()
1725        .map(|(name, p): (&String, &Arc<RuleProvider>)| {
1726            (name.clone(), RuleProviderInfo::from_provider(p))
1727        })
1728        .collect();
1729    Json(RuleProvidersResponse { providers: map })
1730}
1731
1732async fn get_rule_provider(
1733    State(state): State<Arc<AppState>>,
1734    Path(name): Path<String>,
1735) -> Result<Json<RuleProviderInfo>, StatusCode> {
1736    let providers = state.rule_providers.read();
1737    let p = providers.get(&name).ok_or(StatusCode::NOT_FOUND)?;
1738    Ok(Json(RuleProviderInfo::from_provider(p)))
1739}
1740
1741async fn refresh_rule_provider(
1742    State(state): State<Arc<AppState>>,
1743    Path(name): Path<String>,
1744) -> StatusCode {
1745    let provider = {
1746        let providers = state.rule_providers.read();
1747        providers.get(&name).cloned()
1748    };
1749    let Some(p) = provider else {
1750        return StatusCode::NOT_FOUND;
1751    };
1752    let ctx = meow_rules::ParserContext::empty();
1753    match p.refresh(&ctx).await {
1754        Ok(()) => StatusCode::NO_CONTENT,
1755        Err(e) => {
1756            tracing::warn!(provider = %name, "rule-provider refresh failed: {:#}", e);
1757            StatusCode::SERVICE_UNAVAILABLE
1758        }
1759    }
1760}
1761
1762// ── Listeners ─────────────────────────────────────────────────────────
1763
1764async fn get_listeners(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
1765    let items: Vec<serde_json::Value> = state
1766        .listeners
1767        .iter()
1768        .map(|l| {
1769            serde_json::json!({
1770                "name": l.name,
1771                "type": l.listener_type.to_string(),
1772                "port": l.port,
1773                "listen": l.listen,
1774            })
1775        })
1776        .collect();
1777    Json(serde_json::json!(items))
1778}