From 1d17c5cee92ba814888fbb454dafa1c2f29d163d Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:08:42 +0800 Subject: [PATCH 01/70] feat(dns): integrate hijacking and fake-IP routing Modernize DNS client handling, remove resolved takeover, preserve UDP hijack rules, route DNS through TUN, and keep fake-IP routing consistent. --- clash-lib/src/app/dns/dns_client.rs | 175 ++++++-- clash-lib/src/app/dns/fakeip/mod.rs | 141 +++++-- clash-lib/src/app/dns/resolver/enhanced.rs | 22 +- clash-lib/src/app/dns/resolver/system.rs | 4 +- clash-lib/src/app/dns/server/mod.rs | 406 +------------------ clash-lib/src/config/def.rs | 6 +- clash-lib/src/config/internal/config.rs | 183 +++++---- clash-lib/src/config/internal/convert/mod.rs | 90 +++- clash-lib/src/config/internal/convert/tun.rs | 174 +++++++- clash-lib/src/lib.rs | 39 +- clash-lib/src/proxy/tun/datagram.rs | 52 ++- clash-lib/src/proxy/tun/routes/linux.rs | 173 +++----- clash-lib/src/proxy/tun/routes/mod.rs | 2 +- clash-lib/src/proxy/tun/runner.rs | 20 +- clash-lib/tests/tun_fake_ip_real_tests.rs | 4 +- 15 files changed, 735 insertions(+), 756 deletions(-) diff --git a/clash-lib/src/app/dns/dns_client.rs b/clash-lib/src/app/dns/dns_client.rs index 68834747..3929ed55 100644 --- a/clash-lib/src/app/dns/dns_client.rs +++ b/clash-lib/src/app/dns/dns_client.rs @@ -25,7 +25,7 @@ use hickory_proto::{ }; use rustls::{ClientConfig, pki_types::ServerName}; use tokio::{sync::RwLock, task::JoinHandle}; -use tracing::{info, instrument, trace, warn}; +use tracing::{debug, info, instrument, trace, warn}; use crate::{ Error, @@ -63,7 +63,7 @@ impl Display for DNSNetMode { #[cfg(test)] mod tests { use super::*; - use crate::proxy; + use crate::{app::dns::MockClashResolver, proxy}; use hickory_proto::{ op, rr::{Name, rdata::opt::EdnsOption}, @@ -78,8 +78,9 @@ mod tests { c: None, bg_handle: None, })), - cfg: DnsConfig::Udp(addr, None, proxy.clone(), None), + cfg: RwLock::new(DnsConfig::Udp(addr, None, proxy.clone(), None)), proxy, + bootstrap_resolver: None, host: url::Host::Domain("example.org".to_string()), port: 53, net: DNSNetMode::Udp, @@ -89,6 +90,28 @@ mod tests { } } + #[tokio::test] + async fn refresh_upstream_address_uses_bootstrap_resolver() { + let mut resolver = MockClashResolver::new(); + resolver + .expect_resolve() + .with( + mockall::predicate::eq("example.org"), + mockall::predicate::eq(false), + ) + .once() + .returning(|_, _| Ok(Some(net::IpAddr::from([203, 0, 113, 10])))); + + let mut client = client_with_ecs(None); + client.bootstrap_resolver = Some(Arc::new(resolver)); + + assert!(client.refresh_upstream_address().await.unwrap()); + assert_eq!( + client.cfg.read().await.addr(), + net::SocketAddr::from(([203, 0, 113, 10], 53)) + ); + } + fn build_message(record_type: RecordType) -> Message { let mut msg = Message::new( 0, @@ -226,6 +249,7 @@ pub struct Opts { type FwMark = Option; +#[derive(Clone)] enum DnsConfig { Udp( net::SocketAddr, @@ -255,6 +279,26 @@ enum DnsConfig { ), } +impl DnsConfig { + fn addr(&self) -> net::SocketAddr { + match self { + Self::Udp(addr, ..) + | Self::Tcp(addr, ..) + | Self::Tls(addr, ..) + | Self::Https(addr, ..) => *addr, + } + } + + fn set_ip(&mut self, ip: IpAddr) { + match self { + Self::Udp(addr, ..) + | Self::Tcp(addr, ..) + | Self::Tls(addr, ..) + | Self::Https(addr, ..) => addr.set_ip(ip), + } + } +} + impl Display for DnsConfig { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match &self { @@ -303,8 +347,9 @@ struct Inner { pub struct DnsClient { inner: Arc>, - cfg: DnsConfig, + cfg: RwLock, proxy: Arc, + bootstrap_resolver: Option>, // debug purpose host: url::Host, @@ -316,53 +361,111 @@ pub struct DnsClient { } impl DnsClient { - /// Rebuild the DNS stream with retries, waiting between attempts. - /// Network transitions can briefly make outbound sockets unavailable; a - /// short retry loop avoids permanently failing the resolver on a transient - /// rebuild error. - async fn rebuild_with_retries( + async fn build_stream( &self, - ) -> anyhow::Result<(client::Client, JoinHandle<()>)> { + ) -> Result<(client::Client, JoinHandle<()>), Error> { + let cfg = self.cfg.read().await.clone(); + dns_stream_builder(&cfg, self.rule_dispatch.clone()).await + } + + async fn refresh_upstream_address(&self) -> anyhow::Result { + let url::Host::Domain(domain) = &self.host else { + return Ok(false); + }; + let Some(resolver) = &self.bootstrap_resolver else { + return Ok(false); + }; + let Some(ip) = resolver.resolve(domain, false).await? else { + return Err(Error::DNSError(format!( + "unable to refresh DNS upstream address for {domain}" + )) + .into()); + }; + + let mut cfg = self.cfg.write().await; + let old_addr = cfg.addr(); + if old_addr.ip() == ip { + debug!( + upstream = %self.id(), + address = %old_addr, + "DNS upstream address refresh returned the current address" + ); + return Ok(false); + } + + cfg.set_ip(ip); + info!( + upstream = %self.id(), + old_address = %old_addr, + new_address = %cfg.addr(), + "refreshed DNS upstream address after connection failures" + ); + Ok(true) + } + + async fn rebuild_current_address( + &self, + address_refreshed: bool, + ) -> Result<(client::Client, JoinHandle<()>), Error> { const MAX_RETRIES: u32 = 3; const RETRY_DELAY: Duration = Duration::from_millis(200); for attempt in 0..=MAX_RETRIES { - match dns_stream_builder(&self.cfg, self.rule_dispatch.clone()).await { + match self.build_stream().await { Ok(result) => { if attempt > 0 { info!( - "{}: dns client rebuild succeeded on attempt {}/{}", - self.id(), - attempt + 1, - MAX_RETRIES + 1 + upstream = %self.id(), + address_refreshed, + attempt = attempt + 1, + max_attempts = MAX_RETRIES + 1, + "dns client rebuild succeeded" ); } return Ok(result); } Err(err) if attempt < MAX_RETRIES => { warn!( - "{}: dns client rebuild attempt {}/{} failed: {err:#}, retrying in {}ms", - self.id(), - attempt + 1, - MAX_RETRIES + 1, - RETRY_DELAY.as_millis() + upstream = %self.id(), + address_refreshed, + attempt = attempt + 1, + max_attempts = MAX_RETRIES + 1, + retry_delay_ms = RETRY_DELAY.as_millis(), + error = %err, + "dns client rebuild failed, retrying" ); tokio::time::sleep(RETRY_DELAY).await; } - Err(err) => { - warn!( - "{}: dns client rebuild failed after {} attempts: {err:#}", - self.id(), - MAX_RETRIES + 1 - ); - return Err(err.into()); - } + Err(err) => return Err(err), } } unreachable!() } + /// Rebuild the DNS stream with retries, waiting between attempts. + /// Network transitions can briefly make outbound sockets unavailable; a + /// short retry loop avoids permanently failing the resolver on a transient + /// rebuild error. + async fn rebuild_with_retries( + &self, + ) -> anyhow::Result<(client::Client, JoinHandle<()>)> { + let err = match self.rebuild_current_address(false).await { + Ok(result) => return Ok(result), + Err(err) => err, + }; + warn!( + upstream = %self.id(), + error = %err, + "dns client rebuild attempts exhausted, refreshing upstream address" + ); + if !self.refresh_upstream_address().await? { + return Err(err.into()); + } + + self.rebuild_current_address(true).await.map_err(Into::into) + } + pub async fn new_client(opts: Opts) -> anyhow::Result { // TODO: use proxy to connect? @@ -385,7 +488,7 @@ impl DnsClient { }; let resolved_ip = match need_resolve { - Some(domain) => match opts.father { + Some(domain) => match opts.father.as_ref() { Some(father) => match father.resolve(domain, false).await? { Some(ip) => Some(ip), _ => { @@ -427,8 +530,9 @@ impl DnsClient { c: None, bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -450,8 +554,9 @@ impl DnsClient { bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -473,8 +578,9 @@ impl DnsClient { c: None, bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -497,8 +603,9 @@ impl DnsClient { bg_handle: None, })), - cfg, + cfg: RwLock::new(cfg), proxy: opts.proxy, + bootstrap_resolver: opts.father, host: opts.host, port: opts.port, net: opts.net, @@ -612,7 +719,7 @@ impl Client for DnsClient { } _ => { // initializing client - info!("initializing dns client: {}", &self.cfg); + info!("initializing dns client: {}", self.cfg.read().await); let (client, bg) = self.rebuild_with_retries().await?; inner.c.replace(client); inner.bg_handle.replace(bg); diff --git a/clash-lib/src/app/dns/fakeip/mod.rs b/clash-lib/src/app/dns/fakeip/mod.rs index 2e92794b..11806791 100644 --- a/clash-lib/src/app/dns/fakeip/mod.rs +++ b/clash-lib/src/app/dns/fakeip/mod.rs @@ -33,11 +33,11 @@ pub trait Store: Sync + Send { pub type ThreadSafeFakeDns = Arc>; pub struct FakeDns { - max: u32, - min: u32, + first: u32, + capacity: u32, #[allow(dead_code)] gateway: u32, - offset: u32, + cursor: u32, skipped_hostnames: Option>, ipnet: ipnet::IpNet, store: Box, @@ -45,26 +45,33 @@ pub struct FakeDns { impl FakeDns { pub fn new(opt: Opts) -> Result { - let ip = match opt.ipnet.network() { - net::IpAddr::V4(ip) => ip, - _ => unreachable!("fakeip range must be valid ipv4 subnet"), + let network = match opt.ipnet { + ipnet::IpNet::V4(network) => network, + ipnet::IpNet::V6(_) => { + return Err(Error::InvalidConfig( + "fake-ip-range must be an IPv4 subnet".to_string(), + )); + } }; - // avoid tun gateway and its subnet broadcast - let min = Self::ip_to_uint(&ip) + 8; - let prefix_len = opt.ipnet.prefix_len(); - let max_prefix_len = opt.ipnet.max_prefix_len(); - debug_assert_eq!(max_prefix_len, 32, "v4 subnet"); - - // do not allocate the last 16 IPs in the range, to avoid broadcast and multicast addresses. - let total = (1 << (max_prefix_len - prefix_len)) - 16; + if network.prefix_len() > 30 { + return Err(Error::InvalidConfig( + "fake-ip-range must contain a network address, a gateway, at \ + least one allocatable address, and a broadcast address" + .to_string(), + )); + } - let max = min + total - 1; + let network_addr = Self::ip_to_uint(&network.network()); + let broadcast = Self::ip_to_uint(&network.broadcast()); + let gateway = network_addr + 1; + let first = network_addr + 2; + let capacity = broadcast - first; Ok(Self { - max, - min, - gateway: min - 1, - offset: 0, + first, + capacity, + gateway, + cursor: 0, skipped_hostnames: opt.skipped_hostnames, ipnet: opt.ipnet, store: opt.store, @@ -155,32 +162,26 @@ impl FakeDns { } async fn get(&mut self, host: &str) -> net::IpAddr { - let current = self.offset; - - loop { - self.offset = (self.offset + 1) % (self.max - self.min); - - if self.offset == current { - self.offset = (self.offset + 1) % (self.max - self.min); - let ip = net::Ipv4Addr::from(self.min + self.offset - 1); - info!( - fake_ip = %ip, - range = %self.ipnet, - "fake-ip pool full, evicting previous mapping" - ); - self.store.del_by_ip(std::net::IpAddr::V4(ip)).await; - break; - } - - let ip = net::Ipv4Addr::from(self.min + self.offset - 1); - if !self.store.exist(std::net::IpAddr::V4(ip)).await { - break; + for _ in 0..self.capacity { + let ip = net::Ipv4Addr::from(self.first + self.cursor); + self.cursor = (self.cursor + 1) % self.capacity; + if !self.store.exist(net::IpAddr::V4(ip)).await { + self.store.put_by_ip(net::IpAddr::V4(ip), host).await; + return net::IpAddr::V4(ip); } } - let ip = net::Ipv4Addr::from(self.min + self.offset - 1); - self.store.put_by_ip(std::net::IpAddr::V4(ip), host).await; - std::net::IpAddr::V4(ip) + // The pool is full. Reuse the oldest candidate selected by the cursor. + let ip = net::Ipv4Addr::from(self.first + self.cursor); + self.cursor = (self.cursor + 1) % self.capacity; + self.store.del_by_ip(net::IpAddr::V4(ip)).await; + info!( + fake_ip = %ip, + range = %self.ipnet, + "fake-ip pool full, evicting previous mapping" + ); + self.store.put_by_ip(net::IpAddr::V4(ip), host).await; + net::IpAddr::V4(ip) } fn ip_to_uint(ip: &net::Ipv4Addr) -> u32 { @@ -227,6 +228,62 @@ mod tests { assert!(!pool.exist("::1".parse().unwrap()).await); } + #[tokio::test] + async fn test_allocates_every_usable_address_without_gateway_or_broadcast() { + let store = Box::new(InMemStore::new(10)); + let mut pool = FakeDns::new(Opts { + ipnet: "192.168.0.0/29".parse().unwrap(), + skipped_hostnames: None, + store, + }) + .unwrap(); + + let mut allocated = Vec::new(); + for index in 0..5 { + allocated.push(pool.lookup(&format!("{index}.example")).await); + } + + assert_eq!( + allocated, + [ + "192.168.0.2", + "192.168.0.3", + "192.168.0.4", + "192.168.0.5", + "192.168.0.6" + ] + .map(|ip| ip.parse::().unwrap()) + ); + } + + #[tokio::test] + async fn test_30_pool_allocates_its_single_usable_address() { + let store = Box::new(InMemStore::new(10)); + let mut pool = FakeDns::new(Opts { + ipnet: "192.168.0.0/30".parse().unwrap(), + skipped_hostnames: None, + store, + }) + .unwrap(); + + assert_eq!( + pool.lookup("example.com").await, + "192.168.0.2".parse::().unwrap() + ); + } + + #[test] + fn test_rejects_too_small_or_ipv6_pool() { + for ipnet in ["192.168.0.0/31", "192.168.0.1/32", "fd00::/64"] { + let result = FakeDns::new(Opts { + ipnet: ipnet.parse().unwrap(), + skipped_hostnames: None, + store: Box::new(InMemStore::new(10)), + }); + assert!(result.is_err(), "{ipnet} must be rejected"); + } + } + #[tokio::test] async fn test_inmem_cycle_used() { let store = Box::new(InMemStore::new(10)); diff --git a/clash-lib/src/app/dns/resolver/enhanced.rs b/clash-lib/src/app/dns/resolver/enhanced.rs index 88f8bdf1..4fcd3c88 100644 --- a/clash-lib/src/app/dns/resolver/enhanced.rs +++ b/clash-lib/src/app/dns/resolver/enhanced.rs @@ -335,12 +335,22 @@ impl EnhancedResolver { let client_id = c.id(); c.exchange(message) .inspect_err(|x| { - error!( - client = %client_id, - query = %query_name, - record_type = %query_type, - err = ?x, - "resolve error"); + if x.to_string().contains("receiver was canceled") { + debug!( + client = %client_id, + query = %query_name, + record_type = %query_type, + "dns upstream query canceled after another response completed" + ); + } else { + error!( + client = %client_id, + query = %query_name, + record_type = %query_type, + err = ?x, + "resolve error" + ); + } }) .inspect_ok(|response| { let ips = Self::ip_list_of_message(response) diff --git a/clash-lib/src/app/dns/resolver/system.rs b/clash-lib/src/app/dns/resolver/system.rs index 65d905b2..630da5c8 100644 --- a/clash-lib/src/app/dns/resolver/system.rs +++ b/clash-lib/src/app/dns/resolver/system.rs @@ -135,7 +135,7 @@ mod tests { #[tokio::test] async fn test_system_resolver_default_config() { let resolver = SystemResolver::new(false).unwrap(); - let response = resolver.resolve("www.google.com", false).await.unwrap(); - assert!(response.is_some()); + let response = resolver.resolve("localhost", false).await.unwrap(); + assert!(response.is_some_and(|ip| ip.is_ipv4())); } } diff --git a/clash-lib/src/app/dns/server/mod.rs b/clash-lib/src/app/dns/server/mod.rs index 981f913a..311f5e1f 100644 --- a/clash-lib/src/app/dns/server/mod.rs +++ b/clash-lib/src/app/dns/server/mod.rs @@ -1,13 +1,7 @@ use futures::FutureExt; use hickory_proto::op::Message; -#[cfg(target_os = "linux")] -use network_interface::NetworkInterfaceConfig; use chimera_dns::DNSListenAddr; -use std::sync::{Arc, Mutex}; -#[allow(unused_mut)] -#[cfg(target_os = "linux")] -use std::{net::IpAddr, time::Duration}; use tracing::{error, info, instrument}; @@ -43,9 +37,6 @@ pub struct DnsRunner { listener: DNSListenAddr, resolver: ThreadSafeDNSResolver, cwd: std::path::PathBuf, - manage_system_resolver: bool, - managed_resolv_conf_backup: Arc>>, - managed_resolved_link: Arc>>, cancellation_token: tokio_util::sync::CancellationToken, task: std::sync::Mutex>>, @@ -57,7 +48,6 @@ impl DnsRunner { listen: DNSListenAddr, resolver: ThreadSafeDNSResolver, cwd: &std::path::Path, - manage_system_resolver: bool, cancellation_token: Option, ) -> Self { Self { @@ -65,389 +55,39 @@ impl DnsRunner { listener: listen, resolver, cwd: cwd.to_path_buf(), - manage_system_resolver, - managed_resolv_conf_backup: Arc::new(Mutex::new(None)), - managed_resolved_link: Arc::new(Mutex::new(None)), cancellation_token: cancellation_token.unwrap_or_default(), task: std::sync::Mutex::new(None), } } } -#[cfg(target_os = "linux")] -const MANAGED_RESOLV_CONF_MARKER: &str = - "# managed by Chimera Client while tun dns-hijack is active"; - -#[cfg(target_os = "linux")] -const DNS_BIND_TARGET_READY_MAX_ATTEMPTS: u32 = 100; - -#[cfg(target_os = "linux")] -const DNS_BIND_TARGET_READY_POLL_INTERVAL_MS: u64 = 50; - -#[cfg(target_os = "linux")] -fn linux_listener_target_ips(listen: &DNSListenAddr) -> Vec { - let mut targets = Vec::new(); - - for ip in listen - .udp - .iter() - .map(|addr| addr.ip()) - .chain(listen.tcp.iter().map(|addr| addr.ip())) - .chain(listen.doh.iter().map(|cfg| cfg.addr.ip())) - .chain(listen.dot.iter().map(|cfg| cfg.addr.ip())) - .chain(listen.doh3.iter().map(|cfg| cfg.addr.ip())) - { - if !targets.contains(&ip) { - targets.push(ip); - } - } - - targets -} - -#[cfg(target_os = "linux")] -fn find_interface_name_by_ip(ip: IpAddr) -> Result, std::io::Error> { - let interfaces = network_interface::NetworkInterface::show() - .map_err(|err| std::io::Error::other(err.to_string()))?; - - for iface in interfaces { - for addr in iface.addr { - match (ip, addr) { - (IpAddr::V4(target), network_interface::Addr::V4(addr)) - if addr.ip == target => - { - return Ok(Some(iface.name)); - } - (IpAddr::V6(target), network_interface::Addr::V6(addr)) - if addr.ip == target => - { - return Ok(Some(iface.name)); - } - _ => {} - } - } - } - - Ok(None) -} - -#[cfg(target_os = "linux")] -async fn wait_for_linux_dns_listener_targets( - listen: &DNSListenAddr, - cancellation_token: &tokio_util::sync::CancellationToken, -) -> bool { - let targets = linux_listener_target_ips(listen) - .into_iter() - .filter(|ip| !ip.is_loopback()) - .collect::>(); - - if targets.is_empty() { - return true; - } - - for _ in 0..DNS_BIND_TARGET_READY_MAX_ATTEMPTS { - let mut ready = true; - for target in &targets { - match find_interface_name_by_ip(*target) { - Ok(Some(_)) => {} - Ok(None) => { - ready = false; - break; - } - Err(err) => { - error!( - "failed to inspect interfaces while waiting for dns bind target {}: {}", - target, err - ); - ready = false; - break; - } - } - } - - if ready { - return true; - } - - tokio::select! { - _ = cancellation_token.cancelled() => return false, - _ = tokio::time::sleep(Duration::from_millis( - DNS_BIND_TARGET_READY_POLL_INTERVAL_MS, - )) => {} - } - } - - error!( - "dns listener bind targets never became ready: {:?}", - targets - ); - false -} - -#[cfg(not(target_os = "linux"))] -async fn wait_for_linux_dns_listener_targets( - _: &DNSListenAddr, - _: &tokio_util::sync::CancellationToken, -) -> bool { - true -} - -#[cfg(target_os = "linux")] -fn run_resolvectl(args: &[&str]) -> Result<(), std::io::Error> { - let output = std::process::Command::new("resolvectl") - .args(args) - .output()?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let details = if stderr.is_empty() { stdout } else { stderr }; - - Err(std::io::Error::other(format!( - "resolvectl {} failed: {}", - args.join(" "), - details - ))) -} - -#[cfg(target_os = "linux")] -async fn maybe_take_over_linux_resolved_link( - listen: &DNSListenAddr, - managed_link: &Arc>>, - enabled: bool, -) -> bool { - if !enabled { - return false; - } - - let Some(addr) = listen.udp else { - return false; - }; - - if addr.port() != 53 || addr.ip().is_loopback() { - return false; - } - - let link_name = match find_interface_name_by_ip(addr.ip()) { - Ok(Some(link_name)) => link_name, - Ok(None) => { - info!( - "linux per-link dns takeover skipped because no interface owns {}", - addr.ip() - ); - return false; - } - Err(err) => { - error!( - "failed to resolve interface for linux per-link dns takeover: {}", - err - ); - return false; - } - }; - - let dns_ip = addr.ip().to_string(); - let commands = [ - vec!["dns", link_name.as_str(), dns_ip.as_str()], - vec!["domain", link_name.as_str(), "~."], - vec!["default-route", link_name.as_str(), "yes"], - vec!["llmnr", link_name.as_str(), "no"], - vec!["mdns", link_name.as_str(), "no"], - vec!["dnssec", link_name.as_str(), "no"], - vec!["dnsovertls", link_name.as_str(), "no"], - ]; - - for command in &commands { - if let Err(err) = run_resolvectl(command) { - error!( - "failed to configure systemd-resolved per-link dns on {}: {}", - link_name, err - ); - let _ = run_resolvectl(&["revert", link_name.as_str()]); - return false; - } - } - - { - let mut guard = managed_link.lock().unwrap(); - *guard = Some(link_name.clone()); - } - - info!( - "configured systemd-resolved per-link dns on {} via {}", - link_name, addr - ); - true -} - -#[cfg(target_os = "linux")] -async fn maybe_take_over_linux_stub_resolver( - listen: &DNSListenAddr, - backup: &Arc>>, - managed_link: &Arc>>, - enabled: bool, -) { - if !enabled { - return; - } - - if maybe_take_over_linux_resolved_link(listen, managed_link, enabled).await { - return; - } - - let Some(addr) = listen.udp else { - return; - }; - - if addr.port() != 53 { - return; - } - - let path = "/etc/resolv.conf"; - let current = match tokio::fs::read_to_string(path).await { - Ok(current) => current, - Err(err) => { - error!("failed to read {}: {}", path, err); - return; - } - }; - - if current.contains(MANAGED_RESOLV_CONF_MARKER) { - info!("linux stub resolver takeover is already active"); - return; - } - - if !current.contains("127.0.0.53") - && !current.contains("managed by man:systemd-resolved") - { - info!( - "linux stub resolver takeover skipped because /etc/resolv.conf is not using systemd-resolved stub" - ); - return; - } - - { - let mut guard = backup.lock().unwrap(); - if guard.is_none() { - *guard = Some(current.clone()); - } - } - - let replacement = format!( - "{MANAGED_RESOLV_CONF_MARKER}\nnameserver {}\noptions edns0 trust-ad\nsearch .\n", - addr.ip() - ); - - match tokio::fs::write(path, replacement).await { - Ok(()) => { - info!( - "temporarily redirected linux stub resolver to local dns listener {}", - addr - ); - } - Err(err) => { - error!("failed to update {}: {}", path, err); - } - } -} - -#[cfg(not(target_os = "linux"))] -async fn maybe_take_over_linux_stub_resolver( - _: &DNSListenAddr, - _: &Arc>>, - _: &Arc>>, - _: bool, -) { -} - -#[cfg(target_os = "linux")] -async fn maybe_restore_linux_stub_resolver( - backup: &Arc>>, - managed_link: &Arc>>, -) { - if let Some(link_name) = { - let mut guard = managed_link.lock().unwrap(); - guard.take() - } { - match run_resolvectl(&["revert", link_name.as_str()]) { - Ok(()) => { - info!( - "restored original systemd-resolved per-link dns configuration for {}", - link_name - ); - } - Err(err) => { - error!( - "failed to revert systemd-resolved per-link dns for {}: {}", - link_name, err - ); - } - } - } - - let previous = { - let mut guard = backup.lock().unwrap(); - guard.take() - }; - - let Some(previous) = previous else { - return; - }; - - let path = "/etc/resolv.conf"; - match tokio::fs::write(path, previous).await { - Ok(()) => { - info!("restored original linux stub resolver configuration"); - } - Err(err) => { - error!("failed to restore {}: {}", path, err); - } - } -} - -#[cfg(not(target_os = "linux"))] -async fn maybe_restore_linux_stub_resolver( - _: &Arc>>, - _: &Arc>>, -) { -} - impl Runner for DnsRunner { fn run_async(&self) { if !self.enable { info!("dns listener is disabled, skipping"); return; } + if self.listener.udp.is_none() + && self.listener.tcp.is_none() + && self.listener.doh.is_none() + && self.listener.dot.is_none() + && self.listener.doh3.is_none() + { + info!( + "dns listener is not configured; internal resolver remains available" + ); + return; + } let resolver = self.resolver.clone(); let listen = self.listener.clone(); - let listen_for_server = listen.clone(); let cwd = self.cwd.clone(); - let manage_system_resolver = self.manage_system_resolver; - let managed_resolv_conf_backup = self.managed_resolv_conf_backup.clone(); - let managed_resolved_link = self.managed_resolved_link.clone(); let cancellation_token = self.cancellation_token.clone(); let handle = tokio::spawn(async move { - if !wait_for_linux_dns_listener_targets(&listen, &cancellation_token) - .await - { - return; - } - let h = DnsMessageExchanger { resolver }; - let r = chimera_dns::get_dns_listener(listen_for_server, h, &cwd).await; + let r = chimera_dns::get_dns_listener(listen, h, &cwd).await; if let Some(r) = r { - maybe_take_over_linux_stub_resolver( - &listen, - &managed_resolv_conf_backup, - &managed_resolved_link, - manage_system_resolver, - ) - .await; tokio::select! { res = r => { match res { @@ -456,23 +96,16 @@ impl Runner for DnsRunner { error!("dns listener error: {}", err); } } - maybe_restore_linux_stub_resolver( - &managed_resolv_conf_backup, - &managed_resolved_link, - ) - .await; }, _ = cancellation_token.cancelled() => { info!("dns listener is closed"); - maybe_restore_linux_stub_resolver( - &managed_resolv_conf_backup, - &managed_resolved_link, - ) - .await; }, } } else { - info!("dns listener: no listen addresses configured, skipping"); + error!( + "dns listener: no listener started; no addresses were configured or all \ + configured addresses failed to bind" + ); } }); @@ -488,13 +121,12 @@ impl Runner for DnsRunner { fn join(&self) -> futures::future::BoxFuture<'_, Result<(), crate::Error>> { let handle = self.task.lock().unwrap().take(); async move { - match handle { - Some(handle) => handle.await.map_err(|err| { + if let Some(handle) = handle { + handle.await.map_err(|err| { crate::Error::Operation(format!( "dns listener join error: {err}" )) - })?, - None => {} + })?; } Ok(()) diff --git a/clash-lib/src/config/def.rs b/clash-lib/src/config/def.rs index ffdc7217..fb0d6c2f 100644 --- a/clash-lib/src/config/def.rs +++ b/clash-lib/src/config/def.rs @@ -23,7 +23,8 @@ fn default_tun_device_id() -> String { } fn default_tun_address() -> String { - // reference: mihomo + // Keep the TUN link subnet separate from the default 198.19.0.0/16 + // fake-IP pool. Fake-IP traffic is routed to TUN explicitly. "198.18.0.1/30".to_string() } @@ -106,6 +107,7 @@ pub struct Config { pub rule: Option>, /// 6. Log level + /// /// Either `debug`, `info`, `warning`, `error` or `off` pub log_level: LogLevel, /// external controller address @@ -187,6 +189,7 @@ pub struct Config { /// experimental settings, if any pub experimental: Option, /// 15. Clash router working mode + /// /// Either `rule`, `global` or `direct` #[serde(default)] pub mode: RunMode, @@ -407,6 +410,7 @@ pub struct DNS { /// disabled. pub listen: Option, /// 3. When disabled, system DNS config will be used + /// /// All other DNS related options will only be used when this is enabled pub enable: bool, /// Whether to use `Config::hosts` when resolving hostnames diff --git a/clash-lib/src/config/internal/config.rs b/clash-lib/src/config/internal/config.rs index 01194277..2f7ae8d4 100644 --- a/clash-lib/src/config/internal/config.rs +++ b/clash-lib/src/config/internal/config.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, HashSet}, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, str::FromStr, }; @@ -205,19 +205,56 @@ pub struct TunConfig { pub so_mark: Option, pub route_table: u32, pub dns_hijack: bool, + pub dns_hijack_rules: Vec, } -impl TunConfig { - pub fn dedicated_dns_ipv4(&self) -> Option { - let network = u32::from(self.gateway.network()); - let broadcast = u32::from(self.gateway.broadcast()); - let candidate = network.checked_add(2)?; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DnsHijackProtocol { + Udp, + Tcp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DnsHijackAddress { + Any, + Ip(IpAddr), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DnsHijackRule { + pub protocol: DnsHijackProtocol, + pub address: DnsHijackAddress, + pub port: u16, +} - if candidate >= broadcast { - return None; +impl DnsHijackRule { + pub fn matches_udp(&self, destination: SocketAddr) -> bool { + self.protocol == DnsHijackProtocol::Udp + && self.port == destination.port() + && match self.address { + DnsHijackAddress::Any => true, + DnsHijackAddress::Ip(address) => address == destination.ip(), + } + } +} + +impl TunConfig { + pub fn dns_hijack_udp_ports(&self) -> Vec { + if !self.dns_hijack { + return Vec::new(); + } + if self.dns_hijack_rules.is_empty() { + return vec![53]; } - Some(Ipv4Addr::from(candidate)) + let mut ports = Vec::new(); + for rule in &self.dns_hijack_rules { + if rule.protocol == DnsHijackProtocol::Udp && !ports.contains(&rule.port) + { + ports.push(rule.port); + } + } + ports } } @@ -263,97 +300,75 @@ pub struct InlineRuleProvider { #[cfg(test)] mod tests { - use std::net::Ipv4Addr; + use std::net::{IpAddr, SocketAddr}; - use super::TunConfig; - - #[test] - fn dedicated_dns_is_gateway_plus_one_in_24() { - let tun = TunConfig { - gateway: "198.18.0.1/24".parse().unwrap(), - ..Default::default() - }; - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(198, 18, 0, 2))); - } - - #[test] - fn dedicated_dns_in_16_subnet() { - let tun = TunConfig { - gateway: "10.0.0.1/16".parse().unwrap(), - ..Default::default() - }; - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(10, 0, 0, 2))); - } + use super::{DnsHijackAddress, DnsHijackProtocol, DnsHijackRule, TunConfig}; #[test] - fn dedicated_dns_gateway_is_network_plus_one() { - let tun = TunConfig { - gateway: "172.16.0.5/24".parse().unwrap(), - ..Default::default() + fn dns_hijack_rule_matches_udp_destination() { + let any = DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 53, }; - // gateway addr (172.16.0.5) != network+1 (172.16.0.1), but - // dedicated_dns_ipv4 computes from network address: network+2 = 172.16.0.2 - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(172, 16, 0, 2))); - } - - #[test] - fn dedicated_dns_in_30_subnet_room_for_dns() { - let tun = TunConfig { - gateway: "10.0.0.1/30".parse().unwrap(), - ..Default::default() + let specific = DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Ip("1.1.1.1".parse().unwrap()), + port: 53, }; - // /30 subnet: network=10.0.0.0, broadcast=10.0.0.3 - // network+2 = 10.0.0.2 < 10.0.0.3, so Some(10.0.0.2) - assert_eq!(tun.dedicated_dns_ipv4(), Some(Ipv4Addr::new(10, 0, 0, 2))); - } - - #[test] - fn dedicated_dns_in_31_subnet_too_small() { - let tun = TunConfig { - gateway: "10.0.0.1/31".parse().unwrap(), - ..Default::default() + let tcp = DnsHijackRule { + protocol: DnsHijackProtocol::Tcp, + ..any }; - // /31 subnet: network=10.0.0.0, broadcast=10.0.0.1 - // candidate overflow would clamp, so None - assert_eq!(tun.dedicated_dns_ipv4(), None); - } - #[test] - fn dedicated_dns_in_32_subnet_too_small() { - let tun = TunConfig { - gateway: "10.0.0.1/32".parse().unwrap(), - ..Default::default() + assert!(any.matches_udp("8.8.8.8:53".parse().unwrap())); + assert!(!any.matches_udp("8.8.8.8:5353".parse().unwrap())); + assert!(specific.matches_udp("1.1.1.1:53".parse().unwrap())); + assert!(!specific.matches_udp("8.8.8.8:53".parse().unwrap())); + assert!(!tcp.matches_udp("1.1.1.1:53".parse().unwrap())); + + let ipv6 = DnsHijackRule { + address: DnsHijackAddress::Ip(IpAddr::V6( + "2001:4860:4860::8888".parse().unwrap(), + )), + ..any }; - // /32 subnet: network=10.0.0.1, broadcast=10.0.0.1 - // network+2 = 10.0.0.3 > 10.0.0.1, so None - assert_eq!(tun.dedicated_dns_ipv4(), None); + assert!(ipv6.matches_udp(SocketAddr::new( + "2001:4860:4860::8888".parse().unwrap(), + 53 + ))); } #[test] - fn dedicated_dns_overflow_protection() { - // Use network address near u32::MAX to test checked_add safety + fn dns_hijack_udp_ports_follow_protocol_rules() { let tun = TunConfig { - gateway: "255.255.255.0/24".parse().unwrap(), + dns_hijack: true, + dns_hijack_rules: vec![ + DnsHijackRule { + protocol: DnsHijackProtocol::Tcp, + address: DnsHijackAddress::Any, + port: 53, + }, + DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 5353, + }, + DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 5353, + }, + ], ..Default::default() }; - // network = 255.255.255.0, broadcast = 255.255.255.255 - // network+2 = 255.255.255.2 <= broadcast = 255.255.255.255, ok - assert_eq!( - tun.dedicated_dns_ipv4(), - Some(Ipv4Addr::new(255, 255, 255, 2)) - ); - } + assert_eq!(tun.dns_hijack_udp_ports(), vec![5353]); - #[test] - fn dedicated_dns_default_gateway() { - let tun = TunConfig { - gateway: "198.18.0.1/24".parse().unwrap(), + let legacy = TunConfig { + dns_hijack: true, ..Default::default() }; - let dns = tun.dedicated_dns_ipv4().unwrap(); - // DNS must not be the gateway IP itself - assert_ne!(dns, Ipv4Addr::new(198, 18, 0, 1)); - // DNS must not be a public IP - assert!(!dns.is_global()); + assert_eq!(legacy.dns_hijack_udp_ports(), vec![53]); + assert!(TunConfig::default().dns_hijack_udp_ports().is_empty()); } } diff --git a/clash-lib/src/config/internal/convert/mod.rs b/clash-lib/src/config/internal/convert/mod.rs index b2e15504..15b3dcdf 100644 --- a/clash-lib/src/config/internal/convert/mod.rs +++ b/clash-lib/src/config/internal/convert/mod.rs @@ -52,6 +52,9 @@ pub(super) fn convert(mut c: def::Config) -> Result {} } } + let dns: crate::app::dns::Config = (&c).try_into()?; + let mut tun = tun::convert(c.tun.take())?; + configure_fake_ip_route(&dns, &mut tun)?; config::Config { proxies: c.proxy.take().unwrap_or_default().into_iter().try_fold( @@ -113,9 +116,8 @@ pub(super) fn convert(mut c: def::Config) -> Result, _>>()?, general: general::convert(&c)?, - // relate to dns::Config - dns: (&c).try_into()?, - tun: tun::convert(c.tun.take())?, + dns, + tun, experimental: c.experimental.take(), profile: Profile { store_selected: c.profile.store_selected, @@ -125,9 +127,44 @@ pub(super) fn convert(mut c: def::Config) -> Result Result<(), Error> { + if !tun.enable || !dns.enable || dns.enhance_mode != def::DNSMode::FakeIp { + return Ok(()); + } + + let fake_ip_range = match dns.fake_ip_range { + ipnet::IpNet::V4(range) => range, + ipnet::IpNet::V6(_) => { + return Err(Error::InvalidConfig( + "fake-ip-range must be an IPv4 subnet".to_string(), + )); + } + }; + + let tun_network = tun.gateway.trunc(); + if tun_network.contains(&fake_ip_range.network()) + || fake_ip_range.contains(&tun_network.network()) + { + return Err(Error::InvalidConfig(format!( + "tun gateway subnet `{tun_network}` overlaps fake-ip-range \ + `{fake_ip_range}`; use separate subnets" + ))); + } + + let fake_ip_route = ipnet::IpNet::V4(fake_ip_range.trunc()); + if !tun.route_all && !tun.routes.contains(&fake_ip_route) { + tun.routes.push(fake_ip_route); + } + + Ok(()) +} + #[cfg(test)] mod tests { - use crate::config::def; + use crate::{Error, config::def}; use super::convert; @@ -205,6 +242,51 @@ tun: assert!(converted.tun.route_all); } + #[test] + fn fake_ip_mode_adds_route_for_separate_default_pool() { + let mut cfg = parse_config( + r#" +tun: + enable: true +"#, + ); + cfg.dns.enable = true; + cfg.dns.enhanced_mode = def::DNSMode::FakeIp; + cfg.dns.nameserver = vec!["1.1.1.1".to_string()]; + + let converted = convert(cfg).expect("internal convert should succeed"); + assert_eq!(converted.tun.gateway.to_string(), "198.18.0.1/30"); + assert!( + converted + .tun + .routes + .contains(&"198.19.0.0/16".parse().unwrap()) + ); + } + + #[test] + fn reject_overlapping_tun_and_fake_ip_subnets() { + let mut cfg = parse_config( + r#" +tun: + enable: true + gateway: 198.18.0.1/30 +"#, + ); + cfg.dns.enable = true; + cfg.dns.enhanced_mode = def::DNSMode::FakeIp; + cfg.dns.fake_ip_range = "198.18.0.0/16".to_string(); + cfg.dns.nameserver = vec!["1.1.1.1".to_string()]; + + match convert(cfg) { + Err(Error::InvalidConfig(message)) => { + assert!(message.contains("overlaps fake-ip-range")); + } + Err(other) => panic!("unexpected error: {other}"), + Ok(_) => panic!("overlapping subnets must be rejected"), + } + } + #[test] fn parse_relay_group_with_proxies() { let cfg = parse_config( diff --git a/clash-lib/src/config/internal/convert/tun.rs b/clash-lib/src/config/internal/convert/tun.rs index 73697b68..456f8ed6 100644 --- a/clash-lib/src/config/internal/convert/tun.rs +++ b/clash-lib/src/config/internal/convert/tun.rs @@ -1,8 +1,88 @@ use crate::{ Error, - config::{def, internal::config}, + config::{ + def, + internal::config::{ + self, DnsHijackAddress, DnsHijackProtocol, DnsHijackRule, + }, + }, }; +fn parse_dns_hijack_rule(value: &str) -> Result { + let (protocol, address) = match value.split_once("://") { + Some(("udp", address)) => (DnsHijackProtocol::Udp, address), + Some(("tcp", address)) => (DnsHijackProtocol::Tcp, address), + Some((protocol, _)) => { + return Err(Error::InvalidConfig(format!( + "parse tun dns-hijack: unsupported protocol {protocol}" + ))); + } + None => (DnsHijackProtocol::Udp, value), + }; + + let (address, port) = address.rsplit_once(':').ok_or_else(|| { + Error::InvalidConfig(format!( + "parse tun dns-hijack: missing port in {value}" + )) + })?; + let port = port.parse::().map_err(|e| { + Error::InvalidConfig(format!("parse tun dns-hijack port in {value}: {e}")) + })?; + if port == 0 { + return Err(Error::InvalidConfig(format!( + "parse tun dns-hijack: port must not be zero in {value}" + ))); + } + + let address = if address == "any" { + DnsHijackAddress::Any + } else { + if address.contains(':') + && !(address.starts_with('[') && address.ends_with(']')) + { + return Err(Error::InvalidConfig(format!( + "parse tun dns-hijack: IPv6 address must be bracketed in {value}" + ))); + } + let address = address + .strip_prefix('[') + .and_then(|address| address.strip_suffix(']')) + .unwrap_or(address); + DnsHijackAddress::Ip(address.parse().map_err(|e| { + Error::InvalidConfig(format!( + "parse tun dns-hijack address in {value}: {e}" + )) + })?) + }; + + Ok(DnsHijackRule { + protocol, + address, + port, + }) +} + +fn parse_dns_hijack( + value: def::DnsHijack, +) -> Result<(bool, Vec), crate::Error> { + let (enabled, values) = match value { + def::DnsHijack::Switch(false) => return Ok((false, Vec::new())), + def::DnsHijack::Switch(true) => { + (true, vec!["any:53".to_string(), "tcp://any:53".to_string()]) + } + def::DnsHijack::List(values) => (true, values), + }; + + let mut rules = Vec::with_capacity(values.len()); + for value in values { + let rule = parse_dns_hijack_rule(&value)?; + if !rules.contains(&rule) { + rules.push(rule); + } + } + Ok((enabled, rules)) +} + pub(super) fn convert( before: Option, ) -> Result { @@ -24,6 +104,7 @@ pub(super) fn convert( match before { Some(t) => { + let (dns_hijack, dns_hijack_rules) = parse_dns_hijack(t.dns_hijack)?; let mut route_exclude_address = parse_routes(t.route_exclude_address, "route-exclude-address")?; @@ -79,10 +160,8 @@ pub(super) fn convert( mtu: t.mtu, so_mark: t.so_mark, route_table: t.route_table, - dns_hijack: match t.dns_hijack { - def::DnsHijack::Switch(v) => v, - def::DnsHijack::List(_) => true, - }, + dns_hijack, + dns_hijack_rules, }) } None => Ok(config::TunConfig::default()), @@ -91,7 +170,10 @@ pub(super) fn convert( #[cfg(test)] mod tests { - use crate::config::def; + use crate::config::{ + def, + internal::config::{DnsHijackAddress, DnsHijackProtocol, DnsHijackRule}, + }; use super::convert; @@ -115,7 +197,7 @@ mod tests { assert_eq!(converted.device_id, "utun1989"); assert_eq!(converted.route_table, 2468); - assert_eq!(converted.gateway.to_string(), "198.18.0.1/24"); + assert_eq!(converted.gateway.to_string(), "198.18.0.1/30"); assert!(!converted.dns_hijack); } @@ -130,6 +212,84 @@ dns-hijack: ); let converted = convert(Some(tun)).expect("tun convert should succeed"); assert!(converted.dns_hijack); + assert_eq!( + converted.dns_hijack_rules, + vec![DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Any, + port: 53, + }] + ); + } + + #[test] + fn expand_dns_hijack_true_to_udp_and_tcp() { + let converted = convert(Some(parse_tun("enable: true\ndns-hijack: true"))) + .expect("valid rule"); + + assert_eq!(converted.dns_hijack_rules.len(), 2); + assert_eq!( + converted.dns_hijack_rules[0].protocol, + DnsHijackProtocol::Udp + ); + assert_eq!( + converted.dns_hijack_rules[1].protocol, + DnsHijackProtocol::Tcp + ); + } + + #[test] + fn preserve_empty_dns_hijack_list_as_enabled() { + let converted = convert(Some(parse_tun("enable: true\ndns-hijack: []"))) + .expect("empty list remains compatible"); + + assert!(converted.dns_hijack); + assert!(converted.dns_hijack_rules.is_empty()); + } + + #[test] + fn parse_dns_hijack_addresses_and_remove_duplicates() { + let tun = parse_tun( + r#" +enable: true +dns-hijack: + - 1.1.1.1:53 + - tcp://[::1]:53 + - 1.1.1.1:53 +"#, + ); + let converted = convert(Some(tun)).expect("valid rules"); + + assert_eq!(converted.dns_hijack_rules.len(), 2); + assert_eq!( + converted.dns_hijack_rules[0].address, + DnsHijackAddress::Ip("1.1.1.1".parse().unwrap()) + ); + assert_eq!( + converted.dns_hijack_rules[1].address, + DnsHijackAddress::Ip("::1".parse().unwrap()) + ); + } + + #[test] + fn reject_invalid_dns_hijack_rules() { + for rule in [ + "quic://any:53", + "any", + "any:0", + "any:65536", + "not-an-ip:53", + "::1:53", + ] { + let tun = parse_tun(&format!("enable: true\ndns-hijack: [\"{rule}\"]")); + match convert(Some(tun)) { + Err(crate::Error::InvalidConfig(message)) => { + assert!(message.contains("parse tun dns-hijack")) + } + Err(other) => panic!("unexpected error for {rule}: {other}"), + Ok(_) => panic!("invalid rule should fail: {rule}"), + } + } } #[test] diff --git a/clash-lib/src/lib.rs b/clash-lib/src/lib.rs index 180ccb91..895c8993 100644 --- a/clash-lib/src/lib.rs +++ b/clash-lib/src/lib.rs @@ -461,15 +461,6 @@ impl RuntimeComponents { } } -#[cfg(feature = "tun")] -fn dns_listener_is_empty(listen: &DNSListenAddr) -> bool { - listen.udp.is_none() - && listen.tcp.is_none() - && listen.doh.is_none() - && listen.dot.is_none() - && listen.doh3.is_none() -} - async fn create_components( cwd: PathBuf, config: InternalConfig, @@ -548,32 +539,8 @@ async fn create_components( debug!("initializing dns resolver"); // Clone the dns.listen for the DNS Server later before we consume the config // TODO: we should separate the DNS resolver and DNS server config here - #[allow(unused_mut)] - let mut dns_listen = config.dns.listen.clone(); + let dns_listen = config.dns.listen.clone(); let dns_enable = config.dns.enable; - #[cfg(feature = "tun")] - let auto_manage_linux_dns = cfg!(target_os = "linux") - && config.tun.enable - && config.tun.dns_hijack - && dns_enable - && dns_listener_is_empty(&dns_listen); - - #[cfg(feature = "tun")] - if auto_manage_linux_dns { - let dedicated_dns_ip = config.tun.dedicated_dns_ipv4().ok_or_else(|| { - Error::InvalidConfig( - "tun dns-hijack requires a subnet with room for a dedicated DNS address" - .to_string(), - ) - })?; - let tun_dns_addr = std::net::SocketAddr::from((dedicated_dns_ip, 53)); - dns_listen.udp = Some(tun_dns_addr); - dns_listen.tcp = Some(tun_dns_addr); - info!( - "auto-enabling linux tun DNS listener on {} for systemd-resolved per-link takeover", - tun_dns_addr - ); - } // Extract the country MMDB file/url config early so they can be consumed // here, while the actual MMDB loading happens after OutboundManager (like @@ -775,10 +742,6 @@ async fn create_components( dns_listen.clone(), dns_resolver.clone(), &cwd, - #[cfg(feature = "tun")] - auto_manage_linux_dns, - #[cfg(not(feature = "tun"))] - false, Some(cancellation_token.child_token()), )); diff --git a/clash-lib/src/proxy/tun/datagram.rs b/clash-lib/src/proxy/tun/datagram.rs index 09026c3c..a547c4f4 100644 --- a/clash-lib/src/proxy/tun/datagram.rs +++ b/clash-lib/src/proxy/tun/datagram.rs @@ -5,6 +5,7 @@ use crate::{ net::DEFAULT_OUTBOUND_INTERFACE, }, common::errors::new_io_error, + config::internal::config::DnsHijackRule, proxy::datagram::UdpPacket, session::{Network, Session, Type}, }; @@ -32,12 +33,26 @@ enum FlushState { Disconnected, } +fn should_hijack_udp_dns( + enabled: bool, + rules: &[DnsHijackRule], + destination: std::net::SocketAddr, +) -> bool { + enabled + && if rules.is_empty() { + destination.port() == 53 + } else { + rules.iter().any(|rule| rule.matches_udp(destination)) + } +} + pub(crate) async fn handle_inbound_datagram( socket: watfaq_netstack::UdpSocket, dispatcher: Arc, resolver: ThreadSafeDNSResolver, so_mark: Option, dns_hijack: bool, + dns_hijack_rules: Vec, ) { // tun i/o // lr: app packets went into tun will be accessed from lr @@ -123,7 +138,7 @@ pub(crate) async fn handle_inbound_datagram( trace!("tun -> dispatcher: {:?}", pkt); - if dns_hijack && pkt.dst_addr.port() == 53 { + if should_hijack_udp_dns(dns_hijack, &dns_hijack_rules, remote_addr) { trace!("got dns packet: {:?}, returning from Clash DNS server", pkt); match hickory_proto::op::Message::from_vec(&pkt.data) { @@ -372,8 +387,14 @@ impl Sink for TunDatagram { #[cfg(test)] mod tests { - use super::{TunDatagram, is_noise_datagram}; - use crate::{proxy::datagram::UdpPacket, session::SocksAddr}; + use super::{TunDatagram, is_noise_datagram, should_hijack_udp_dns}; + use crate::{ + config::internal::config::{ + DnsHijackAddress, DnsHijackProtocol, DnsHijackRule, + }, + proxy::datagram::UdpPacket, + session::SocksAddr, + }; use futures::{Sink, task::noop_waker_ref}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, @@ -392,6 +413,31 @@ mod tests { ) } + #[test] + fn udp_dns_hijack_uses_rules_and_preserves_empty_list_compatibility() { + let destination = "1.1.1.1:53".parse().unwrap(); + let tcp_rule = DnsHijackRule { + protocol: DnsHijackProtocol::Tcp, + address: DnsHijackAddress::Any, + port: 53, + }; + let udp_rule = DnsHijackRule { + protocol: DnsHijackProtocol::Udp, + address: DnsHijackAddress::Ip("1.1.1.1".parse().unwrap()), + port: 53, + }; + + assert!(!should_hijack_udp_dns(false, &[], destination)); + assert!(should_hijack_udp_dns(true, &[], destination)); + assert!(!should_hijack_udp_dns(true, &[tcp_rule], destination)); + assert!(should_hijack_udp_dns(true, &[udp_rule], destination)); + assert!(!should_hijack_udp_dns( + true, + &[udp_rule], + "8.8.8.8:53".parse().unwrap() + )); + } + #[tokio::test(flavor = "current_thread")] async fn tun_datagram_flush_waits_for_capacity_instead_of_failing() { let (tx, mut rx) = tokio::sync::mpsc::channel(1); diff --git a/clash-lib/src/proxy/tun/routes/linux.rs b/clash-lib/src/proxy/tun/routes/linux.rs index ee681a75..709ed852 100644 --- a/clash-lib/src/proxy/tun/routes/linux.rs +++ b/clash-lib/src/proxy/tun/routes/linux.rs @@ -1,6 +1,6 @@ -use std::net::{IpAddr, Ipv4Addr}; +use std::net::IpAddr; -use ipnet::{IpNet, Ipv4Net}; +use ipnet::IpNet; use tracing::warn; use crate::{ @@ -8,11 +8,15 @@ use crate::{ config::internal::config::TunConfig, }; -const FWMARK_MAIN_RULE_PREF: &str = "100"; -const DNS_HIJACK_RULE_PREF: &str = "101"; +const FWMARK_MAIN_RULE_PREF: &str = "88"; +// DNS must be selected before the main-table lookup at preference 90. +// Chimera's SO_MARK-to-main rule at preference 88 prevents its own upstream +// DNS traffic from looping back into the TUN. +const DNS_HIJACK_RULE_PREF: &str = "89"; const ROUTE_ALL_RULE_PREF: &str = "102"; -const MAIN_SUPPRESS_RULE_PREF: &str = "103"; -const NIXOS_SOURCE_MAIN_RULE_PREF: &str = "90"; +// Resolve connected, LAN, VPN, and other specific main-table routes before the +// catch-all TUN rule, while suppressing only the physical default route. +const MAIN_SUPPRESS_RULE_PREF: &str = "90"; fn is_missing_ip_state(stderr: &str) -> bool { matches!( @@ -106,38 +110,6 @@ fn add_excluded_route(table: &str, dest: &IpNet) -> std::io::Result<()> { } } -fn is_nixos() -> bool { - std::path::Path::new("/etc/NIXOS").exists() - || std::path::Path::new("/run/current-system/sw/bin").exists() -} - -fn main_route_source_v4() -> std::io::Result> { - let output = std::process::Command::new("ip") - .args(["-4", "route", "get", "1.1.1.1"]) - .output()?; - warn!("executing: ip -4 route get 1.1.1.1"); - if !output.status.success() { - return Err(new_io_error(format!( - "query default IPv4 source failed: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let Some(line) = stdout.lines().next() else { - return Ok(None); - }; - - let mut parts = line.split_whitespace(); - while let Some(part) = parts.next() { - if part == "src" { - return Ok(parts.next().and_then(|src| src.parse().ok())); - } - } - - Ok(None) -} - pub fn delete_interface(name: &str) -> std::io::Result<()> { let cmd_str = format!("ip link del dev {name}"); let args = ["link", "del", "dev", name]; @@ -148,32 +120,6 @@ pub fn delete_interface(name: &str) -> std::io::Result<()> { Ok(()) } -pub fn ensure_interface_address(name: &str, addr: Ipv4Net) -> std::io::Result<()> { - let cidr = addr.to_string(); - let cmd = std::process::Command::new("ip") - .args(["addr", "add", &cidr, "dev", name]) - .output()?; - warn!("executing: ip addr add {} dev {}", cidr, name); - - if cmd.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&cmd.stderr); - if stderr.contains("File exists") { - warn!( - "address {} already configured on {}, continuing", - cidr, name - ); - return Ok(()); - } - - Err(new_io_error(format!( - "ip addr add {} dev {} failed: {}", - cidr, name, stderr - ))) -} - fn run_ip_cmd_single( cmd_str: &str, args: &[&str], @@ -227,10 +173,9 @@ fn delete_ip_cmd_all(args: &[&str], enable_v6: bool) -> std::io::Result<()> { /// three rules are added: /// # ip route add default dev wg0 table 2468 -/// # ip rule add pref 90 from $OUTBOUND_IPV4 table main (NixOS only) -/// # ip rule add pref 100 fwmark 1234 table main +/// # ip rule add pref 88 fwmark 1234 table main +/// # ip rule add pref 90 table main suppress_prefixlength 0 /// # ip rule add pref 102 not fwmark 1234 table 2468 -/// # ip rule add pref 103 table main suppress_prefixlength 0 /// for ipv6 /// # ip -6 ... pub fn setup_policy_routing( @@ -250,29 +195,6 @@ pub fn setup_policy_routing( add_excluded_route(&table, route)?; } - if is_nixos() { - match main_route_source_v4()? { - Some(addr_v4) => { - run_ip_cmd( - &[ - "rule", - "add", - "pref", - NIXOS_SOURCE_MAIN_RULE_PREF, - "from", - &format!("{addr_v4}/32"), - "table", - "main", - ], - false, - )?; - } - None => { - warn!("NixOS source-main rule skipped: no default IPv4 source found") - } - } - } - if let Some(so_mark) = tun_cfg.so_mark { run_ip_cmd( &[ @@ -318,14 +240,31 @@ pub fn setup_policy_routing( enable_v6, )?; + for port in tun_cfg.dns_hijack_udp_ports() { + run_ip_cmd( + &[ + "rule", + "add", + "pref", + DNS_HIJACK_RULE_PREF, + "ipproto", + "udp", + "dport", + &port.to_string(), + "table", + &table, + ], + enable_v6, + )?; + } + Ok(()) } /// policy rules to clean up: -/// # ip rule del pref 90 from $OUTBOUND_IPV4 table main (NixOS only) -/// # ip rule del pref 100 fwmark $SO_MARK table main +/// # ip rule del pref 88 fwmark $SO_MARK table main +/// # ip rule del pref 90 table main suppress_prefixlength 0 /// # ip rule del pref 102 not fwmark $SO_MARK table $TABLE -/// # ip rule del pref 103 table main suppress_prefixlength 0 /// for v6 /// # ip -6 ... pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { @@ -345,19 +284,8 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { )?; } - if is_nixos() { - delete_ip_cmd_all( - &[ - "rule", - "del", - "pref", - NIXOS_SOURCE_MAIN_RULE_PREF, - "table", - "main", - ], - false, - )?; - } + // Also removes the broad source-address bypass emitted by older builds. + delete_ip_cmd_all(&["rule", "del", "pref", "90", "table", "main"], false)?; if let Some(so_mark) = tun_cfg.so_mark { delete_ip_cmd_all( @@ -432,33 +360,17 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { enable_v6, )?; - if let Some(so_mark) = tun_cfg.so_mark { + for port in tun_cfg.dns_hijack_udp_ports() { delete_ip_cmd_all( &[ "rule", "del", "pref", DNS_HIJACK_RULE_PREF, - "not", - "fwmark", - &so_mark.to_string(), + "ipproto", + "udp", "dport", - "53", - "table", - &table, - ], - enable_v6, - )?; - - delete_ip_cmd_all( - &[ - "rule", - "del", - "not", - "fwmark", - &so_mark.to_string(), - "dport", - "53", + &port.to_string(), "table", &table, ], @@ -466,6 +378,7 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { )?; } + // Remove rules left by older builds that did not include ipproto. delete_ip_cmd_all( &[ "rule", @@ -479,6 +392,14 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { ], enable_v6, )?; + // Remove the rule emitted by transparent-dns step 1. + delete_ip_cmd_all( + &[ + "rule", "del", "pref", "101", "ipproto", "udp", "dport", "53", "table", + &table, + ], + enable_v6, + )?; delete_ip_cmd_all(&["rule", "del", "dport", "53", "table", &table], enable_v6)?; Ok(()) diff --git a/clash-lib/src/proxy/tun/routes/mod.rs b/clash-lib/src/proxy/tun/routes/mod.rs index df57e9be..4d8de6b8 100644 --- a/clash-lib/src/proxy/tun/routes/mod.rs +++ b/clash-lib/src/proxy/tun/routes/mod.rs @@ -17,7 +17,7 @@ mod linux; #[cfg(target_os = "linux")] use linux::add_route; #[cfg(target_os = "linux")] -pub use linux::{delete_interface, ensure_interface_address, maybe_routes_clean_up}; +pub use linux::{delete_interface, maybe_routes_clean_up}; #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] mod other; diff --git a/clash-lib/src/proxy/tun/runner.rs b/clash-lib/src/proxy/tun/runner.rs index 30c8a3d8..9d936e3d 100644 --- a/clash-lib/src/proxy/tun/runner.rs +++ b/clash-lib/src/proxy/tun/runner.rs @@ -269,24 +269,6 @@ impl TunRunner { info!("reconciling routes for existing tun {}", &tun_name); } - #[cfg(target_os = "linux")] - if cfg.dns_hijack - && let Some(dedicated_dns_ip) = cfg.dedicated_dns_ipv4() - { - routes::ensure_interface_address( - &tun_name, - ipnet::Ipv4Net::new( - dedicated_dns_ip, - cfg.gateway.prefix_len(), - ) - .map_err(|err| { - Error::Operation(format!( - "failed to build dedicated tun dns cidr: {err}" - )) - })?, - )?; - } - maybe_add_routes(cfg, &tun_name)?; dev @@ -316,6 +298,7 @@ impl Runner for TunRunner { let dispatcher = self.dispatcher.clone(); let resolver = self.resolver.clone(); let dns_hijack = self.cfg.dns_hijack; + let dns_hijack_rules = self.cfg.dns_hijack_rules.clone(); let cancellation_token = self.cancellation_token.clone(); let handle = tokio::spawn(async move { @@ -426,6 +409,7 @@ impl Runner for TunRunner { resolver.clone(), so_mark, dns_hijack, + dns_hijack_rules.clone(), ) .await; Err(Error::Operation("tun stopped unexpectedly 3".to_string())) diff --git a/clash-lib/tests/tun_fake_ip_real_tests.rs b/clash-lib/tests/tun_fake_ip_real_tests.rs index fd8f1d52..8b31fabc 100644 --- a/clash-lib/tests/tun_fake_ip_real_tests.rs +++ b/clash-lib/tests/tun_fake_ip_real_tests.rs @@ -59,9 +59,7 @@ tun: enable: true device-id: "dev://chimera-test-tun" route-all: false - routes: - - 198.18.0.0/16 - gateway: "198.18.0.1/24" + gateway: "198.19.0.1/30" dns-hijack: false dns: From f4522771ce26286d1ca7db0fb2693922c700357d Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:09:32 +0800 Subject: [PATCH 02/70] feat(nix): add the Chimera NixOS module Add flake packaging, the service module, and deployment documentation. --- NIXOS_MODULE.md | 779 ++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 18 +- nix/module.nix | 168 +++++++++++ nix/package.nix | 66 ++++ 4 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 NIXOS_MODULE.md create mode 100644 nix/module.nix create mode 100644 nix/package.nix diff --git a/NIXOS_MODULE.md b/NIXOS_MODULE.md new file mode 100644 index 00000000..fd19400c --- /dev/null +++ b/NIXOS_MODULE.md @@ -0,0 +1,779 @@ +# Chimera Client 可复用 NixOS 模块声明与发布指南 + +本文定义 Chimera Client 对外发布时推荐采用的 Nix package、NixOS +module、flake 输出和测试契约。目标是让用户不依赖 +`services.mihomo`,只需导入 Chimera Client 自己的模块即可声明式运行 +服务。 + +本文面向两个角色: + +- 项目维护者:在仓库中实现 package、module 和 NixOS VM 测试; +- 下游用户:从 flake、固定版本源码或 nixpkgs 包中启用服务。 + +## 1. 发布目标 + +建议最终仓库结构如下: + +```text +Chimera_Client/ +├── Cargo.toml +├── Cargo.lock +├── flake.nix +├── nix/ +│ ├── package.nix +│ ├── module.nix +│ └── tests/ +│ └── service.nix +└── NIXOS_MODULE.md +``` + +发布后的 flake 至少应提供: + +```nix +packages..chimera-client +packages..default +nixosModules.chimera-client +nixosModules.default +checks..package +checks..nixos-module +``` + +模块的稳定公共入口为: + +```nix +services.chimera-client +``` + +不要使用 `services.clash-rs` 作为长期接口。`clash-rs` 是当前二进制 +名称,而 `chimera-client` 才是软件和模块的发布身份。 + +## 2. 当前程序的 CLI 契约 + +当前二进制为 `clash-rs`,已经具备 NixOS 服务所需的基本接口: + +```bash +# 验证配置 +clash-rs \ + --directory /var/lib/private/chimera-client \ + --config /run/credentials/chimera-client.service/config.yaml \ + --test-config + +# 前台运行 +clash-rs \ + --directory /var/lib/private/chimera-client \ + --config /run/credentials/chimera-client.service/config.yaml + +# 输出版本 +clash-rs --version +``` + +注意: + +1. `--directory` 是状态和资源目录,不是配置文件所在目录; +2. `--config` 可以传绝对路径,因此可以直接读取 systemd credential; +3. `--test-config` 对应 systemd 的 `ExecStartPre`; +4. 程序必须保持前台运行,由 systemd 管理生命周期; +5. `cache.db`、下载的 MMDB、ASN MMDB、GeoSite 和 provider 数据应位于 + `--directory`; +6. 当前兼容模式默认开启。指定 `--directory` 时,程序会把该目录设为 + 工作目录,并为未指定的 MMDB/GeoSite 应用兼容默认值。 + +将来即使增加 `check`、`run` 子命令,也应至少在一个稳定发布周期内 +保留上述参数兼容性。 + +## 3. 推荐的公共 options + +第一版模块建议声明以下选项: + +| Option | 类型 | 默认值 | 说明 | +|---|---|---:|---| +| `enable` | `bool` | `false` | 启用 Chimera Client | +| `package` | `package` | flake 默认包 | 要运行的软件包 | +| `configFile` | `path` | 无 | 包含敏感信息的 YAML 配置 | +| `checkConfig` | `bool` | `true` | 启动前执行 `--test-config` | +| `tun.enable` | `bool` | `false` | 授予 TUN 和策略路由权限 | +| `processInspection` | `bool` | `false` | 允许按进程名称/路径匹配 | +| `extraArgs` | `listOf str` | `[]` | 追加的 CLI 参数 | +| `environment` | `attrsOf str` | `{}` | 服务环境变量 | +| `stateDirectory` | `str` | `"chimera-client"` | systemd 状态目录名 | +| `openFirewall` | `bool` | `false` | 预留选项,第一版建议不自动开放端口 | + +模块不应提供 `settings` 并把完整 YAML 生成到 Nix Store。代理节点密码、 +订阅 URL、控制器 secret 和证书路径通常属于敏感配置,应通过 +`configFile` 与 systemd credentials 加载。 + +`tun.enable` 只控制 systemd 权限和沙箱,不修改 YAML。用户仍需在 +配置文件中显式设置: + +```yaml +tun: + enable: true + route-all: true +``` + +## 4. 可复用的 NixOS module 模板 + +建议将以下内容保存为 `nix/module.nix`。其中 +`self.packages.${pkgs.system}.default` 的注入方式由 flake 决定;如果 +模块需要脱离 flake 单独使用,可要求用户显式设置 `package`。 + +```nix +{ + config, + lib, + pkgs, + utils, + ... +}: + +let + cfg = config.services.chimera-client; + + executable = lib.getExe cfg.package; + stateDirectory = "/var/lib/private/${cfg.stateDirectory}"; + credentialConfig = "\${CREDENTIALS_DIRECTORY}/config.yaml"; + + capabilities = + lib.optional cfg.tun.enable "CAP_NET_ADMIN" + ++ lib.optionals cfg.processInspection [ + "CAP_DAC_READ_SEARCH" + "CAP_SYS_PTRACE" + ]; + + commonArgs = [ + executable + "--directory" + stateDirectory + "--config" + credentialConfig + ]; + + startCommand = + utils.escapeSystemdExecArgs + (commonArgs ++ cfg.extraArgs); + + checkCommand = + utils.escapeSystemdExecArgs + (commonArgs ++ [ "--test-config" ]); +in +{ + options.services.chimera-client = { + enable = lib.mkEnableOption "Chimera Client rule-based proxy service"; + + package = lib.mkOption { + type = lib.types.package; + description = '' + Chimera Client package containing the clash-rs executable. + + The package must declare meta.mainProgram = "clash-rs". + ''; + }; + + configFile = lib.mkOption { + type = lib.types.path; + description = '' + Path to the Chimera Client YAML configuration. + + The file may contain credentials and is loaded through systemd + credentials. Do not generate secret configuration in the Nix Store. + ''; + }; + + checkConfig = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Validate the configuration with --test-config before starting. + ''; + }; + + tun.enable = lib.mkEnableOption '' + the privileges and sandbox exceptions required for TUN mode + ''; + + processInspection = lib.mkEnableOption '' + privileges required for process-name and process-path rules + ''; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "--controller-ipc" + "/run/chimera-client/controller.sock" + ]; + description = '' + Additional command-line arguments passed to Chimera Client. + + Do not put secrets in this option because command-line arguments are + observable through process metadata. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + RUST_LOG = "info"; + }; + description = "Environment variables for the service."; + }; + + stateDirectory = lib.mkOption { + type = lib.types.strMatching "[A-Za-z0-9_.-]+"; + default = "chimera-client"; + description = '' + StateDirectory name used by systemd. Runtime data is stored below + /var/lib/private/ when DynamicUser is enabled. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether the module may open configured listener ports. + + The first module release should reject true until listener extraction + from external YAML is implemented safely. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = + lib.meta.availableOn + pkgs.stdenv.hostPlatform + cfg.package; + message = '' + services.chimera-client.package is not available on + ${pkgs.stdenv.hostPlatform.system}. + ''; + } + { + assertion = !cfg.openFirewall; + message = '' + services.chimera-client.openFirewall is not implemented yet. + Open explicit ports with networking.firewall.allowedTCPPorts and + networking.firewall.allowedUDPPorts. + ''; + } + ]; + + systemd.services.chimera-client = { + description = "Chimera Client rule-based proxy service"; + documentation = [ + "https://github.com/mfsga/Chimera_Client" + ]; + + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + ]; + + # Chimera currently invokes `ip` as a subprocess. + path = [ + pkgs.iproute2 + ]; + + environment = cfg.environment; + + serviceConfig = { + Type = "simple"; + ExecStartPre = lib.optional cfg.checkConfig checkCommand; + ExecStart = startCommand; + + Restart = "on-failure"; + RestartSec = "3s"; + TimeoutStopSec = "30s"; + KillSignal = "SIGTERM"; + + DynamicUser = true; + StateDirectory = cfg.stateDirectory; + LoadCredential = [ + "config.yaml:${cfg.configFile}" + ]; + UMask = "0077"; + + AmbientCapabilities = capabilities; + CapabilityBoundingSet = capabilities; + + NoNewPrivileges = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectHostname = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RestrictNamespaces = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "bpf" + ]; + + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ] ++ lib.optional cfg.tun.enable "AF_NETLINK"; + + PrivateDevices = !cfg.tun.enable; + PrivateUsers = !(cfg.tun.enable || cfg.processInspection); + + ProtectProc = + if cfg.processInspection + then "default" + else "invisible"; + + ProcSubset = + if cfg.processInspection + then "all" + else "pid"; + }; + }; + }; + + meta.maintainers = [ ]; +} +``` + +### 4.1 为什么需要 `path` + +Chimera Client 当前不是只通过 Rust netlink API 管理系统,它还会调用: + +```text +ip +``` + +因此 unit 必须显式提供: + +```nix +path = [ + pkgs.iproute2 +]; +``` + +否则开发环境能够运行、systemd 服务却可能报 `No such file or +directory`。 + +### 4.2 为什么 TUN 模式不能照抄全部默认沙箱 + +TUN 模式需要: + +- `CAP_NET_ADMIN`; +- `AF_NETLINK`; +- 访问 `/dev/net/tun`; +- 创建、删除接口和策略路由。 + +Chimera 不修改 `systemd-resolved` 的 per-link DNS,也不需要 +`CAP_NET_BIND_SERVICE` 或 resolve1 Polkit 授权。 + +因此启用 `tun.enable` 时至少要设置: + +```nix +PrivateDevices = false; +PrivateUsers = false; +RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + "AF_NETLINK" +]; +``` + +不要仅添加 `CAP_NET_ADMIN` 后仍保持 `PrivateDevices = true`,否则服务 +可能拥有 capability,却看不到宿主的 TUN 设备。 + +### 4.3 进程识别权限 + +当 YAML 包含 `PROCESS-NAME`、`PROCESS-PATH` 等规则时,模块用户应设置: + +```nix +services.chimera-client.processInspection = true; +``` + +这会放宽 `/proc` 可见性,并授予: + +```text +CAP_DAC_READ_SEARCH +CAP_SYS_PTRACE +``` + +第一版发布前必须用真实进程匹配测试确认这些权限足够;如果不同内核的 +Yama、hidepid 或 LSM 仍阻止检查,应记录平台限制,而不是直接把服务 +改成 root 常驻。 + +## 5. Package 声明要求 + +`nix/package.nix` 应从源码构建,而不是包装开发机的 `target/debug` +二进制。包至少需要满足: + +```nix +meta = { + mainProgram = "clash-rs"; + platforms = lib.platforms.linux; +}; +``` + +建议使用 `rustPlatform.buildRustPackage`。示意模板: + +```nix +{ + lib, + rustPlatform, + pkg-config, + cmake, + protobuf, + llvmPackages, + ... +}: + +rustPlatform.buildRustPackage { + pname = "chimera-client"; + version = "0.23.0"; + + src = lib.cleanSource ../.; + + cargoLock = { + lockFile = ../Cargo.lock; + + # Cargo.lock 中 git dependencies 需要逐项提供 outputHashes。 + outputHashes = { + # "dependency-version" = "sha256-..."; + }; + }; + + nativeBuildInputs = [ + pkg-config + cmake + protobuf + llvmPackages.libclang + ]; + + LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; + + cargoBuildFlags = [ + "--package" + "clash-rs" + ]; + + cargoTestFlags = [ + "--package" + "clash-rs" + ]; + + meta = { + description = "Rust rule-based proxy client with DNS, TUN and Clash-compatible APIs"; + homepage = "https://github.com/mfsga/Chimera_Client"; + license = lib.licenses.gpl3Only; # 发布前按仓库真实许可证修正 + mainProgram = "clash-rs"; + platforms = lib.platforms.linux; + }; +} +``` + +上面的 `license` 和 `outputHashes` 是发布阻断项,不能保留猜测值。 +发布前必须: + +1. 在仓库根目录加入明确的许可证文件; +2. 为所有 Cargo git dependencies 固定 Nix output hash; +3. 确认 release 构建启用了需要的默认 features; +4. 确认最终包只安装需要发布的二进制和资源。 + +## 6. Flake 输出契约 + +当前仓库的 `flake.nix` 只提供本机开发 shell,并把 nixpkgs 输入绑定到 +本机 `/nix/store` 路径。这种写法不可发布。 + +公开发布时应将输入改为可复现的远端引用,例如: + +```nix +inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; +``` + +推荐输出结构: + +```nix +{ + description = "Chimera Client"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs, ... }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + ]; + + forAllSystems = + nixpkgs.lib.genAttrs supportedSystems + (system: + let + pkgs = import nixpkgs { inherit system; }; + in + pkgs); + in + { + packages = nixpkgs.lib.mapAttrs + (_: pkgs: + let + package = pkgs.callPackage ./nix/package.nix { }; + in + { + chimera-client = package; + default = package; + }) + forAllSystems; + + nixosModules.chimera-client = + { lib, pkgs, ... }: + { + imports = [ ./nix/module.nix ]; + + services.chimera-client.package = + lib.mkDefault + self.packages.${pkgs.system}.default; + }; + + nixosModules.default = self.nixosModules.chimera-client; + }; +} +``` + +这里展示的是输出契约,不应未经 `nix flake check` 就直接视为最终 +实现。实际实现还需要把 `pkgs` 的作用域正确传入 module wrapper,并 +加入 checks 和 devShell。 + +## 7. 下游使用示例 + +### 7.1 Flake 用户 + +```nix +{ + inputs.chimera-client.url = + "github:mfsga/Chimera_Client"; + + outputs = { nixpkgs, chimera-client, ... }: { + nixosConfigurations.desktop = + nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + chimera-client.nixosModules.default + + ({ config, lib, ... }: { + services.chimera-client = { + enable = true; + configFile = + config.age.secrets.chimera-client-config.path; + tun.enable = true; + processInspection = true; + checkConfig = true; + }; + + age.secrets.chimera-client-config.file = + ./secrets/chimera-client.yaml.age; + + networking.firewall.checkReversePath = + lib.mkDefault "loose"; + }) + ]; + }; + }; +} +``` + +### 7.2 不使用 secret manager + +```nix +services.chimera-client = { + enable = true; + package = inputs.chimera-client.packages.${pkgs.system}.default; + configFile = "/var/lib/secrets/chimera-client/config.yaml"; + tun.enable = true; +}; +``` + +配置文件应由 root 安装: + +```bash +sudo install -Dm600 config.yaml \ + /var/lib/secrets/chimera-client/config.yaml +``` + +不要将含凭据的 YAML 写入: + +```nix +environment.etc."chimera-client/config.yaml".text = "..."; +``` + +## 8. TUN 与网络安全约束 + +### 8.1 反向路径过滤 + +策略路由和 TUN 可能与严格 rpfilter 冲突。主机配置通常需要: + +```nix +networking.firewall.checkReversePath = "loose"; +``` + +模块第一版不建议静默覆盖全局防火墙策略。可以: + +- 在文档中要求用户设置; +- 或增加一个显式 option,由用户授权后设置 `mkDefault "loose"`。 + +### 8.2 TUN 与 Fake-IP 网段 + +推荐默认布局: + +```text +TUN link: 198.18.0.1/30 +Fake-IP: 198.19.0.0/16 +``` + +两个网段必须分离。Chimera Client 在 Fake-IP 模式和非 +`route-all` 模式下会自动补充 Fake-IP 到 TUN 的路由;显式重叠配置 +应在启动前验证阶段失败。 + +### 8.3 远程部署与回退 + +启用 TUN 可能切断当前 SSH、Codex 或构建代理连接。远程部署应: + +1. 先执行 `nixos-rebuild build`; +2. 使用 `nixos-rebuild test` 或带自动回退的部署工具; +3. 保留物理局域网和管理端点的 main-table 绕行规则; +4. 确认 `SIGTERM` 能清理 TUN 和 policy rules; +5. 在另一个终端持续检查默认路由和远程连接; +6. 不要在未验证备用通道时直接切换生产主机。 + +备用代理、SSH 跳板或带外管理地址属于部署环境信息,不应硬编码进 +通用 module。下游部署者应在主机配置中显式维护这些绕行地址。 + +## 9. NixOS VM 测试要求 + +建议 `nix/tests/service.nix` 至少覆盖: + +1. module 能成功 evaluation; +2. package 的 `meta.mainProgram` 可解析; +3. credential 文件没有被复制成普通公开配置; +4. `ExecStartPre` 会接受合法配置; +5. 非法配置导致 unit 启动失败; +6. `StateDirectory` 可写,能够创建 `cache.db`; +7. 非 TUN 模式不拥有 `CAP_NET_ADMIN`; +8. TUN 模式包含 `CAP_NET_ADMIN` 和 `AF_NETLINK`; +9. unit PATH 中能找到 `ip`,且不依赖 `resolvectl`; +10. 服务收到 `SIGTERM` 后正常退出; +11. 重启后没有重复 policy rule; +12. 条件允许时,验证 TUN 和 Fake-IP 使用分离网段。 + +最小测试形状: + +```nix +import "${pkgs.path}/nixos/tests/make-test-python.nix" ({ + name = "chimera-client"; + + nodes.machine = { config, ... }: { + imports = [ ../module.nix ]; + + services.chimera-client = { + enable = true; + package = packageUnderTest; + configFile = ./fixtures/minimal.yaml; + checkConfig = true; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("chimera-client.service") + machine.succeed("systemctl is-active chimera-client.service") + machine.succeed( + "systemctl show chimera-client.service " + "-p StateDirectory -p DynamicUser" + ) + ''; +}) +``` + +TUN 测试应单独建立测试项,避免普通 module evaluation 因内核权限或 +网络副作用变得不稳定。 + +## 10. 发布检查清单 + +### Package + +- [ ] 仓库有明确许可证; +- [ ] Cargo.lock 已提交; +- [ ] 所有 git dependencies 有固定 hash; +- [ ] `nix build .#chimera-client` 成功; +- [ ] `nix run . -- --version` 成功; +- [ ] `meta.mainProgram = "clash-rs"`; +- [ ] x86_64-linux 构建通过; +- [ ] aarch64-linux 至少完成 evaluation,最好有真实构建。 + +### Module + +- [ ] `services.chimera-client.enable`; +- [ ] package 可覆盖; +- [ ] configFile 使用 credential; +- [ ] 状态文件写入 StateDirectory; +- [ ] `ExecStartPre` 验证配置; +- [ ] TUN 权限只在明确启用时授予; +- [ ] process inspection 权限只在明确启用时授予; +- [ ] `ip` 位于 unit PATH,unit 不依赖 `resolvectl`; +- [ ] SIGTERM 能清理系统状态; +- [ ] 无效配置不会进入重启风暴。 + +### Tests + +- [ ] `nix flake check`; +- [ ] 非 TUN 服务测试; +- [ ] 无效配置测试; +- [ ] capability 断言; +- [ ] 状态目录写入测试; +- [ ] TUN 独立 VM 测试; +- [ ] restart/reload 幂等性测试。 + +### Documentation + +- [ ] README 提供 flake 导入示例; +- [ ] options 有稳定名称和说明; +- [ ] 说明配置包含秘密; +- [ ] 说明 rpfilter; +- [ ] 说明 TUN/Fake-IP 网段; +- [ ] 说明远程部署回退方案; +- [ ] 标明支持的 NixOS/nixpkgs 版本。 + +## 11. 与官方实践的关系 + +本设计沿用 NixOS 官方推荐的 module 结构: + +- 使用 `options` 声明带类型的公共接口; +- 使用 `config = lib.mkIf cfg.enable` 生成 systemd 配置; +- package 允许由用户覆盖; +- 使用 `LoadCredential` 避免秘密进入普通命令行和公开配置; +- 使用 `DynamicUser` 与 `StateDirectory` 管理服务身份和可写状态; +- 使用 NixOS VM tests 验证模块行为。 + +Chimera Client 当前仍会主动调用 `ip` 管理 Linux policy routing,但 +不会调用 `resolvectl` 或管理 per-link DNS。该差异必须体现在 unit +PATH、TUN 沙箱和测试中。 + +官方参考: + +- [NixOS Manual: Writing NixOS Modules](https://nixos.org/manual/nixos/stable/index.html#sec-writing-modules) +- [NixOS Manual: NixOS Tests](https://nixos.org/manual/nixos/stable/index.html#sec-nixos-tests) +- [Nixpkgs Mihomo module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/mihomo.nix) +- [Nixpkgs Reference Manual](https://nixos.org/manual/nixpkgs/stable/) diff --git a/flake.nix b/flake.nix index 1c61a7f5..010a8c1c 100644 --- a/flake.nix +++ b/flake.nix @@ -6,12 +6,28 @@ inputs.nixpkgs.url = "path:/nix/store/pzxxxg9vvzk63122vj38lcmqg9dl6qxk-nixos-26.05.1947.a0374025a863/nixos"; - outputs = { nixpkgs, ... }: + outputs = { self, nixpkgs, ... }: let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; + chimeraClient = pkgs.callPackage ./nix/package.nix { }; in { + packages.${system} = { + chimera-client = chimeraClient; + default = chimeraClient; + }; + + nixosModules.chimera-client = + { lib, pkgs, ... }: + { + imports = [ ./nix/module.nix ]; + services.chimera-client.package = + lib.mkDefault self.packages.${pkgs.system}.default; + }; + + nixosModules.default = self.nixosModules.chimera-client; + devShells.${system}.default = pkgs.mkShell { nativeBuildInputs = with pkgs; [ cargo diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 00000000..ee8fb013 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,168 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: + +let + cfg = config.services.chimera-client; + executable = lib.getExe cfg.package; + stateDirectory = "/var/lib/private/${cfg.stateDirectory}"; + # %d expands to the service credential directory in systemd command lines. + # Unlike $CREDENTIALS_DIRECTORY it survives escapeSystemdExecArgs unchanged. + credentialConfig = "%d/config.yaml"; + + capabilities = + lib.optional cfg.tun.enable "CAP_NET_ADMIN" + ++ lib.optionals cfg.processInspection [ + "CAP_DAC_READ_SEARCH" + "CAP_SYS_PTRACE" + ]; + + commonArgs = [ + executable + "--directory" + stateDirectory + "--config" + credentialConfig + ]; + + escapeExecArgs = + args: + lib.replaceStrings [ "%%d" ] [ "%d" ] + (utils.escapeSystemdExecArgs args); +in +{ + options.services.chimera-client = { + enable = lib.mkEnableOption "Chimera Client rule-based proxy service"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./package.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./nix/package.nix { }"; + description = "Chimera Client package containing the clash-rs executable."; + }; + + configFile = lib.mkOption { + type = lib.types.path; + description = '' + Path to the YAML configuration. The file is loaded with a systemd + credential so secrets do not need to be copied into the Nix store. + ''; + }; + + checkConfig = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Validate the configuration before starting the service."; + }; + + tun.enable = lib.mkEnableOption "permissions required for TUN mode"; + + processInspection = + lib.mkEnableOption "permissions required for process matching rules"; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional non-secret command-line arguments."; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Environment variables passed to Chimera Client."; + }; + + stateDirectory = lib.mkOption { + type = lib.types.strMatching "[A-Za-z0-9_.-]+"; + default = "chimera-client"; + description = "Name of the systemd-managed state directory."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = + lib.meta.availableOn pkgs.stdenv.hostPlatform cfg.package; + message = '' + services.chimera-client.package is unavailable on + ${pkgs.stdenv.hostPlatform.system}. + ''; + } + ]; + + systemd.services.chimera-client = { + description = "Chimera Client rule-based proxy service"; + documentation = [ "https://github.com/mfsga/Chimera_Client" ]; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + ]; + + path = [ + pkgs.iproute2 + ]; + + environment = cfg.environment; + + serviceConfig = { + Type = "simple"; + ExecStartPre = lib.optional cfg.checkConfig ( + escapeExecArgs (commonArgs ++ [ "--test-config" ]) + ); + ExecStart = escapeExecArgs (commonArgs ++ cfg.extraArgs); + + Restart = "on-failure"; + RestartSec = "3s"; + TimeoutStopSec = "30s"; + KillSignal = "SIGTERM"; + + DynamicUser = true; + StateDirectory = cfg.stateDirectory; + LoadCredential = [ "config.yaml:${cfg.configFile}" ]; + UMask = "0077"; + + AmbientCapabilities = capabilities; + CapabilityBoundingSet = capabilities; + + NoNewPrivileges = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectHostname = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RestrictNamespaces = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "bpf" + ]; + + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ] ++ lib.optional cfg.tun.enable "AF_NETLINK"; + + PrivateDevices = !cfg.tun.enable; + PrivateUsers = !(cfg.tun.enable || cfg.processInspection); + ProtectProc = if cfg.processInspection then "default" else "invisible"; + ProcSubset = if cfg.processInspection then "all" else "pid"; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 00000000..d8b4c9a8 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,66 @@ +{ + lib, + rustPlatform, + pkg-config, + cmake, + protobuf, + llvmPackages, +}: + +rustPlatform.buildRustPackage { + pname = "chimera-client"; + version = "0.23.0"; + + src = lib.cleanSourceWith { + src = ../.; + filter = + path: type: + let + baseName = baseNameOf path; + in + !( + baseName == "target" + || baseName == "ref" + || baseName == "logs" + || baseName == "nix" + || baseName == ".git" + || baseName == "result" + || lib.hasPrefix "result-" baseName + || lib.hasSuffix ".log" baseName + ); + }; + + cargoLock = { + lockFile = ../Cargo.lock; + # This repository is distributed outside nixpkgs and currently contains + # pinned Cargo git dependencies. Replace this with outputHashes before a + # future nixpkgs submission. + allowBuiltinFetchGit = true; + }; + + nativeBuildInputs = [ + pkg-config + cmake + protobuf + llvmPackages.libclang + ]; + + LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; + + cargoBuildFlags = [ + "--package" + "clash-rs" + ]; + + # The workspace contains network-, Docker-, and privilege-dependent tests. + # They remain covered by CI and NixOS VM checks rather than the package build. + doCheck = false; + + meta = { + description = "Rust rule-based proxy client with DNS, TUN and Clash-compatible APIs"; + homepage = "https://github.com/mfsga/Chimera_Client"; + license = lib.licenses.asl20; + mainProgram = "clash-rs"; + platforms = lib.platforms.linux; + }; +} From 7786b282342dcb525f1c0dd12dc8662f6a607b5d Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:09:52 +0800 Subject: [PATCH 03/70] fix(runtime): harden startup and shutdown lifecycle Add production startup logging, isolate API integration tests, and handle SIGTERM during graceful shutdown. --- clash-lib/src/lib.rs | 20 ++++++- clash-lib/tests/api_reload_tests.rs | 1 + clash-lib/tests/api_tests.rs | 86 ++++++++++++++++++++++------- start.sh | 42 +++++++++++++- 4 files changed, 126 insertions(+), 23 deletions(-) mode change 100644 => 100755 start.sh diff --git a/clash-lib/src/lib.rs b/clash-lib/src/lib.rs index 895c8993..2c78bc5a 100644 --- a/clash-lib/src/lib.rs +++ b/clash-lib/src/lib.rs @@ -229,6 +229,24 @@ pub fn setup_default_crypto_provider() { }); } +async fn wait_for_shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + let mut terminate = tokio::signal::unix::signal( + tokio::signal::unix::SignalKind::terminate(), + )?; + tokio::select! { + result = tokio::signal::ctrl_c() => result, + _ = terminate.recv() => Ok(()), + } + } + + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} + pub async fn start( config: InternalConfig, cwd: String, @@ -394,7 +412,7 @@ pub async fn start( }); tokio::select! { - result = tokio::signal::ctrl_c() => { + result = wait_for_shutdown_signal() => { result.map_err(Error::Io)?; shutdown_token.cancel(); } diff --git a/clash-lib/tests/api_reload_tests.rs b/clash-lib/tests/api_reload_tests.rs index f0da3c38..928c775f 100644 --- a/clash-lib/tests/api_reload_tests.rs +++ b/clash-lib/tests/api_reload_tests.rs @@ -58,6 +58,7 @@ fn write_config(path: &PathBuf, api_port: u16, socks_port: u16, mode: &str) { "ipv6: false\n\ log_level: info\n\ mode: {mode}\n\ +mmdb: null\n\ external-controller: 127.0.0.1:{api_port}\n\ socks-port: {socks_port}\n\ dns:\n\ diff --git a/clash-lib/tests/api_tests.rs b/clash-lib/tests/api_tests.rs index f0470a74..1a16ba1b 100644 --- a/clash-lib/tests/api_tests.rs +++ b/clash-lib/tests/api_tests.rs @@ -2,10 +2,48 @@ use crate::common::{ClashInstance, send_http_request}; use bytes::{Buf, Bytes}; use clash_lib::{Config, Options}; use http_body_util::BodyExt; -use std::{path::PathBuf, time::Duration}; +use std::{net::TcpListener, path::PathBuf, time::Duration}; mod common; +fn available_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("failed to reserve test port") + .local_addr() + .expect("failed to inspect test port") + .port() +} + +fn isolated_config(api_port: u16) -> (PathBuf, u16) { + let socks_port = available_port(); + let path = std::env::temp_dir().join(format!( + "chimera-api-test-{api_port}-{}.yaml", + std::process::id() + )); + std::fs::write( + &path, + format!( + "allow-lan: true\n\ +bind-address: 0.0.0.0\n\ +socks-port: {socks_port}\n\ +mode: direct\n\ +log-level: info\n\ +mmdb: null\n\ +external-controller: 127.0.0.1:{api_port}\n\ +secret: clash-rs\n\ +tun:\n\ + enable: false\n\ +proxies:\n\ + - {{name: DIRECT_alias, type: direct}}\n\ + - {{name: REJECT_alias, type: reject}}\n\ +rules:\n\ + - MATCH,DIRECT\n" + ), + ) + .expect("failed to write isolated test configuration"); + (path, socks_port) +} + async fn get_allow_lan(port: u16) -> bool { let url = format!("http://127.0.0.1:{}/configs", port); let req = hyper::Request::builder() @@ -45,42 +83,48 @@ async fn test_config_reload_via_payload() { config_path.to_string_lossy() ); + let api_port = available_port(); + let reload_api_port = available_port(); + let (isolated_config, socks_port) = isolated_config(api_port); let _clash = ClashInstance::start( Options { - config: Config::File(config_path.to_string_lossy().to_string()), + config: Config::File(isolated_config.to_string_lossy().to_string()), cwd: Some(wd.to_string_lossy().to_string()), rt: None, log_file: None, - config_path: Some(config_path.to_string_lossy().to_string()), + config_path: Some(isolated_config.to_string_lossy().to_string()), }, - vec![9090, 8888, 8889, 8899, 53553, 53554, 53555], + vec![api_port, reload_api_port, socks_port], ) .expect("Failed to start clash"); assert!( - get_allow_lan(9090).await, + get_allow_lan(api_port).await, "expected allow-lan=true before reload" ); - let new_payload = r#" + let new_payload = format!( + r#" socks-port: 7892 bind-address: 127.0.0.1 allow-lan: false mode: direct log-level: info -external-controller: :9091 +mmdb: null +external-controller: 127.0.0.1:{reload_api_port} secret: clash-rs tun: enable: false proxies: - - {name: DIRECT_alias, type: direct} - - {name: REJECT_alias, type: reject} -"#; + - {{name: DIRECT_alias, type: direct}} + - {{name: REJECT_alias, type: reject}} +"# + ); let body = serde_json::json!({ "payload": new_payload }).to_string(); - let configs_url = "http://127.0.0.1:9090/configs"; + let configs_url = format!("http://127.0.0.1:{api_port}/configs"); let req = hyper::Request::builder() - .uri(configs_url) + .uri(&configs_url) .header(hyper::header::AUTHORIZATION, "Bearer clash-rs") .header(hyper::header::CONTENT_TYPE, "application/json") .method(http::method::Method::PUT) @@ -99,7 +143,7 @@ proxies: tokio::time::sleep(Duration::from_millis(500)).await; assert!( - !get_allow_lan(9091).await, + !get_allow_lan(reload_api_port).await, "expected allow-lan=false after reload" ); } @@ -116,26 +160,28 @@ async fn test_get_set_allow_lan() { config_path.to_string_lossy() ); + let api_port = available_port(); + let (isolated_config, socks_port) = isolated_config(api_port); let _clash = ClashInstance::start( Options { - config: Config::File(config_path.to_string_lossy().to_string()), + config: Config::File(isolated_config.to_string_lossy().to_string()), cwd: Some(wd.to_string_lossy().to_string()), rt: None, log_file: None, - config_path: Some(config_path.to_string_lossy().to_string()), + config_path: Some(isolated_config.to_string_lossy().to_string()), }, - vec![9090, 8888, 8889, 8899, 53553, 53554, 53555], + vec![api_port, socks_port], ) .expect("Failed to start clash"); assert!( - get_allow_lan(9090).await, + get_allow_lan(api_port).await, "'allow_lan' should be true by config" ); - let configs_url = "http://127.0.0.1:9090/configs"; + let configs_url = format!("http://127.0.0.1:{api_port}/configs"); let req = hyper::Request::builder() - .uri(configs_url) + .uri(&configs_url) .header(hyper::header::AUTHORIZATION, "Bearer clash-rs") .header(hyper::header::CONTENT_TYPE, "application/json") .method(http::method::Method::PATCH) @@ -148,7 +194,7 @@ async fn test_get_set_allow_lan() { assert_eq!(res.status(), http::StatusCode::ACCEPTED); assert!( - !get_allow_lan(9090).await, + !get_allow_lan(api_port).await, "'allow_lan' should be false after update" ); } diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 index 0701ae8d..4ef23ee8 --- a/start.sh +++ b/start.sh @@ -1,2 +1,40 @@ -cargo watch -x "run --package clash-rs --bin clash-rs -- -c config.yaml" -# cargo watch -x "run " +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT_DIR" + +CONFIG_FILE="${CONFIG_FILE:-config-prod.yaml}" +LOG_DIR="${LOG_DIR:-logs}" +RUN_ID="$(date '+%Y%m%d-%H%M%S')" + +mkdir -p "$LOG_DIR" + +APP_LOG_FILE="${APP_LOG_FILE:-$ROOT_DIR/$LOG_DIR/chimera-$RUN_ID.log}" +CONSOLE_LOG_FILE="${CONSOLE_LOG_FILE:-$ROOT_DIR/$LOG_DIR/chimera-$RUN_ID.console.log}" + +# clash-lib currently opens --log-file in append-only mode, so create it first. +touch "$APP_LOG_FILE" "$CONSOLE_LOG_FILE" + +echo "config: $CONFIG_FILE" +echo "app log: $APP_LOG_FILE" +echo "console log: $CONSOLE_LOG_FILE" + +# Keep Cargo's target directory owned by the developer. Only the already-built +# binary needs elevated privileges when TUN setup is enabled. +nix develop --command cargo build -p clash-rs +nix develop --command "$ROOT_DIR/target/debug/clash-rs" -t -c "$CONFIG_FILE" + +run_command=( + "$ROOT_DIR/target/debug/clash-rs" + -c "$CONFIG_FILE" + --log-file "$APP_LOG_FILE" +) + +if [[ "${RUN_AS_ROOT:-1}" == "1" ]]; then + nix develop --command sudo -- "${run_command[@]}" \ + 2>&1 | tee -a "$CONSOLE_LOG_FILE" +else + nix develop --command "${run_command[@]}" \ + 2>&1 | tee -a "$CONSOLE_LOG_FILE" +fi From 8d02680670ffb816de8c6e6438ce84794a06f0e9 Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:10:18 +0800 Subject: [PATCH 04/70] fix(router): preserve wildcard and matcher behavior Keep wildcard trie keys intact and pass rule matchers through dispatcher tracking as trait objects. --- .../src/app/dispatcher/dispatcher_impl.rs | 4 +- clash-lib/src/app/dispatcher/tracked.rs | 8 +-- clash-lib/src/common/trie.rs | 67 +++++++++++++------ 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/clash-lib/src/app/dispatcher/dispatcher_impl.rs b/clash-lib/src/app/dispatcher/dispatcher_impl.rs index a9d09084..a82da363 100644 --- a/clash-lib/src/app/dispatcher/dispatcher_impl.rs +++ b/clash-lib/src/app/dispatcher/dispatcher_impl.rs @@ -143,7 +143,7 @@ impl Dispatcher { rhs, self.manager.clone(), sess.clone(), - rule, + rule.map(|matcher| matcher.as_ref()), ) .await; let shutdown_mode = if sess.typ == crate::session::Type::HttpConnect @@ -417,7 +417,7 @@ impl Dispatcher { outbound_datagram, manager.clone(), sess.clone(), - rule, + rule.map(|matcher| matcher.as_ref()), ) .await; diff --git a/clash-lib/src/app/dispatcher/tracked.rs b/clash-lib/src/app/dispatcher/tracked.rs index 2b076dfe..a309ca29 100644 --- a/clash-lib/src/app/dispatcher/tracked.rs +++ b/clash-lib/src/app/dispatcher/tracked.rs @@ -125,12 +125,11 @@ pub struct TrackedStream { #[allow(unused)] impl TrackedStream { - #[allow(clippy::borrowed_box)] pub async fn new( inner: BoxedChainedStream, manager: Arc, sess: Session, - rule: Option<&Box>, + rule: Option<&dyn RuleMatcher>, ) -> Self { let uuid = uuid::Uuid::new_v4(); let chain = inner.chain().clone(); @@ -143,7 +142,6 @@ impl TrackedStream { session_holder: sess, start_time: chrono::Utc::now(), rule: rule - .as_ref() .map(|matcher| matcher.type_name().to_owned()) .unwrap_or_default(), rule_payload: rule @@ -446,12 +444,11 @@ pub struct TrackedDatagram { } impl TrackedDatagram { - #[allow(clippy::borrowed_box)] pub async fn new( inner: BoxedChainedDatagram, manager: Arc, sess: Session, - rule: Option<&Box>, + rule: Option<&dyn RuleMatcher>, ) -> Self { let uuid = uuid::Uuid::new_v4(); let chain = inner.chain().clone(); @@ -461,7 +458,6 @@ impl TrackedDatagram { session_holder: sess, start_time: chrono::Utc::now(), rule: rule - .as_ref() .map(|matcher| matcher.type_name().to_owned()) .unwrap_or_default(), rule_payload: rule.map(|matcher| matcher.payload()).unwrap_or_default(), diff --git a/clash-lib/src/common/trie.rs b/clash-lib/src/common/trie.rs index 750f3edf..b032ed0a 100644 --- a/clash-lib/src/common/trie.rs +++ b/clash-lib/src/common/trie.rs @@ -44,26 +44,8 @@ impl Node { self.children.insert(key.to_string(), child); } - fn traverse(&self, prefix: &mut Vec, f: &mut F) -> bool - where - F: FnMut(&str, &T) -> bool, - { - if let Some(data) = self.data.as_deref() { - let domain = prefix.iter().rev().cloned().collect::>().join("."); - if !f(&domain, data) { - return false; - } - } - - for (label, child) in &self.children { - prefix.push(label.clone()); - if !child.traverse(prefix, f) { - return false; - } - prefix.pop(); - } - - true + pub fn get_children(&self) -> &HashMap> { + &self.children } } @@ -125,8 +107,49 @@ impl StringTrie { where F: FnMut(&str, &T) -> bool, { - let mut prefix = Vec::new(); - self.root.traverse(&mut prefix, &mut f); + for (key, child) in self.root.get_children() { + Self::traverse_inner(&[key], child, &mut f); + if let Some(data) = child.get_data() + && !f(key, data) + { + return; + } + } + } + + fn traverse_inner<'a, F>( + keys: &'a [&String], + node: &'a Node, + f: &mut F, + ) -> bool + where + F: FnMut(&str, &T) -> bool, + { + for (key, child) in node.get_children() { + let keys = [&[key], keys].concat(); + if let Some(data) = child.get_data() { + let domain = keys + .iter() + .map(|key| key.as_str()) + .collect::>() + .join(DOMAIN_STEP); + let domain = if domain.starts_with(DOMAIN_STEP) { + COMPLEX_WILDCARD.to_string() + &domain + } else { + domain + }; + + if !f(&domain, data) { + return false; + } + } + + if !Self::traverse_inner(&keys, child, f) { + return false; + } + } + + true } fn insert_inner(&mut self, parts: &[&str], data: Arc) { From cb00b1afc851083929c003a309a88f7c31ef1659 Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:10:31 +0800 Subject: [PATCH 05/70] refactor: resolve baseline clippy warnings Clean up cross-crate lint issues before the routing and protocol work that follows. --- clash-dns/src/handler.rs | 11 +++--- .../src/app/dispatcher/dispatcher_impl.rs | 7 ++-- clash-lib/src/app/net/mod.rs | 6 ++-- clash-lib/src/app/router/mod.rs | 10 +++--- clash-lib/src/config/internal/proxy.rs | 1 + clash-lib/src/proxy/group/relay/mod.rs | 1 + clash-lib/src/proxy/hysteria2/congestion.rs | 6 +--- clash-netstack/tests/common.rs | 8 ++--- clash-netstack/tests/integration_test.rs | 34 +++++++++---------- xhttp-h2-phaseb/src/bin/server.rs | 20 ++++------- 10 files changed, 47 insertions(+), 57 deletions(-) diff --git a/clash-dns/src/handler.rs b/clash-dns/src/handler.rs index 47d6e1bb..0713c8c0 100644 --- a/clash-dns/src/handler.rs +++ b/clash-dns/src/handler.rs @@ -528,10 +528,7 @@ mod tests { use hickory_proto::rr::{DNSClass, Name, RData, RecordType}; use rustls::{ClientConfig, pki_types::ServerName}; use std::{sync::Arc, time::Duration}; - use tokio::{ - net::{TcpListener, UdpSocket}, - task::JoinHandle, - }; + use tokio::net::{TcpListener, UdpSocket}; async fn send_query( client: &mut Client, @@ -635,10 +632,10 @@ mod tests { super::get_dns_listener(cfg, mock_exchanger, std::path::Path::new(".")) .await; assert!(listener.is_some()); - let _: JoinHandle> = tokio::spawn(async move { + std::mem::drop(tokio::spawn(async move { listener.unwrap().await?; - Ok(()) - }); + Ok::<(), anyhow::Error>(()) + })); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/clash-lib/src/app/dispatcher/dispatcher_impl.rs b/clash-lib/src/app/dispatcher/dispatcher_impl.rs index a82da363..278166a4 100644 --- a/clash-lib/src/app/dispatcher/dispatcher_impl.rs +++ b/clash-lib/src/app/dispatcher/dispatcher_impl.rs @@ -113,7 +113,7 @@ impl Dispatcher { RunMode::Direct => (PROXY_DIRECT, None), }; - let rule_summary = rule_summary(rule); + let rule_summary = rule_summary(rule.map(Box::as_ref)); debug!("dispatching {} to {}[{}]", sess, outbound_name, mode); let mgr = self.outbound_manager.clone(); @@ -354,7 +354,7 @@ impl Dispatcher { outbound_name }; - let rule_summary = rule_summary(rule); + let rule_summary = rule_summary(rule.map(Box::as_ref)); debug!( outbound_name = %outbound_name, rule = %rule_summary, @@ -620,7 +620,7 @@ async fn reverse_lookup( Some(dst) } -fn rule_summary(rule: Option<&Box>) -> String { +fn rule_summary(rule: Option<&dyn crate::app::router::RuleMatcher>) -> String { rule.map(|rule| { let payload = rule.payload(); if payload.is_empty() { @@ -807,6 +807,7 @@ impl OutboundHandleMap { } } +#[allow(clippy::items_after_test_module)] #[cfg(test)] mod tests { use super::{OutboundHandleMap, try_queue_outbound_packet}; diff --git a/clash-lib/src/app/net/mod.rs b/clash-lib/src/app/net/mod.rs index b0e25e34..190830e4 100644 --- a/clash-lib/src/app/net/mod.rs +++ b/clash-lib/src/app/net/mod.rs @@ -211,10 +211,12 @@ pub fn resolve_outbound_interface( Interface::Name(name) => get_interface_by_name(name), }); - if interface.is_some() && configured.is_none() { + if let Some(interface) = interface + && configured.is_none() + { warn!( "configured outbound interface {} not found, falling back to auto-detect", - interface.expect("checked is_some") + interface ); } diff --git a/clash-lib/src/app/router/mod.rs b/clash-lib/src/app/router/mod.rs index af6ac407..7b18c137 100644 --- a/clash-lib/src/app/router/mod.rs +++ b/clash-lib/src/app/router/mod.rs @@ -111,15 +111,13 @@ impl Router { if sess.destination.is_domain() && r.should_resolve_ip() && !sess_resolved - { - if let Ok(Some(ip)) = self + && let Ok(Some(ip)) = self .dns_resolver .resolve(sess.destination.domain().unwrap(), false) .await - { - sess.resolved_ip = Some(ip); - sess_resolved = true; - } + { + sess.resolved_ip = Some(ip); + sess_resolved = true; } if let Some(ip) = sess.resolved_ip.or(sess.destination.ip()) { diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index 9fcc2496..648fc472 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -26,6 +26,7 @@ impl OutboundProxy { #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum OutboundProxyProtocol { #[serde(rename = "direct")] Direct(OutboundDirect), diff --git a/clash-lib/src/proxy/group/relay/mod.rs b/clash-lib/src/proxy/group/relay/mod.rs index 6ef53606..4af711bf 100644 --- a/clash-lib/src/proxy/group/relay/mod.rs +++ b/clash-lib/src/proxy/group/relay/mod.rs @@ -46,6 +46,7 @@ impl std::fmt::Debug for Handler { } impl Handler { + #[allow(clippy::new_ret_no_self)] pub fn new( opts: HandlerOptions, providers: Vec, diff --git a/clash-lib/src/proxy/hysteria2/congestion.rs b/clash-lib/src/proxy/hysteria2/congestion.rs index a006ef49..ef1e3a96 100644 --- a/clash-lib/src/proxy/hysteria2/congestion.rs +++ b/clash-lib/src/proxy/hysteria2/congestion.rs @@ -133,11 +133,7 @@ impl Controller for Brutal { None => max_budget, }; - self.budget_at_last_sent = if bytes > budget as u64 { - 0 - } else { - budget as u64 - bytes - }; + self.budget_at_last_sent = (budget as u64).saturating_sub(bytes); self.last_send_time = Some(now); } diff --git a/clash-netstack/tests/common.rs b/clash-netstack/tests/common.rs index 6211f265..0e14b8bf 100644 --- a/clash-netstack/tests/common.rs +++ b/clash-netstack/tests/common.rs @@ -169,10 +169,10 @@ pub fn build_tcp_ack(seq: u32, ack: u32, window: u16) -> Bytes { buf.put_u16(0x0000); // checksum placeholder buf.put_u16(0x0000); // urgent pointer // checksums - let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..].to_vec()); + let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..]); let chk_pos = tcp_start + 16; buf[chk_pos..chk_pos + 2].copy_from_slice(&tcp_sum.to_be_bytes()); - let ip_sum = ipv4_checksum(&buf[..20].to_vec()); + let ip_sum = ipv4_checksum(&buf[..20]); buf[10..12].copy_from_slice(&ip_sum.to_be_bytes()); buf.freeze() } @@ -203,10 +203,10 @@ pub fn build_tcp_syn_packet_with_port(src_port: u16) -> Bytes { buf.put_u16(0x7210); buf.put_u16(0x0000); // TCP checksum placeholder buf.put_u16(0x0000); // urgent - let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..].to_vec()); + let tcp_sum = tcp_udp_checksum(src_ip, dst_ip, 6, &buf[tcp_start..]); let chk_pos = tcp_start + 16; buf[chk_pos..chk_pos + 2].copy_from_slice(&tcp_sum.to_be_bytes()); - let ip_sum = ipv4_checksum(&buf[..20].to_vec()); + let ip_sum = ipv4_checksum(&buf[..20]); buf[10..12].copy_from_slice(&ip_sum.to_be_bytes()); buf.freeze() } diff --git a/clash-netstack/tests/integration_test.rs b/clash-netstack/tests/integration_test.rs index 7c29d559..2131234f 100644 --- a/clash-netstack/tests/integration_test.rs +++ b/clash-netstack/tests/integration_test.rs @@ -283,23 +283,23 @@ async fn test_new_connection_during_active_transfer() { .expect("conn1 stalled for 5 s") .expect("rx1 closed"); - if let Some((seq, payload_len)) = parse_tcp_data(pkt.data()) { - if payload_len > 0 { - let end_seq = seq.wrapping_add(payload_len as u32); - let advance = end_seq.wrapping_sub(cumulative_ack); - if advance > 0 && advance < (1u32 << 31) { - received += advance as usize; - cumulative_ack = end_seq; - } - tun_in - .send(build_tcp_ack(client_seq, cumulative_ack, u16::MAX)) - .unwrap(); - // Signal conn2 client on the first received data segment. - if !signalled { - signalled = true; - if let Some(tx) = ready_tx.take() { - let _ = tx.send(()); - } + if let Some((seq, payload_len)) = parse_tcp_data(pkt.data()) + && payload_len > 0 + { + let end_seq = seq.wrapping_add(payload_len as u32); + let advance = end_seq.wrapping_sub(cumulative_ack); + if advance > 0 && advance < (1u32 << 31) { + received += advance as usize; + cumulative_ack = end_seq; + } + tun_in + .send(build_tcp_ack(client_seq, cumulative_ack, u16::MAX)) + .unwrap(); + // Signal conn2 client on the first received data segment. + if !signalled { + signalled = true; + if let Some(tx) = ready_tx.take() { + let _ = tx.send(()); } } } diff --git a/xhttp-h2-phaseb/src/bin/server.rs b/xhttp-h2-phaseb/src/bin/server.rs index 66d0419b..10250f52 100644 --- a/xhttp-h2-phaseb/src/bin/server.rs +++ b/xhttp-h2-phaseb/src/bin/server.rs @@ -79,10 +79,10 @@ async fn handle( } } Method::POST => { - if sid.is_none() { - Ok(handle_stream_one(req).await) + if let Some(sid) = sid { + Ok(handle_packet_up(req, state, sid, seq).await) } else { - Ok(handle_packet_up(req, state, sid.unwrap(), seq).await) + Ok(handle_stream_one(req).await) } } _ => Ok(simple(StatusCode::METHOD_NOT_ALLOWED)), @@ -133,21 +133,15 @@ async fn handle_stream_down( let (tx, rx) = mpsc::channel::, Infallible>>(32); let store = state.sessions.clone(); - let sid2 = sid.clone(); let queue = session.queue.clone(); tokio::spawn(async move { - loop { - match queue.read_chunk().await { - Some(bytes) => { - if tx.send(Ok(Frame::data(bytes))).await.is_err() { - break; - } - } - None => break, + while let Some(bytes) = queue.read_chunk().await { + if tx.send(Ok(Frame::data(bytes))).await.is_err() { + break; } } - let _ = store.remove(&sid2).await; + let _ = store.remove(&sid).await; }); Response::builder() From e0ded100416f361b85e21e546cb35c122ab0e53a Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:10:52 +0800 Subject: [PATCH 06/70] feat(net): route outbound sockets by destination Select strict per-destination egress interfaces and propagate route-aware socket creation through DNS and proxy connectors. --- .gitignore | 4 +- Cargo.lock | 50 ++- clash-lib/Cargo.toml | 3 + clash-lib/src/app/dns/mod.rs | 7 + clash-lib/src/app/dns/resolver/enhanced.rs | 37 ++ clash-lib/src/app/dns/resolver/system.rs | 19 + .../src/app/dns/resolver/system_static_crt.rs | 19 + clash-lib/src/app/net/mod.rs | 425 +++++++++++++++++- clash-lib/src/lib.rs | 3 +- clash-lib/src/proxy/direct/mod.rs | 93 ++-- clash-lib/src/proxy/transport/xhttp/mod.rs | 16 +- clash-lib/src/proxy/utils/proxy_connector.rs | 147 +++++- clash-lib/src/proxy/utils/socket_helpers.rs | 71 ++- nix/package.nix | 4 + 14 files changed, 824 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index c99a75d0..aa20d09d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ docs/ start*.* release*.* -config*.yaml +config*.yaml* cache.db scripts/ @@ -17,4 +17,4 @@ Country.mmdb geosite.dat -xray* \ No newline at end of file +xray* diff --git a/Cargo.lock b/Cargo.lock index bbb53242..b3a3b69a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,6 +807,7 @@ dependencies = [ "rcgen", "regex", "ring-compat", + "rtnetlink 0.21.0", "rustls", "rustls-pemfile", "serde", @@ -2849,6 +2850,18 @@ dependencies = [ "netlink-packet-core 0.8.1", ] +[[package]] +name = "netlink-packet-route" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +dependencies = [ + "bitflags 2.12.1", + "libc", + "log", + "netlink-packet-core 0.8.1", +] + [[package]] name = "netlink-packet-sock-diag" version = "0.4.2" @@ -2890,6 +2903,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "netlink-proto" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.1", + "netlink-sys", + "thiserror 2.0.18", +] + [[package]] name = "netlink-sys" version = "0.8.8" @@ -3745,13 +3773,31 @@ dependencies = [ "netlink-packet-core 0.7.0", "netlink-packet-route 0.19.0", "netlink-packet-utils", - "netlink-proto", + "netlink-proto 0.11.5", "netlink-sys", "nix 0.27.1", "thiserror 1.0.69", "tokio", ] +[[package]] +name = "rtnetlink" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc19f84f710fa2f337617f9bc0400260a94224bde7bae28fd8879f3771ca5784" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route 0.30.0", + "netlink-proto 0.12.1", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4262,7 +4308,7 @@ dependencies = [ "netlink-packet-core 0.7.0", "netlink-packet-sock-diag", "netlink-sys", - "rtnetlink", + "rtnetlink 0.14.1", "sysctl", "tracing", "windows-sys 0.52.0", diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index 7ce1879a..61f1b092 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -199,6 +199,9 @@ windows = { version = "0.62", features = [ [target.'cfg(any(target_os="linux", target_os="macos", target_os="windows"))'.dependencies] sock2proc = { git = "https://github.com/Watfaq/sock2proc.git", rev = "9f9e630" } +[target.'cfg(target_os="linux")'.dependencies] +rtnetlink = "0.21" + [dev-dependencies] bollard = "0.21" rcgen = "0.14" diff --git a/clash-lib/src/app/dns/mod.rs b/clash-lib/src/app/dns/mod.rs index 5dfd2e49..1835738f 100644 --- a/clash-lib/src/app/dns/mod.rs +++ b/clash-lib/src/app/dns/mod.rs @@ -58,6 +58,13 @@ pub trait ClashResolver: Sync + Send { host: &str, enhanced: bool, ) -> anyhow::Result>; + async fn resolve_all( + &self, + host: &str, + enhanced: bool, + ) -> anyhow::Result> { + Ok(self.resolve(host, enhanced).await?.into_iter().collect()) + } async fn resolve_v4( &self, host: &str, diff --git a/clash-lib/src/app/dns/resolver/enhanced.rs b/clash-lib/src/app/dns/resolver/enhanced.rs index 4fcd3c88..38b2c739 100644 --- a/clash-lib/src/app/dns/resolver/enhanced.rs +++ b/clash-lib/src/app/dns/resolver/enhanced.rs @@ -637,6 +637,43 @@ impl ClashResolver for EnhancedResolver { } } + async fn resolve_all( + &self, + host: &str, + enhanced: bool, + ) -> anyhow::Result> { + if let Some(ip) = parse_ip_literal(host) { + return Ok(vec![ip]); + } + if enhanced { + return Ok(self.resolve(host, true).await?.into_iter().collect()); + } + + let ipv4 = self.lookup_ip(host, rr::RecordType::A); + let ipv6_enabled = self.ipv6.load(Relaxed); + let ipv6 = async { + if ipv6_enabled { + self.lookup_ip(host, rr::RecordType::AAAA).await + } else { + Ok(Vec::new()) + } + }; + let (ipv4, ipv6) = tokio::join!(ipv4, ipv6); + let mut addresses = Vec::new(); + if let Ok(ipv4) = ipv4 { + addresses.extend(ipv4); + } + if let Ok(ipv6) = ipv6 { + addresses.extend(ipv6); + } + addresses.sort_unstable(); + addresses.dedup(); + if addresses.is_empty() { + return Err(anyhow!("no record for hostname: {}", host)); + } + Ok(addresses) + } + #[instrument(skip(self), level = "trace")] async fn resolve_v4( &self, diff --git a/clash-lib/src/app/dns/resolver/system.rs b/clash-lib/src/app/dns/resolver/system.rs index 630da5c8..59e6e133 100644 --- a/clash-lib/src/app/dns/resolver/system.rs +++ b/clash-lib/src/app/dns/resolver/system.rs @@ -52,6 +52,25 @@ impl ClashResolver for SystemResolver { Ok(response.into_iter().choose(&mut rand::rng())) } + async fn resolve_all( + &self, + host: &str, + _: bool, + ) -> anyhow::Result> { + if let Some(ip) = parse_ip_literal(host) { + return Ok(vec![ip]); + } + + let mut addresses = tokio::net::lookup_host(format!("{host}:0")) + .await? + .map(|address| address.ip()) + .filter(|address| self.ipv6() || address.is_ipv4()) + .collect::>(); + addresses.sort_unstable(); + addresses.dedup(); + Ok(addresses) + } + async fn resolve_v4( &self, host: &str, diff --git a/clash-lib/src/app/dns/resolver/system_static_crt.rs b/clash-lib/src/app/dns/resolver/system_static_crt.rs index 6dddd3a2..7565c46d 100644 --- a/clash-lib/src/app/dns/resolver/system_static_crt.rs +++ b/clash-lib/src/app/dns/resolver/system_static_crt.rs @@ -39,6 +39,25 @@ impl ClashResolver for SystemResolver { .choose(&mut rand::rng())) } + async fn resolve_all( + &self, + host: &str, + _: bool, + ) -> anyhow::Result> { + if let Some(ip) = parse_ip_literal(host) { + return Ok(vec![ip]); + } + + let response = self.inner.lookup_ip(host).await?; + let mut addresses = response + .iter() + .filter(|address| self.ipv6() || address.is_ipv4()) + .collect::>(); + addresses.sort_unstable(); + addresses.dedup(); + Ok(addresses) + } + async fn resolve_v4( &self, host: &str, diff --git a/clash-lib/src/app/net/mod.rs b/clash-lib/src/app/net/mod.rs index 190830e4..e3b8d317 100644 --- a/clash-lib/src/app/net/mod.rs +++ b/clash-lib/src/app/net/mod.rs @@ -10,7 +10,10 @@ use network_interface::{ use serde::Serialize; use std::sync::{Arc, LazyLock}; #[cfg(feature = "tun")] -use tracing::{trace, warn}; +use tracing::trace; + +#[cfg(feature = "tun")] +use crate::{Error, Result}; pub static DEFAULT_OUTBOUND_INTERFACE: LazyLock< Arc>>, @@ -18,6 +21,200 @@ pub static DEFAULT_OUTBOUND_INTERFACE: LazyLock< #[cfg(feature = "tun")] pub static TUN_SOMARK: LazyLock>> = LazyLock::new(Default::default); +#[cfg(all(feature = "tun", target_os = "linux"))] +static ROUTE_NETLINK_HANDLE: tokio::sync::OnceCell = + tokio::sync::OnceCell::const_new(); + +#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressFamily { + Ipv4, + Ipv6, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +pub struct RouteDecision { + pub family: AddressFamily, + pub destination: IpAddr, + pub interface_index: u32, + pub interface_name: String, + pub gateway: Option, + pub preferred_source: Option, + pub table: u32, + pub metric: Option, +} + +#[derive(thiserror::Error, Debug)] +pub enum RouteSelectionError { + #[error("failed to initialize route netlink connection: {0}")] + Connection(#[source] std::io::Error), + #[error("netlink route query for {destination} failed: {message}")] + Netlink { + destination: IpAddr, + message: String, + }, + #[error("route query for {destination} failed: {message}")] + QueryFailed { + destination: IpAddr, + message: String, + }, + #[error("invalid route query output for {destination}: {message}")] + InvalidOutput { + destination: IpAddr, + message: String, + }, + #[error("route to {destination} uses unknown interface index {interface_index}")] + InterfaceNotFound { + destination: IpAddr, + interface_index: u32, + }, + #[error("route lookup is unsupported on this platform")] + Unsupported, +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +#[derive(Debug, PartialEq, Eq)] +struct ParsedRoute { + interface_index: u32, + gateway: Option, + preferred_source: Option, + table: u32, + metric: Option, +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +async fn route_netlink_handle() +-> std::result::Result<&'static rtnetlink::Handle, RouteSelectionError> { + ROUTE_NETLINK_HANDLE + .get_or_try_init(|| async { + let (connection, handle, _) = rtnetlink::new_connection() + .map_err(RouteSelectionError::Connection)?; + tokio::spawn(connection); + Ok(handle) + }) + .await +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +fn route_address_to_ip( + address: &rtnetlink::packet_route::route::RouteAddress, +) -> Option { + use rtnetlink::packet_route::route::RouteAddress; + + match address { + RouteAddress::Inet(address) => Some(IpAddr::V4(*address)), + RouteAddress::Inet6(address) => Some(IpAddr::V6(*address)), + _ => None, + } +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +fn parse_netlink_route( + destination: IpAddr, + message: &rtnetlink::packet_route::route::RouteMessage, +) -> std::result::Result { + use rtnetlink::packet_route::route::RouteAttribute; + + let mut interface_index = None; + let mut gateway = None; + let mut preferred_source = None; + let mut table = message.header.table as u32; + let mut metric = None; + + for attribute in &message.attributes { + match attribute { + RouteAttribute::Oif(index) => interface_index = Some(*index), + RouteAttribute::Gateway(address) => { + gateway = route_address_to_ip(address); + } + RouteAttribute::PrefSource(address) => { + preferred_source = route_address_to_ip(address); + } + RouteAttribute::Table(value) => table = *value, + RouteAttribute::Priority(value) => metric = Some(*value), + _ => {} + } + } + + let interface_index = + interface_index.ok_or_else(|| RouteSelectionError::InvalidOutput { + destination, + message: "missing output interface index".to_owned(), + })?; + + Ok(ParsedRoute { + interface_index, + gateway, + preferred_source, + table, + metric, + }) +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +pub async fn route_for_destination( + destination: IpAddr, + fwmark: Option, +) -> std::result::Result { + use futures::TryStreamExt; + use rtnetlink::RouteMessageBuilder; + + let prefix_length = if destination.is_ipv4() { 32 } else { 128 }; + let mut request = RouteMessageBuilder::::new() + .destination_prefix(destination, prefix_length) + .map_err(|error| RouteSelectionError::InvalidOutput { + destination, + message: error.to_string(), + })?; + if let Some(fwmark) = fwmark { + request = request.mark(fwmark); + } + + let handle = route_netlink_handle().await?; + let mut routes = handle.route().get(request.build()).execute(); + let message = routes + .try_next() + .await + .map_err(|error| RouteSelectionError::Netlink { + destination, + message: error.to_string(), + })? + .ok_or_else(|| RouteSelectionError::QueryFailed { + destination, + message: "kernel returned no matching route".to_owned(), + })?; + + let parsed = parse_netlink_route(destination, &message)?; + let interface = + get_interface_by_index(parsed.interface_index).ok_or_else(|| { + RouteSelectionError::InterfaceNotFound { + destination, + interface_index: parsed.interface_index, + } + })?; + + Ok(RouteDecision { + family: if destination.is_ipv4() { + AddressFamily::Ipv4 + } else { + AddressFamily::Ipv6 + }, + destination, + interface_index: parsed.interface_index, + interface_name: interface.name, + gateway: parsed.gateway, + preferred_source: parsed.preferred_source, + table: parsed.table, + metric: parsed.metric, + }) +} + +#[cfg(any(not(feature = "tun"), not(target_os = "linux")))] +pub async fn route_for_destination( + _destination: IpAddr, + _fwmark: Option, +) -> std::result::Result { + Err(RouteSelectionError::Unsupported) +} #[cfg(not(feature = "tun"))] pub fn get_interface_by_name(_name: &str) -> Option { @@ -54,15 +251,16 @@ fn is_candidate_outbound_v6(addr: Ipv6Addr) -> bool { pub async fn init_net_config( tun_somark: Option, interface: Option<&Interface>, -) { +) -> Result<()> { *DEFAULT_OUTBOUND_INTERFACE.write().await = - resolve_outbound_interface(interface); + resolve_outbound_interface(interface)?; *TUN_SOMARK.write().await = tun_somark; trace!( "default outbound interface: {:?}, tun somark: {:?}", *DEFAULT_OUTBOUND_INTERFACE.read().await, *TUN_SOMARK.read().await ); + Ok(()) } #[cfg(feature = "tun")] @@ -173,6 +371,15 @@ pub fn get_interface_by_name(name: &str) -> Option { Some(outbound) } +#[cfg(feature = "tun")] +fn get_interface_by_index(index: u32) -> Option { + network_interface::NetworkInterface::show() + .ok()? + .into_iter() + .find(|interface| interface.index == index) + .map(Into::into) +} + #[cfg(feature = "tun")] pub fn get_interface_by_ip(ip: IpAddr) -> Option { let now = std::time::Instant::now(); @@ -202,25 +409,91 @@ pub fn get_interface_by_ip(ip: IpAddr) -> Option { Some(outbound) } +#[cfg(all(feature = "tun", target_os = "linux"))] +fn validate_linux_interface_flags( + interface: &OutboundInterface, + flags: u32, +) -> Result<()> { + let families = match (interface.addr_v4.is_some(), interface.addr_v6.is_some()) { + (true, true) => "IPv4/IPv6", + (true, false) => "IPv4", + (false, true) => "IPv6", + (false, false) => "none", + }; + + for (flag, state) in [ + (libc::IFF_UP as u32, "UP"), + (libc::IFF_RUNNING as u32, "RUNNING"), + ] { + if flags & flag == 0 { + return Err(Error::InvalidConfig(format!( + "configured outbound interface \"{}\" is not {state}: \ + index={}, family={families}", + interface.name, interface.index + ))); + } + } + + Ok(()) +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +fn validate_configured_interface_state(interface: &OutboundInterface) -> Result<()> { + let flags_path = format!("/sys/class/net/{}/flags", interface.name); + let raw_flags = std::fs::read_to_string(&flags_path).map_err(|error| { + Error::InvalidConfig(format!( + "failed to inspect configured outbound interface \"{}\": \ + index={}, error={error}", + interface.name, interface.index + )) + })?; + let flags = u32::from_str_radix(raw_flags.trim().trim_start_matches("0x"), 16) + .map_err(|error| { + Error::InvalidConfig(format!( + "failed to parse state for configured outbound interface \"{}\": \ + index={}, error={error}", + interface.name, interface.index + )) + })?; + + validate_linux_interface_flags(interface, flags) +} + +#[cfg(all(feature = "tun", not(target_os = "linux")))] +fn validate_configured_interface_state( + _interface: &OutboundInterface, +) -> Result<()> { + Ok(()) +} + #[cfg(feature = "tun")] pub fn resolve_outbound_interface( interface: Option<&Interface>, -) -> Option { - let configured = interface.and_then(|interface| match interface { +) -> Result> { + let Some(interface) = interface else { + return Ok(None); + }; + + let configured = match interface { Interface::IpAddr(ip) => get_interface_by_ip(*ip), Interface::Name(name) => get_interface_by_name(name), - }); - - if let Some(interface) = interface - && configured.is_none() - { - warn!( - "configured outbound interface {} not found, falling back to auto-detect", - interface - ); + } + .ok_or_else(|| { + Error::InvalidConfig(format!( + "configured outbound interface \"{interface}\" does not exist" + )) + })?; + + if configured.addr_v4.is_none() && configured.addr_v6.is_none() { + return Err(Error::InvalidConfig(format!( + "configured outbound interface \"{}\" has no usable IP address", + configured.name + ))); } - configured.or_else(get_outbound_interface) + validate_configured_interface_state(&configured)?; + + Ok(Some(configured)) } #[cfg(feature = "tun")] @@ -336,3 +609,125 @@ impl Interface { } } } + +#[cfg(all(test, feature = "tun"))] +mod tests { + use super::{Interface, OutboundInterface, resolve_outbound_interface}; + use crate::Error; + + #[cfg(target_os = "linux")] + fn test_interface() -> OutboundInterface { + OutboundInterface { + name: "test0".to_owned(), + addr_v4: Some(std::net::Ipv4Addr::new(192, 0, 2, 1)), + netmask_v4: None, + broadcast_v4: None, + addr_v6: None, + netmask_v6: None, + broadcast_v6: None, + index: 42, + mac_addr: None, + } + } + + #[test] + fn configured_interface_does_not_fall_back_when_missing() { + let interface = Interface::Name("__chimera_missing_interface__".to_owned()); + + let error = resolve_outbound_interface(Some(&interface)).unwrap_err(); + + assert!(matches!( + error, + Error::InvalidConfig(message) + if message + == "configured outbound interface \ + \"__chimera_missing_interface__\" does not exist" + )); + } + + #[test] + fn unconfigured_interface_does_not_guess_an_outbound() { + assert!(resolve_outbound_interface(None).unwrap().is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn configured_interface_must_be_up_and_running() { + let interface = test_interface(); + + let down = super::validate_linux_interface_flags(&interface, 0).unwrap_err(); + assert!(matches!( + down, + Error::InvalidConfig(message) + if message.contains("is not UP") + && message.contains("index=42") + && message.contains("family=IPv4") + )); + + let not_running = + super::validate_linux_interface_flags(&interface, libc::IFF_UP as u32) + .unwrap_err(); + assert!(matches!( + not_running, + Error::InvalidConfig(message) + if message.contains("is not RUNNING") + )); + + super::validate_linux_interface_flags( + &interface, + (libc::IFF_UP | libc::IFF_RUNNING) as u32, + ) + .unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn parses_linux_route_decision_fields() { + use std::net::IpAddr; + + use rtnetlink::RouteMessageBuilder; + + let destination = "185.148.13.16".parse().unwrap(); + let message = RouteMessageBuilder::::new() + .destination_prefix(destination, 32) + .unwrap() + .output_interface(42) + .gateway("192.168.1.1".parse().unwrap()) + .unwrap() + .pref_source("192.168.1.9".parse().unwrap()) + .unwrap() + .table_id(100) + .priority(20) + .build(); + let route = super::parse_netlink_route(destination, &message).unwrap(); + + assert_eq!(route.interface_index, 42); + assert_eq!(route.gateway, Some("192.168.1.1".parse().unwrap())); + assert_eq!(route.preferred_source, Some("192.168.1.9".parse().unwrap())); + assert_eq!(route.table, 100); + assert_eq!(route.metric, Some(20)); + } + + #[cfg(target_os = "linux")] + #[test] + fn route_parser_defaults_to_main_table() { + use std::net::IpAddr; + + use rtnetlink::RouteMessageBuilder; + + let destination = "2606:4700::1111".parse().unwrap(); + let message = RouteMessageBuilder::::new() + .destination_prefix(destination, 128) + .unwrap() + .output_interface(7) + .pref_source("fd00::2".parse().unwrap()) + .unwrap() + .priority(5) + .build(); + let route = super::parse_netlink_route(destination, &message).unwrap(); + + assert_eq!(route.interface_index, 7); + assert_eq!(route.gateway, None); + assert_eq!(route.table, 254); + } +} diff --git a/clash-lib/src/lib.rs b/clash-lib/src/lib.rs index 2c78bc5a..3fb62eea 100644 --- a/clash-lib/src/lib.rs +++ b/clash-lib/src/lib.rs @@ -493,7 +493,8 @@ async fn create_components( interface" ); } - init_net_config(config.tun.so_mark, config.general.interface.as_ref()).await; + init_net_config(config.tun.so_mark, config.general.interface.as_ref()) + .await?; install_default_socket_protector(); } diff --git a/clash-lib/src/proxy/direct/mod.rs b/clash-lib/src/proxy/direct/mod.rs index 12d05c99..410b3952 100644 --- a/clash-lib/src/proxy/direct/mod.rs +++ b/clash-lib/src/proxy/direct/mod.rs @@ -1,7 +1,6 @@ use std::fmt::Debug; use async_trait::async_trait; -use futures::TryFutureExt; pub(crate) mod datagram; @@ -15,13 +14,12 @@ use crate::{ }, dns::ThreadSafeDNSResolver, }, - common::errors::map_io_error, config::internal::proxy::PROXY_DIRECT, proxy::{ ConnectorType, DialWithConnector, OutboundHandler, OutboundType, utils::{ - RemoteConnector, new_protected_dual_stack_udp_socket, - new_protected_tcp_stream, + GLOBAL_DIRECT_CONNECTOR, RemoteConnector, + new_protected_dual_stack_udp_socket, new_protected_udp_socket, }, }, }; @@ -68,19 +66,16 @@ impl OutboundHandler for Handler { sess: &Session, resolver: ThreadSafeDNSResolver, ) -> std::io::Result { - let remote_ip = resolver - .resolve(sess.destination.host().as_str(), false) - .map_err(map_io_error) - .await? - .ok_or_else(|| std::io::Error::other("no dns result"))?; - - let s = new_protected_tcp_stream( - (remote_ip, sess.destination.port()).into(), - sess.iface.as_ref(), - #[cfg(target_os = "linux")] - sess.so_mark, - ) - .await?; + let s = GLOBAL_DIRECT_CONNECTOR + .connect_stream( + resolver, + sess.destination.host().as_str(), + sess.destination.port(), + sess.iface.as_ref(), + #[cfg(target_os = "linux")] + sess.so_mark, + ) + .await?; let s = ChainedStreamWrapper::new(s); s.append_to_chain(self.name()).await; @@ -92,15 +87,30 @@ impl OutboundHandler for Handler { sess: &Session, resolver: ThreadSafeDNSResolver, ) -> std::io::Result { - // The outbound socket is shared across all destinations from the same - // client. Use a dual-stack socket so one socket can send to both IPv4 - // and IPv6 destinations without EAFNOSUPPORT. - let udp = new_protected_dual_stack_udp_socket( - sess.iface.as_ref(), - #[cfg(target_os = "linux")] - sess.so_mark, - ) - .await?; + let udp = if sess.typ == crate::session::Type::Dns { + let destination = sess.destination.ip().ok_or_else(|| { + std::io::Error::other( + "DNS UDP destination must be resolved before dialing", + ) + })?; + new_protected_udp_socket( + None, + sess.iface.as_ref(), + #[cfg(target_os = "linux")] + sess.so_mark, + Some((destination, sess.destination.port()).into()), + ) + .await? + } else { + // General direct UDP sockets may serve multiple destinations, so + // they must not be pinned to the first packet's route. + new_protected_dual_stack_udp_socket( + sess.iface.as_ref(), + #[cfg(target_os = "linux")] + sess.so_mark, + ) + .await? + }; let d = ChainedDatagramWrapper::new(OutboundDatagramImpl::new(udp, resolver)); @@ -227,6 +237,37 @@ mod tests { assert_eq!(pkt.data, b"hello-v4"); } + #[tokio::test] + async fn test_connect_datagram_dns_uses_resolved_upstream() { + let echo = spawn_udp_echo("127.0.0.1:0").await; + let handler = Handler::new("DIRECT"); + let sess = Session { + network: Network::Udp, + typ: Type::Dns, + destination: SocksAddr::Ip(echo), + ..Default::default() + }; + + let mut datagram = handler + .connect_datagram(&sess, make_resolver()) + .await + .expect("DNS connect_datagram failed"); + datagram + .send(UdpPacket { + data: b"dns-query".to_vec(), + dst_addr: SocksAddr::Ip(echo), + ..Default::default() + }) + .await + .expect("DNS send failed"); + + let packet = tokio::time::timeout(Duration::from_secs(2), datagram.next()) + .await + .expect("DNS response timed out") + .expect("DNS stream ended"); + assert_eq!(packet.data, b"dns-query"); + } + /// Same direct UDP socket, two different IPv4 destinations. This validates /// the 1-to-N multiplexing that requires a dual-stack socket. #[tokio::test] diff --git a/clash-lib/src/proxy/transport/xhttp/mod.rs b/clash-lib/src/proxy/transport/xhttp/mod.rs index 66cac542..170b6f40 100644 --- a/clash-lib/src/proxy/transport/xhttp/mod.rs +++ b/clash-lib/src/proxy/transport/xhttp/mod.rs @@ -18,10 +18,11 @@ use super::RealityClient; #[cfg(feature = "tls")] use super::TlsClient; use super::Transport; +#[cfg(all(feature = "tun", target_os = "linux"))] +use crate::app::net::TUN_SOMARK; use crate::{ - app::net::DEFAULT_OUTBOUND_INTERFACE, common::errors::map_io_error, - proxy::{AnyStream, utils::new_tcp_stream}, + proxy::{AnyStream, utils::new_protected_tcp_stream}, }; const DUPLEX_BUFFER_SIZE: usize = 64 * 1024; @@ -176,12 +177,15 @@ async fn connect_download_stream( .ok_or_else(|| { io::Error::other("xhttp download server resolved no addresses") })?; - let iface = DEFAULT_OUTBOUND_INTERFACE.read().await.clone(); - let tcp = new_tcp_stream( + #[cfg(all(feature = "tun", target_os = "linux"))] + let so_mark = *TUN_SOMARK.read().await; + #[cfg(all(not(feature = "tun"), target_os = "linux"))] + let so_mark = None; + let tcp = new_protected_tcp_stream( endpoint, - iface.as_ref(), - #[cfg(target_os = "linux")] None, + #[cfg(target_os = "linux")] + so_mark, ) .await?; let stream: AnyStream = Box::new(tcp); diff --git a/clash-lib/src/proxy/utils/proxy_connector.rs b/clash-lib/src/proxy/utils/proxy_connector.rs index 1fa6d2fa..8e68b254 100644 --- a/clash-lib/src/proxy/utils/proxy_connector.rs +++ b/clash-lib/src/proxy/utils/proxy_connector.rs @@ -1,8 +1,11 @@ use async_trait::async_trait; +use futures::{FutureExt, StreamExt, stream::FuturesUnordered}; use std::{ + collections::VecDeque, fmt::Debug, - net::SocketAddr, + net::{IpAddr, SocketAddr}, sync::{Arc, LazyLock}, + time::Duration, }; use tracing::trace; @@ -62,6 +65,28 @@ fn global_direct_connector() -> Arc { Arc::new(DirectConnector::new()) } +fn interleave_ip_families(addresses: Vec) -> Vec { + let prefer_ipv6 = addresses.first().is_some_and(IpAddr::is_ipv6); + let (mut ipv4, mut ipv6): (VecDeque<_>, VecDeque<_>) = + addresses.into_iter().partition(IpAddr::is_ipv4); + let mut ordered = Vec::with_capacity(ipv4.len() + ipv6.len()); + + while !ipv4.is_empty() || !ipv6.is_empty() { + let (primary, secondary) = if prefer_ipv6 { + (&mut ipv6, &mut ipv4) + } else { + (&mut ipv4, &mut ipv6) + }; + if let Some(address) = primary.pop_front() { + ordered.push(address); + } + if let Some(address) = secondary.pop_front() { + ordered.push(address); + } + } + ordered +} + #[async_trait] impl RemoteConnector for DirectConnector { async fn connect_stream( @@ -72,20 +97,59 @@ impl RemoteConnector for DirectConnector { iface: Option<&OutboundInterface>, #[cfg(target_os = "linux")] so_mark: Option, ) -> std::io::Result { - let dial_addr = resolver - .resolve(address, false) - .await - .map_err(|v| new_io_error(format!("can't resolve dns: {v}")))? - .ok_or(new_io_error("no dns result"))?; + let addresses = if let Ok(ip) = address.parse() { + vec![ip] + } else { + resolver + .resolve_all(address, false) + .await + .map_err(|error| { + new_io_error(format!("can't resolve dns: {error}")) + })? + }; + if addresses.is_empty() { + return Err(new_io_error("no dns result")); + } - new_protected_tcp_stream( - (dial_addr, port).into(), - iface, - #[cfg(target_os = "linux")] - so_mark, - ) - .await - .map(|x| Box::new(x) as _) + let mut attempts = FuturesUnordered::new(); + for (attempt, dial_addr) in + interleave_ip_families(addresses).into_iter().enumerate() + { + let iface = iface.cloned(); + attempts.push( + async move { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis( + 300 * attempt as u64, + )) + .await; + } + let result = new_protected_tcp_stream( + (dial_addr, port).into(), + iface.as_ref(), + #[cfg(target_os = "linux")] + so_mark, + ) + .await; + (dial_addr, result) + } + .boxed(), + ); + } + + let mut errors = Vec::new(); + while let Some((dial_addr, result)) = attempts.next().await { + match result { + Ok(stream) => return Ok(Box::new(stream) as _), + Err(error) => { + errors.push(format!("{dial_addr}: {error}")); + } + } + } + Err(new_io_error(format!( + "all resolved addresses failed: {}", + errors.join("; ") + ))) } async fn connect_datagram( @@ -204,3 +268,58 @@ impl RemoteConnector for ProxyConnector { Ok(Box::new(stream)) } } + +#[cfg(test)] +mod tests { + use std::{net::IpAddr, sync::Arc}; + + use tokio::net::TcpListener; + + use super::{DirectConnector, RemoteConnector}; + use crate::app::dns::MockClashResolver; + + #[tokio::test] + async fn direct_connector_tries_the_next_resolved_address() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut resolver = MockClashResolver::new(); + resolver + .expect_resolve_all() + .withf(|host, enhanced| host == "proxy.test" && !enhanced) + .once() + .returning(|_, _| { + Ok(vec![ + "127.0.0.2".parse::().unwrap(), + "127.0.0.1".parse::().unwrap(), + ]) + }); + + let stream = DirectConnector::new() + .connect_stream( + Arc::new(resolver), + "proxy.test", + port, + None, + #[cfg(target_os = "linux")] + None, + ) + .await + .expect("second resolved address should connect"); + + drop(stream); + drop(listener); + } + + #[test] + fn happy_eyeballs_interleaves_address_families() { + let addresses = ["192.0.2.1", "192.0.2.2", "2001:db8::1", "2001:db8::2"] + .map(|address| address.parse().unwrap()) + .to_vec(); + + assert_eq!( + super::interleave_ip_families(addresses), + ["192.0.2.1", "2001:db8::1", "192.0.2.2", "2001:db8::2",] + .map(|address| address.parse::().unwrap()) + ); + } +} diff --git a/clash-lib/src/proxy/utils/socket_helpers.rs b/clash-lib/src/proxy/utils/socket_helpers.rs index aaed9316..fa981719 100644 --- a/clash-lib/src/proxy/utils/socket_helpers.rs +++ b/clash-lib/src/proxy/utils/socket_helpers.rs @@ -13,6 +13,10 @@ use tracing::{debug, error, instrument, trace}; #[cfg(feature = "tun")] use crate::app::net::DEFAULT_OUTBOUND_INTERFACE; use crate::app::net::OutboundInterface; +#[cfg(all(feature = "tun", target_os = "linux"))] +use crate::app::net::{get_interface_by_name, route_for_destination}; +#[cfg(all(feature = "tun", target_os = "linux"))] +use crate::common::errors::new_io_error; use crate::proxy::utils::platform::{ maybe_protect_socket, must_bind_socket_on_interface, }; @@ -173,10 +177,51 @@ async fn default_outbound_interface() -> Option { DEFAULT_OUTBOUND_INTERFACE.read().await.clone() } +#[cfg(feature = "tun")] +async fn effective_interface_for_destination( + destination: Option, + explicit: Option<&OutboundInterface>, + #[cfg(target_os = "linux")] so_mark: Option, +) -> std::io::Result> { + let default_iface = default_outbound_interface().await; + let mut effective_iface = + select_effective_iface(explicit, &default_iface).cloned(); + + #[cfg(target_os = "linux")] + if let Some(destination) = destination.filter(|_| effective_iface.is_none()) + && !destination.is_loopback() + { + let route = route_for_destination(destination, so_mark) + .await + .map_err(new_io_error)?; + debug!( + destination = %destination, + interface = %route.interface_name, + interface_index = route.interface_index, + gateway = ?route.gateway, + preferred_source = ?route.preferred_source, + table = route.table, + metric = ?route.metric, + fwmark = ?so_mark, + "selected outbound route" + ); + effective_iface = + Some(get_interface_by_name(&route.interface_name).ok_or_else(|| { + new_io_error(format!( + "route interface \"{}\" disappeared before connecting \ + to {destination}", + route.interface_name + )) + })?); + } + + Ok(effective_iface) +} + /// Create an outbound TCP socket protected from being routed back into TUN. /// -/// This mirrors mihomo's dialer behavior: a per-session explicit interface -/// wins, otherwise the detected default outbound interface is applied. +/// A per-session or configured interface wins. On Linux, automatic mode +/// queries the kernel route for this concrete destination before binding. pub async fn new_protected_tcp_stream( endpoint: SocketAddr, iface: Option<&OutboundInterface>, @@ -184,11 +229,16 @@ pub async fn new_protected_tcp_stream( ) -> std::io::Result { #[cfg(feature = "tun")] { - let default_iface = default_outbound_interface().await; - let effective_iface = select_effective_iface(iface, &default_iface); + let effective_iface = effective_interface_for_destination( + Some(endpoint.ip()), + iface, + #[cfg(target_os = "linux")] + so_mark, + ) + .await?; return new_tcp_stream( endpoint, - effective_iface, + effective_iface.as_ref(), #[cfg(target_os = "linux")] so_mark, ) @@ -498,11 +548,16 @@ pub async fn new_protected_udp_socket( ) -> std::io::Result { #[cfg(feature = "tun")] { - let default_iface = default_outbound_interface().await; - let effective_iface = select_effective_iface(iface, &default_iface); + let effective_iface = effective_interface_for_destination( + family_hint.map(|address| address.ip()), + iface, + #[cfg(target_os = "linux")] + so_mark, + ) + .await?; return new_udp_socket( src, - effective_iface, + effective_iface.as_ref(), #[cfg(target_os = "linux")] so_mark, family_hint, diff --git a/nix/package.nix b/nix/package.nix index d8b4c9a8..65909f39 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -24,8 +24,12 @@ rustPlatform.buildRustPackage { || baseName == "logs" || baseName == "nix" || baseName == ".git" + || baseName == ".direnv" + || baseName == ".envrc" + || baseName == "cache.db" || baseName == "result" || lib.hasPrefix "result-" baseName + || lib.hasInfix ".yaml" baseName || lib.hasSuffix ".log" baseName ); }; From 6cc4c5a329cd10e665167133e77e9c8f0058f76f Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:11:11 +0800 Subject: [PATCH 07/70] feat(tun): make Linux policy routing transactional Add owned-rule cleanup, rollback on partial failure, explicit IPv6 policy tables, and rtnetlink-backed excluded/interface routes with regression tests. --- clash-lib/src/app/net/mod.rs | 72 +++ clash-lib/src/config/def.rs | 12 + clash-lib/src/config/internal/config.rs | 1 + clash-lib/src/config/internal/convert/tun.rs | 82 ++- clash-lib/src/proxy/tun/routes/linux.rs | 507 +++++++++++++------ clash-lib/src/proxy/tun/routes/mod.rs | 13 +- clash-lib/src/proxy/tun/runner.rs | 2 +- 7 files changed, 519 insertions(+), 170 deletions(-) diff --git a/clash-lib/src/app/net/mod.rs b/clash-lib/src/app/net/mod.rs index e3b8d317..3d6de7c1 100644 --- a/clash-lib/src/app/net/mod.rs +++ b/clash-lib/src/app/net/mod.rs @@ -57,6 +57,12 @@ pub enum RouteSelectionError { destination: IpAddr, message: String, }, + #[error("failed to {operation} route {destination}: {message}")] + NetlinkOperation { + operation: &'static str, + destination: ipnet::IpNet, + message: String, + }, #[error("invalid route query output for {destination}: {message}")] InvalidOutput { destination: IpAddr, @@ -208,6 +214,48 @@ pub async fn route_for_destination( }) } +#[cfg(all(feature = "tun", target_os = "linux"))] +fn build_interface_route( + destination: ipnet::IpNet, + interface_index: u32, +) -> std::result::Result< + rtnetlink::packet_route::route::RouteMessage, + RouteSelectionError, +> { + use rtnetlink::{RouteMessageBuilder, packet_route::route::RouteScope}; + + Ok(RouteMessageBuilder::::new() + .destination_prefix(destination.addr(), destination.prefix_len()) + .map_err(|error| RouteSelectionError::NetlinkOperation { + operation: "build", + destination, + message: error.to_string(), + })? + .output_interface(interface_index) + .scope(RouteScope::Link) + .build()) +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +pub async fn add_route_to_interface( + destination: ipnet::IpNet, + interface_index: u32, +) -> std::result::Result<(), RouteSelectionError> { + let route = build_interface_route(destination, interface_index)?; + + route_netlink_handle() + .await? + .route() + .add(route) + .execute() + .await + .map_err(|error| RouteSelectionError::NetlinkOperation { + operation: "add", + destination, + message: error.to_string(), + }) +} + #[cfg(any(not(feature = "tun"), not(target_os = "linux")))] pub async fn route_for_destination( _destination: IpAddr, @@ -730,4 +778,28 @@ mod tests { assert_eq!(route.gateway, None); assert_eq!(route.table, 254); } + + #[cfg(target_os = "linux")] + #[test] + fn builds_link_scoped_interface_route() { + use rtnetlink::packet_route::route::{RouteAttribute, RouteScope}; + + let destination = "fd00:198:18::/64".parse().unwrap(); + let message = super::build_interface_route(destination, 42).unwrap(); + + assert_eq!(message.header.destination_prefix_length, 64); + assert_eq!(message.header.scope, RouteScope::Link); + assert!( + message + .attributes + .iter() + .any(|attribute| matches!(attribute, RouteAttribute::Oif(42))) + ); + assert!(message.attributes.iter().any(|attribute| matches!( + attribute, + RouteAttribute::Destination(address) + if super::route_address_to_ip(address) + == Some("fd00:198:18::".parse().unwrap()) + ))); + } } diff --git a/clash-lib/src/config/def.rs b/clash-lib/src/config/def.rs index fb0d6c2f..9c587d29 100644 --- a/clash-lib/src/config/def.rs +++ b/clash-lib/src/config/def.rs @@ -17,6 +17,7 @@ use crate::{ }; const DEFAULT_ROUTE_TABLE: u32 = 2468; +const DEFAULT_ROUTE_TABLE_V6: u32 = 2469; fn default_tun_device_id() -> String { "utun1989".to_string() @@ -28,10 +29,18 @@ fn default_tun_address() -> String { "198.18.0.1/30".to_string() } +pub(crate) fn default_tun_address_v6() -> String { + "fd00:198:18::1/126".to_string() +} + fn default_route_table() -> u32 { DEFAULT_ROUTE_TABLE } +fn default_route_table_v6() -> u32 { + DEFAULT_ROUTE_TABLE_V6 +} + /// todo: will support only in gui fn default_country_mmdb() -> Option { Some("Country.mmdb".to_string()) @@ -59,6 +68,7 @@ pub struct TunConfig { pub device_id: String, #[serde(default = "default_tun_address")] pub gateway: String, + pub ipv6: Option, #[serde(alias = "gateway_v6", alias = "gateway-v6")] pub gateway_v6: Option, pub routes: Option>, @@ -75,6 +85,8 @@ pub struct TunConfig { pub so_mark: Option, #[serde(default = "default_route_table", alias = "route_table")] pub route_table: u32, + #[serde(default = "default_route_table_v6", alias = "route_table_v6")] + pub route_table_v6: u32, #[serde(default, alias = "dns_hijack")] pub dns_hijack: DnsHijack, } diff --git a/clash-lib/src/config/internal/config.rs b/clash-lib/src/config/internal/config.rs index 2f7ae8d4..cea85d42 100644 --- a/clash-lib/src/config/internal/config.rs +++ b/clash-lib/src/config/internal/config.rs @@ -204,6 +204,7 @@ pub struct TunConfig { pub mtu: Option, pub so_mark: Option, pub route_table: u32, + pub route_table_v6: u32, pub dns_hijack: bool, pub dns_hijack_rules: Vec, } diff --git a/clash-lib/src/config/internal/convert/tun.rs b/clash-lib/src/config/internal/convert/tun.rs index 456f8ed6..61927d8e 100644 --- a/clash-lib/src/config/internal/convert/tun.rs +++ b/clash-lib/src/config/internal/convert/tun.rs @@ -105,6 +105,21 @@ pub(super) fn convert( match before { Some(t) => { let (dns_hijack, dns_hijack_rules) = parse_dns_hijack(t.dns_hijack)?; + let gateway_v6 = match (t.ipv6, t.gateway_v6) { + (Some(false), Some(_)) => { + return Err(Error::InvalidConfig( + "tun ipv6 is false but gateway-v6 is configured".to_owned(), + )); + } + (Some(true), None) => Some(def::default_tun_address_v6()), + (_, gateway) => gateway, + }; + if gateway_v6.is_some() && t.route_table == t.route_table_v6 { + return Err(Error::InvalidConfig( + "tun route-table-v6 must differ from route-table when IPv6 is enabled" + .to_owned(), + )); + } let mut route_exclude_address = parse_routes(t.route_exclude_address, "route-exclude-address")?; @@ -147,8 +162,7 @@ pub(super) fn convert( gateway: t.gateway.parse().map_err(|e| { Error::InvalidConfig(format!("parse tun gateway: {e}")) })?, - gateway_v6: t - .gateway_v6 + gateway_v6: gateway_v6 .map(|gateway| { gateway.parse().map_err(|e| { Error::InvalidConfig(format!( @@ -160,6 +174,7 @@ pub(super) fn convert( mtu: t.mtu, so_mark: t.so_mark, route_table: t.route_table, + route_table_v6: t.route_table_v6, dns_hijack, dns_hijack_rules, }) @@ -197,10 +212,73 @@ mod tests { assert_eq!(converted.device_id, "utun1989"); assert_eq!(converted.route_table, 2468); + assert_eq!(converted.route_table_v6, 2469); assert_eq!(converted.gateway.to_string(), "198.18.0.1/30"); assert!(!converted.dns_hijack); } + #[test] + fn parse_separate_ipv6_route_table() { + let tun = parse_tun("enable: true\nroute-table: 100\nroute-table-v6: 101"); + let converted = convert(Some(tun)).expect("tun convert should succeed"); + + assert_eq!(converted.route_table, 100); + assert_eq!(converted.route_table_v6, 101); + } + + #[test] + fn explicitly_enable_ipv6_with_default_gateway() { + let converted = convert(Some(parse_tun("enable: true\nipv6: true"))) + .expect("IPv6 config should succeed"); + + assert_eq!( + converted.gateway_v6.unwrap().to_string(), + "fd00:198:18::1/126" + ); + } + + #[test] + fn preserve_gateway_v6_as_legacy_enable_switch() { + let converted = + convert(Some(parse_tun("enable: true\ngateway-v6: fd00::1/126"))) + .expect("legacy IPv6 config should succeed"); + + assert_eq!(converted.gateway_v6.unwrap().to_string(), "fd00::1/126"); + } + + #[test] + fn reject_gateway_v6_when_ipv6_is_disabled() { + let error = match convert(Some(parse_tun( + "enable: true\nipv6: false\ngateway-v6: fd00::1/126", + ))) { + Ok(_) => panic!("conflicting IPv6 config should fail"), + Err(error) => error, + }; + + assert!(matches!( + error, + crate::Error::InvalidConfig(message) + if message.contains("ipv6 is false") + )); + } + + #[test] + fn reject_shared_route_table_when_ipv6_is_enabled() { + let tun = parse_tun( + "enable: true\ngateway-v6: fd00:198:18::1/126\nroute-table: 100\nroute-table-v6: 100", + ); + let error = match convert(Some(tun)) { + Ok(_) => panic!("shared table should fail"), + Err(error) => error, + }; + + assert!(matches!( + error, + crate::Error::InvalidConfig(message) + if message.contains("route-table-v6 must differ") + )); + } + #[test] fn parse_dns_hijack_list_as_true() { let tun = parse_tun( diff --git a/clash-lib/src/proxy/tun/routes/linux.rs b/clash-lib/src/proxy/tun/routes/linux.rs index 709ed852..9e2e3f07 100644 --- a/clash-lib/src/proxy/tun/routes/linux.rs +++ b/clash-lib/src/proxy/tun/routes/linux.rs @@ -1,10 +1,11 @@ use std::net::IpAddr; use ipnet::IpNet; -use tracing::warn; +use tracing::{debug, info, warn}; use crate::{ - app::net::OutboundInterface, common::errors::new_io_error, + app::net::{OutboundInterface, add_route_to_interface, route_for_destination}, + common::errors::new_io_error, config::internal::config::TunConfig, }; @@ -41,72 +42,49 @@ pub fn check_ip_command_installed() -> std::io::Result<()> { }) } -pub fn add_route(via: &OutboundInterface, dest: &IpNet) -> std::io::Result<()> { - let cmd = std::process::Command::new("ip") - .arg("route") - .arg("add") - .arg(dest.to_string()) - .arg("dev") - .arg(&via.name) - .output()?; - warn!("executing: ip route add {} dev {}", dest, via.name); - if !cmd.status.success() { - return Err(new_io_error(format!( - "add route failed: {}", - String::from_utf8_lossy(&cmd.stderr) - ))); - } - Ok(()) +pub async fn add_route( + via: &OutboundInterface, + dest: &IpNet, +) -> std::io::Result<()> { + add_route_to_interface(*dest, via.index) + .await + .map_err(|error| new_io_error(error.to_string())) } -fn best_route_for(dest: IpAddr) -> std::io::Result<(Option, String)> { - let output = std::process::Command::new("ip") - .args(["route", "get", &dest.to_string()]) - .output()?; - warn!("executing: ip route get {}", dest); - if !output.status.success() { - return Err(new_io_error(format!( - "query best route for {} failed: {}", - dest, - String::from_utf8_lossy(&output.stderr) - ))); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let Some(line) = stdout.lines().next() else { - return Err(new_io_error(format!("no best route found for {dest}"))); - }; - - let mut gateway = None; - let mut dev = None; - let mut parts = line.split_whitespace(); - while let Some(part) = parts.next() { - match part { - "via" => gateway = parts.next().and_then(|gateway| gateway.parse().ok()), - "dev" => { - dev = parts.next().map(ToOwned::to_owned); - } - _ => {} - } - } - - let dev = dev - .ok_or_else(|| new_io_error(format!("no route device found for {dest}")))?; - Ok((gateway, dev)) +async fn best_route_for(dest: IpAddr) -> std::io::Result<(Option, String)> { + let route = route_for_destination(dest, None) + .await + .map_err(|error| new_io_error(error.to_string()))?; + Ok((route.gateway, route.interface_name)) } -fn add_excluded_route(table: &str, dest: &IpNet) -> std::io::Result<()> { +async fn add_excluded_route( + table: &str, + dest: &IpNet, + transaction: &mut RouteTransaction<'_>, +) -> std::io::Result<()> { + let ipv6 = dest.addr().is_ipv6(); let cidr = dest.to_string(); - let (gateway, dev) = best_route_for(dest.addr())?; + let (gateway, dev) = best_route_for(dest.addr()).await?; let mut args = vec!["route", "add", &cidr]; if let Some(gateway) = gateway { let gateway = gateway.to_string(); args.extend(["via", &gateway, "dev", &dev, "table", table]); - run_ip_cmd(&args, false) + transaction.apply( + &args, + &[ + "route", "del", &cidr, "via", &gateway, "dev", &dev, "table", table, + ], + ipv6, + ) } else { args.extend(["dev", &dev, "table", table]); - run_ip_cmd(&args, false) + transaction.apply( + &args, + &["route", "del", &cidr, "dev", &dev, "table", table], + ipv6, + ) } } @@ -115,7 +93,7 @@ pub fn delete_interface(name: &str) -> std::io::Result<()> { let args = ["link", "del", "dev", name]; let deleted = run_ip_cmd_single(&cmd_str, &args, true)?; if deleted { - warn!("deleted stale tun interface {}", name); + info!("deleted stale tun interface {}", name); } Ok(()) } @@ -126,48 +104,104 @@ fn run_ip_cmd_single( allow_missing: bool, ) -> std::io::Result { let cmd = std::process::Command::new("ip").args(args).output()?; - warn!("executing: {}", cmd_str); + debug!("executing: {}", cmd_str); if cmd.status.success() { return Ok(true); } let stderr = String::from_utf8_lossy(&cmd.stderr); if allow_missing && is_missing_ip_state(&stderr) { - warn!("{} already absent: {}", cmd_str, stderr.trim()); + debug!("{} already absent: {}", cmd_str, stderr.trim()); return Ok(false); } Err(new_io_error(format!("{} failed: {}", cmd_str, stderr))) } -/// Run an ip command for IPv4 and optionally IPv6. -/// -/// When allow_missing is true this returns Ok(false) for already-absent rules -/// or routes, allowing cleanup to be idempotent across restarts. -fn run_ip_cmd_with_mode( +fn run_ip_cmd_for_family( args: &[&str], - enable_v6: bool, + ipv6: bool, allow_missing: bool, ) -> std::io::Result { - let cmd_str = format!("ip {}", args.join(" ")); - let mut changed = run_ip_cmd_single(&cmd_str, args, allow_missing)?; - - if enable_v6 { - let mut v6_args = vec!["-6"]; - v6_args.extend_from_slice(args); - let v6_cmd_str = format!("ip -6 {}", args.join(" ")); - changed |= run_ip_cmd_single(&v6_cmd_str, &v6_args, allow_missing)?; + if ipv6 { + let mut family_args = vec!["-6"]; + family_args.extend_from_slice(args); + run_ip_cmd_single( + &format!("ip {}", family_args.join(" ")), + &family_args, + allow_missing, + ) + } else { + run_ip_cmd_single(&format!("ip {}", args.join(" ")), args, allow_missing) } +} - Ok(changed) +trait IpCommandExecutor: Send { + fn execute( + &mut self, + args: &[&str], + allow_missing: bool, + ) -> std::io::Result; } -fn run_ip_cmd(args: &[&str], enable_v6: bool) -> std::io::Result<()> { - run_ip_cmd_with_mode(args, enable_v6, false).map(|_| ()) +struct SystemIpCommandExecutor; + +impl IpCommandExecutor for SystemIpCommandExecutor { + fn execute( + &mut self, + args: &[&str], + allow_missing: bool, + ) -> std::io::Result { + run_ip_cmd_single(&format!("ip {}", args.join(" ")), args, allow_missing) + } } -fn delete_ip_cmd_all(args: &[&str], enable_v6: bool) -> std::io::Result<()> { - while run_ip_cmd_with_mode(args, enable_v6, true)? {} +struct RouteTransaction<'a> { + executor: &'a mut dyn IpCommandExecutor, + applied: Vec>, +} + +impl<'a> RouteTransaction<'a> { + fn new(executor: &'a mut dyn IpCommandExecutor) -> Self { + Self { + executor, + applied: Vec::new(), + } + } + + fn apply( + &mut self, + add_args: &[&str], + delete_args: &[&str], + ipv6: bool, + ) -> std::io::Result<()> { + let mut command = Vec::new(); + let mut inverse = Vec::new(); + if ipv6 { + command.push("-6".to_owned()); + inverse.push("-6".to_owned()); + } + command.extend(add_args.iter().map(|arg| (*arg).to_owned())); + inverse.extend(delete_args.iter().map(|arg| (*arg).to_owned())); + let command = command.iter().map(String::as_str).collect::>(); + self.executor.execute(&command, false)?; + self.applied.push(inverse); + Ok(()) + } + + fn rollback(&mut self) { + while let Some(args) = self.applied.pop() { + let args = args.iter().map(String::as_str).collect::>(); + let cmd = format!("ip {}", args.join(" ")); + if let Err(error) = self.executor.execute(&args, true) { + warn!(command = %cmd, %error, "failed to roll back TUN route operation"); + } + } + } +} + +fn delete_ip_cmd_all(args: &[&str], ipv6: bool) -> std::io::Result<()> { + while run_ip_cmd_for_family(args, ipv6, true)? {} Ok(()) } @@ -178,25 +212,70 @@ fn delete_ip_cmd_all(args: &[&str], enable_v6: bool) -> std::io::Result<()> { /// # ip rule add pref 102 not fwmark 1234 table 2468 /// for ipv6 /// # ip -6 ... -pub fn setup_policy_routing( +pub async fn setup_policy_routing( + tun_cfg: &TunConfig, + via: &OutboundInterface, +) -> std::io::Result<()> { + let mut executor = SystemIpCommandExecutor; + let mut transaction = RouteTransaction::new(&mut executor); + let result = setup_policy_routing_inner(tun_cfg, via, &mut transaction).await; + if result.is_err() { + transaction.rollback(); + } + result +} + +async fn setup_policy_routing_inner( tun_cfg: &TunConfig, via: &OutboundInterface, + transaction: &mut RouteTransaction<'_>, +) -> std::io::Result<()> { + setup_policy_family( + tun_cfg, + via, + &tun_cfg.route_table.to_string(), + false, + transaction, + ) + .await?; + if tun_cfg.gateway_v6.is_some() { + setup_policy_family( + tun_cfg, + via, + &tun_cfg.route_table_v6.to_string(), + true, + transaction, + ) + .await?; + } + Ok(()) +} + +async fn setup_policy_family( + tun_cfg: &TunConfig, + via: &OutboundInterface, + table: &str, + ipv6: bool, + transaction: &mut RouteTransaction<'_>, ) -> std::io::Result<()> { - let table = tun_cfg.route_table.to_string(); let dev = via.name.as_str(); - let enable_v6 = tun_cfg.gateway_v6.is_some(); - run_ip_cmd( - &["route", "add", "default", "dev", dev, "table", &table], - enable_v6, + transaction.apply( + &["route", "add", "default", "dev", dev, "table", table], + &["route", "del", "default", "dev", dev, "table", table], + ipv6, )?; - for route in &tun_cfg.route_exclude_address { - add_excluded_route(&table, route)?; + for route in tun_cfg + .route_exclude_address + .iter() + .filter(|route| route.addr().is_ipv6() == ipv6) + { + add_excluded_route(table, route, transaction).await?; } if let Some(so_mark) = tun_cfg.so_mark { - run_ip_cmd( + transaction.apply( &[ "rule", "add", @@ -207,10 +286,20 @@ pub fn setup_policy_routing( "table", "main", ], - enable_v6, + &[ + "rule", + "del", + "pref", + FWMARK_MAIN_RULE_PREF, + "fwmark", + &so_mark.to_string(), + "table", + "main", + ], + ipv6, )?; - run_ip_cmd( + transaction.apply( &[ "rule", "add", @@ -220,13 +309,24 @@ pub fn setup_policy_routing( "fwmark", &so_mark.to_string(), "table", - &table, + table, ], - enable_v6, + &[ + "rule", + "del", + "pref", + ROUTE_ALL_RULE_PREF, + "not", + "fwmark", + &so_mark.to_string(), + "table", + table, + ], + ipv6, )?; } - run_ip_cmd( + transaction.apply( &[ "rule", "add", @@ -237,11 +337,21 @@ pub fn setup_policy_routing( "suppress_prefixlength", "0", ], - enable_v6, + &[ + "rule", + "del", + "pref", + MAIN_SUPPRESS_RULE_PREF, + "table", + "main", + "suppress_prefixlength", + "0", + ], + ipv6, )?; for port in tun_cfg.dns_hijack_udp_ports() { - run_ip_cmd( + transaction.apply( &[ "rule", "add", @@ -252,9 +362,21 @@ pub fn setup_policy_routing( "dport", &port.to_string(), "table", - &table, + table, + ], + &[ + "rule", + "del", + "pref", + DNS_HIJACK_RULE_PREF, + "ipproto", + "udp", + "dport", + &port.to_string(), + "table", + table, ], - enable_v6, + ipv6, )?; } @@ -272,21 +394,31 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { return Ok(()); } - let table = tun_cfg.route_table.to_string(); - let enable_v6 = tun_cfg.gateway_v6.is_some(); + clean_up_policy_family(tun_cfg, &tun_cfg.route_table.to_string(), false)?; + if tun_cfg.gateway_v6.is_some() { + clean_up_policy_family(tun_cfg, &tun_cfg.route_table_v6.to_string(), true)?; + } + Ok(()) +} - delete_ip_cmd_all(&["route", "del", "default", "table", &table], enable_v6)?; +fn clean_up_policy_family( + tun_cfg: &TunConfig, + table: &str, + ipv6: bool, +) -> std::io::Result<()> { + delete_ip_cmd_all(&["route", "del", "default", "table", table], ipv6)?; - for route in &tun_cfg.route_exclude_address { + for route in tun_cfg + .route_exclude_address + .iter() + .filter(|route| route.addr().is_ipv6() == ipv6) + { delete_ip_cmd_all( - &["route", "del", &route.to_string(), "table", &table], - enable_v6, + &["route", "del", &route.to_string(), "table", table], + ipv6, )?; } - // Also removes the broad source-address bypass emitted by older builds. - delete_ip_cmd_all(&["rule", "del", "pref", "90", "table", "main"], false)?; - if let Some(so_mark) = tun_cfg.so_mark { delete_ip_cmd_all( &[ @@ -299,7 +431,7 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { "table", "main", ], - enable_v6, + ipv6, )?; delete_ip_cmd_all( @@ -312,34 +444,9 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { "fwmark", &so_mark.to_string(), "table", - &table, - ], - enable_v6, - )?; - - delete_ip_cmd_all( - &[ - "rule", - "del", - "fwmark", - &so_mark.to_string(), - "table", - "main", - ], - enable_v6, - )?; - - delete_ip_cmd_all( - &[ - "rule", - "del", - "not", - "fwmark", - &so_mark.to_string(), - "table", - &table, + table, ], - enable_v6, + ipv6, )?; } delete_ip_cmd_all( @@ -353,11 +460,7 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { "suppress_prefixlength", "0", ], - enable_v6, - )?; - delete_ip_cmd_all( - &["rule", "del", "table", "main", "suppress_prefixlength", "0"], - enable_v6, + ipv6, )?; for port in tun_cfg.dns_hijack_udp_ports() { @@ -372,42 +475,41 @@ pub fn maybe_routes_clean_up(tun_cfg: &TunConfig) -> std::io::Result<()> { "dport", &port.to_string(), "table", - &table, + table, ], - enable_v6, + ipv6, )?; } - // Remove rules left by older builds that did not include ipproto. - delete_ip_cmd_all( - &[ - "rule", - "del", - "pref", - DNS_HIJACK_RULE_PREF, - "dport", - "53", - "table", - &table, - ], - enable_v6, - )?; - // Remove the rule emitted by transparent-dns step 1. - delete_ip_cmd_all( - &[ - "rule", "del", "pref", "101", "ipproto", "udp", "dport", "53", "table", - &table, - ], - enable_v6, - )?; - delete_ip_cmd_all(&["rule", "del", "dport", "53", "table", &table], enable_v6)?; - Ok(()) } #[cfg(test)] mod tests { - use super::is_missing_ip_state; + use super::{IpCommandExecutor, RouteTransaction, is_missing_ip_state}; + + #[derive(Default)] + struct MockExecutor { + commands: Vec<(Vec, bool)>, + fail_at: Option, + } + + impl IpCommandExecutor for MockExecutor { + fn execute( + &mut self, + args: &[&str], + allow_missing: bool, + ) -> std::io::Result { + self.commands.push(( + args.iter().map(|arg| (*arg).to_owned()).collect(), + allow_missing, + )); + if self.fail_at == Some(self.commands.len()) { + return Err(std::io::Error::other("injected command failure")); + } + Ok(true) + } + } #[test] fn detect_missing_ip_rule_errors() { @@ -418,4 +520,79 @@ mod tests { assert!(is_missing_ip_state("Error: FIB table does not exist.")); assert!(!is_missing_ip_state("RTNETLINK answers: File exists")); } + + #[test] + fn rollback_runs_only_applied_operations_in_reverse_order() { + let mut executor = MockExecutor { + fail_at: Some(2), + ..Default::default() + }; + { + let mut transaction = RouteTransaction::new(&mut executor); + transaction + .apply( + &["route", "add", "first"], + &["route", "del", "first"], + false, + ) + .unwrap(); + transaction + .apply( + &["route", "add", "second"], + &["route", "del", "second"], + false, + ) + .unwrap_err(); + transaction.rollback(); + } + + assert_eq!( + executor.commands, + vec![ + (vec!["route".into(), "add".into(), "first".into()], false,), + (vec!["route".into(), "add".into(), "second".into()], false,), + (vec!["route".into(), "del".into(), "first".into()], true,), + ] + ); + } + + #[test] + fn ipv6_transaction_prefixes_apply_and_rollback_commands() { + let mut executor = MockExecutor::default(); + { + let mut transaction = RouteTransaction::new(&mut executor); + transaction + .apply( + &["route", "add", "default"], + &["route", "del", "default"], + true, + ) + .unwrap(); + transaction.rollback(); + } + + assert_eq!( + executor.commands, + vec![ + ( + vec![ + "-6".into(), + "route".into(), + "add".into(), + "default".into(), + ], + false, + ), + ( + vec![ + "-6".into(), + "route".into(), + "del".into(), + "default".into(), + ], + true, + ), + ] + ); + } } diff --git a/clash-lib/src/proxy/tun/routes/mod.rs b/clash-lib/src/proxy/tun/routes/mod.rs index 4d8de6b8..5f255e8e 100644 --- a/clash-lib/src/proxy/tun/routes/mod.rs +++ b/clash-lib/src/proxy/tun/routes/mod.rs @@ -33,7 +33,10 @@ use crate::config::internal::config::TunConfig; use crate::app::net::get_interface_by_name; #[allow(dead_code)] -pub fn maybe_add_routes(cfg: &TunConfig, tun_name: &str) -> std::io::Result<()> { +pub async fn maybe_add_routes( + cfg: &TunConfig, + tun_name: &str, +) -> std::io::Result<()> { if cfg.route_all || !cfg.routes.is_empty() { #[cfg(target_os = "linux")] linux::check_ip_command_installed()?; @@ -111,6 +114,9 @@ pub fn maybe_add_routes(cfg: &TunConfig, tun_name: &str) -> std::io::Result<()> } for r in default_routes { + #[cfg(target_os = "linux")] + add_route(&tun_iface, &r).await?; + #[cfg(not(target_os = "linux"))] add_route(&tun_iface, &r)?; } @@ -144,10 +150,13 @@ pub fn maybe_add_routes(cfg: &TunConfig, tun_name: &str) -> std::io::Result<()> } #[cfg(target_os = "linux")] { - linux::setup_policy_routing(cfg, &tun_iface)?; + linux::setup_policy_routing(cfg, &tun_iface).await?; } } else { for r in &cfg.routes { + #[cfg(target_os = "linux")] + add_route(&tun_iface, r).await?; + #[cfg(not(target_os = "linux"))] add_route(&tun_iface, r)?; } } diff --git a/clash-lib/src/proxy/tun/runner.rs b/clash-lib/src/proxy/tun/runner.rs index 9d936e3d..cd962511 100644 --- a/clash-lib/src/proxy/tun/runner.rs +++ b/clash-lib/src/proxy/tun/runner.rs @@ -269,7 +269,7 @@ impl TunRunner { info!("reconciling routes for existing tun {}", &tun_name); } - maybe_add_routes(cfg, &tun_name)?; + maybe_add_routes(cfg, &tun_name).await?; dev } From 390b8a793fb8a02fe0a8abad3f28bd4f53a2a36c Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:11:35 +0800 Subject: [PATCH 08/70] fix(dns): enforce TUN IPv6 capability Reject incompatible runtime updates, filter AAAA responses for IPv4-only TUN, and verify marked IPv6 upstream sessions. --- clash-lib/src/app/api/handlers/config.rs | 31 +++++++++ clash-lib/src/app/api/runner.rs | 5 ++ clash-lib/src/app/dns/runtime.rs | 72 ++++++++++++-------- clash-lib/src/app/dns/server/handler.rs | 44 ++++++++++++ clash-lib/src/config/internal/convert/mod.rs | 53 ++++++++++++++ clash-lib/src/lib.rs | 5 ++ 6 files changed, 180 insertions(+), 30 deletions(-) diff --git a/clash-lib/src/app/api/handlers/config.rs b/clash-lib/src/app/api/handlers/config.rs index c1fcd7d1..3ece37a7 100644 --- a/clash-lib/src/app/api/handlers/config.rs +++ b/clash-lib/src/app/api/handlers/config.rs @@ -44,6 +44,7 @@ struct ConfigState { dns_resolver: ThreadSafeDNSResolver, dns_listen_addr: DNSListenAddr, dns_enabled: bool, + ipv6_allowed: bool, } pub fn routes( @@ -53,6 +54,7 @@ pub fn routes( dns_resolver: ThreadSafeDNSResolver, dns_listen_addr: DNSListenAddr, dns_enabled: bool, + ipv6_allowed: bool, ) -> Router> { Router::new() .route( @@ -66,6 +68,7 @@ pub fn routes( dns_resolver, dns_listen_addr, dns_enabled, + ipv6_allowed, }) } @@ -259,10 +262,25 @@ impl PatchConfigRequest { } } +fn validate_ipv6_update( + requested: Option, + allowed: bool, +) -> Result<(), &'static str> { + if requested == Some(true) && !allowed { + Err("IPv6 cannot be enabled while TUN is IPv4-only") + } else { + Ok(()) + } +} + async fn patch_configs( State(state): State, Json(payload): Json, ) -> impl IntoResponse { + if let Err(message) = validate_ipv6_update(payload.ipv6, state.ipv6_allowed) { + return (StatusCode::BAD_REQUEST, message).into_response(); + } + let inbound_manager = state.inbound_manager.clone(); let mut need_restart = false; if let Some(bind_address) = payload.bind_address.clone() { @@ -321,3 +339,16 @@ async fn patch_configs( StatusCode::ACCEPTED.into_response() } + +#[cfg(test)] +mod tests { + use super::validate_ipv6_update; + + #[test] + fn blocks_enabling_ipv6_for_ipv4_only_tun() { + assert!(validate_ipv6_update(Some(true), false).is_err()); + assert!(validate_ipv6_update(Some(false), false).is_ok()); + assert!(validate_ipv6_update(None, false).is_ok()); + assert!(validate_ipv6_update(Some(true), true).is_ok()); + } +} diff --git a/clash-lib/src/app/api/runner.rs b/clash-lib/src/app/api/runner.rs index 9e09df47..245fc46a 100644 --- a/clash-lib/src/app/api/runner.rs +++ b/clash-lib/src/app/api/runner.rs @@ -48,6 +48,7 @@ pub struct ApiRunner { cancellation_token: tokio_util::sync::CancellationToken, dns_listen_addr: DNSListenAddr, dns_enabled: bool, + ipv6_allowed: bool, task: std::sync::Mutex>>>, } @@ -69,6 +70,7 @@ impl ApiRunner { cancellation_token: Option, dns_listen_addr: DNSListenAddr, dns_enabled: bool, + ipv6_allowed: bool, ) -> Self { Self { controller_cfg, @@ -85,6 +87,7 @@ impl ApiRunner { cancellation_token: cancellation_token.unwrap_or_default(), dns_listen_addr, dns_enabled, + ipv6_allowed, task: std::sync::Mutex::new(None), } } @@ -104,6 +107,7 @@ impl Runner for ApiRunner { let cwd = self.cwd.clone(); let dns_listen_addr = self.dns_listen_addr.clone(); let dns_enabled = self.dns_enabled; + let ipv6_allowed = self.ipv6_allowed; let cancellation_token = self.cancellation_token.clone(); tracing::debug!("API controller configuration: {:?}", controller_cfg); @@ -173,6 +177,7 @@ impl Runner for ApiRunner { dns_resolver.clone(), dns_listen_addr, dns_enabled, + ipv6_allowed, ), ) .nest( diff --git a/clash-lib/src/app/dns/runtime.rs b/clash-lib/src/app/dns/runtime.rs index ca51122c..e66612bd 100644 --- a/clash-lib/src/app/dns/runtime.rs +++ b/clash-lib/src/app/dns/runtime.rs @@ -85,6 +85,24 @@ impl DnsRuntimeProvider { .await .unwrap_or_else(|| self.outbound.clone()) } + + fn session_for(&self, server_addr: SocketAddr, network: Network) -> Session { + let source = if server_addr.is_ipv4() { + "0.0.0.0:0".parse().unwrap() + } else { + "[::]:0".parse().unwrap() + }; + + Session { + source, + network, + typ: Type::Dns, + destination: server_addr.into(), + so_mark: self.so_mark, + iface: self.iface.clone(), + ..Default::default() + } + } } impl RuntimeProvider for DnsRuntimeProvider { @@ -105,23 +123,9 @@ impl RuntimeProvider for DnsRuntimeProvider { _timeout: Option, ) -> std::pin::Pin>>> { - let src: SocketAddr = if server_addr.is_ipv4() { - "0.0.0.0:0".parse().unwrap() - } else { - "[::]:0".parse().unwrap() - }; - let provider = self.clone(); let dns = self.dns_resolver.clone(); - let sess = Session { - source: src, - network: Network::Tcp, - typ: Type::Dns, - destination: server_addr.into(), - so_mark: self.so_mark, - iface: self.iface.clone(), - ..Default::default() - }; + let sess = self.session_for(server_addr, Network::Tcp); Box::pin(async move { let outbound = provider.pick_outbound(&sess).await; let stream = outbound.connect_stream(&sess, dns); @@ -135,23 +139,9 @@ impl RuntimeProvider for DnsRuntimeProvider { server_addr: SocketAddr, ) -> std::pin::Pin>>> { - let src: SocketAddr = if server_addr.is_ipv4() { - "0.0.0.0:0".parse().unwrap() - } else { - "[::]:0".parse().unwrap() - }; - let provider = self.clone(); let dns = self.dns_resolver.clone(); - let sess = Session { - source: src, - network: Network::Udp, - typ: Type::Dns, - destination: server_addr.into(), - so_mark: self.so_mark, - iface: self.iface.clone(), - ..Default::default() - }; + let sess = self.session_for(server_addr, Network::Udp); Box::pin(async move { let outbound = provider.pick_outbound(&sess).await; @@ -163,6 +153,28 @@ impl RuntimeProvider for DnsRuntimeProvider { } } +#[cfg(test)] +mod tests { + use super::DnsRuntimeProvider; + use crate::session::{Network, SocksAddr, Type}; + + #[test] + fn ipv6_dns_sessions_preserve_target_family_and_socket_mark() { + let provider = DnsRuntimeProvider::new_direct(None, Some(7777)); + let target = "[2606:4700:4700::1111]:853".parse().unwrap(); + + for network in [Network::Tcp, Network::Udp] { + let session = provider.session_for(target, network); + + assert_eq!(session.network, network); + assert_eq!(session.typ, Type::Dns); + assert!(session.source.is_ipv6()); + assert_eq!(session.destination, SocksAddr::Ip(target)); + assert_eq!(session.so_mark, Some(7777)); + } + } +} + // Mutex could be inefficient // But this is for DNS, it doesn't require high perf // SocketAddr indicates the source address of the UDP socket diff --git a/clash-lib/src/app/dns/server/handler.rs b/clash-lib/src/app/dns/server/handler.rs index 5203573f..70f95140 100644 --- a/clash-lib/src/app/dns/server/handler.rs +++ b/clash-lib/src/app/dns/server/handler.rs @@ -6,11 +6,21 @@ use hickory_proto::{ }; use tracing::debug; +fn should_filter_aaaa(req: &Message, ipv6_enabled: bool) -> bool { + !ipv6_enabled + && req.queries.first().map(|query| query.query_type()) + == Some(hickory_proto::rr::RecordType::AAAA) +} + pub async fn exchange_with_resolver<'a>( resolver: &'a ThreadSafeDNSResolver, req: &'a Message, enhanced: bool, ) -> Result { + if should_filter_aaaa(req, resolver.ipv6()) { + return Ok(build_dns_response_message(req, false, false)); + } + if req.queries.first().map(|q| q.query_type()) == Some(hickory_proto::rr::RecordType::AAAA) || !resolver.fake_ip_enabled() @@ -65,3 +75,37 @@ pub async fn exchange_with_resolver<'a>( } } } + +#[cfg(test)] +mod tests { + use hickory_proto::{ + op::{Message, MessageType, OpCode, Query}, + rr::{Name, RecordType}, + }; + + use super::should_filter_aaaa; + + fn query(record_type: RecordType) -> Message { + let mut message = Message::new(0, MessageType::Query, OpCode::Query); + message.add_query(Query::query( + Name::from_ascii("example.com.").unwrap(), + record_type, + )); + message + } + + #[test] + fn filters_aaaa_when_ipv6_is_disabled() { + assert!(should_filter_aaaa(&query(RecordType::AAAA), false)); + } + + #[test] + fn keeps_aaaa_when_ipv6_is_enabled() { + assert!(!should_filter_aaaa(&query(RecordType::AAAA), true)); + } + + #[test] + fn keeps_ipv4_queries_when_ipv6_is_disabled() { + assert!(!should_filter_aaaa(&query(RecordType::A), false)); + } +} diff --git a/clash-lib/src/config/internal/convert/mod.rs b/clash-lib/src/config/internal/convert/mod.rs index 15b3dcdf..ca866b1f 100644 --- a/clash-lib/src/config/internal/convert/mod.rs +++ b/clash-lib/src/config/internal/convert/mod.rs @@ -54,6 +54,7 @@ pub(super) fn convert(mut c: def::Config) -> Result Result Result<(), Error> { + if tun.enable && dns.ipv6 && tun.gateway_v6.is_none() { + return Err(Error::InvalidConfig( + "dns IPv6 responses require tun.ipv6 when TUN is enabled".to_owned(), + )); + } + Ok(()) +} + fn configure_fake_ip_route( dns: &crate::app::dns::Config, tun: &mut config::TunConfig, @@ -197,6 +210,46 @@ tun: assert_eq!(converted.tun.so_mark, Some(6666)); } + #[test] + fn reject_dns_ipv6_with_ipv4_only_tun() { + let mut cfg = parse_config( + r#" +tun: + enable: true + ipv6: false +"#, + ); + cfg.ipv6 = true; + cfg.dns.ipv6 = true; + + let error = match convert(cfg) { + Ok(_) => panic!("IPv4-only TUN must reject DNS IPv6"), + Err(error) => error, + }; + assert!(matches!( + error, + Error::InvalidConfig(message) + if message.contains("dns IPv6 responses require tun.ipv6") + )); + } + + #[test] + fn allow_dns_ipv6_with_ipv6_tun() { + let mut cfg = parse_config( + r#" +tun: + enable: true + ipv6: true +"#, + ); + cfg.ipv6 = true; + cfg.dns.ipv6 = true; + + let converted = convert(cfg).expect("dual-stack TUN should be valid"); + assert!(converted.dns.ipv6); + assert!(converted.tun.gateway_v6.is_some()); + } + #[test] fn keep_tun_so_mark_if_explicitly_set() { let cfg = parse_config( diff --git a/clash-lib/src/lib.rs b/clash-lib/src/lib.rs index 3fb62eea..14228062 100644 --- a/clash-lib/src/lib.rs +++ b/clash-lib/src/lib.rs @@ -297,6 +297,7 @@ pub async fn start( Some(shutdown_token.child_token()), components.dns_listen.clone(), components.dns_enabled, + components.ipv6_allowed, )); // api_listener is not part of components because it requires components to be @@ -379,6 +380,7 @@ pub async fn start( Some(reload_token.child_token()), new_components.dns_listen.clone(), new_components.dns_enabled, + new_components.ipv6_allowed, )); let mut g = global_state.lock().await; @@ -440,6 +442,7 @@ struct RuntimeComponents { inbound_manager: Arc, dns_listen: DNSListenAddr, dns_enabled: bool, + ipv6_allowed: bool, } impl RuntimeComponents { @@ -483,6 +486,7 @@ async fn create_components( cwd: PathBuf, config: InternalConfig, ) -> Result { + let ipv6_allowed = !config.tun.enable || config.tun.gateway_v6.is_some(); #[cfg(feature = "tun")] { if config.tun.enable { @@ -778,6 +782,7 @@ async fn create_components( dns_listener, dns_listen, dns_enabled: dns_enable, + ipv6_allowed, }) } From 5ae5da66de6cba0b851b71d760b60ef7cb28f6bc Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:11:49 +0800 Subject: [PATCH 09/70] feat(health): add configurable streaming probes Support minimum download thresholds, provider-specific checks, SSE validation, and WebSocket echo probes under the extended health feature. --- clash-bin/Cargo.toml | 14 +- clash-lib/Cargo.toml | 12 +- clash-lib/src/app/outbound/manager.rs | 95 +++- .../app/remote_content_manager/healthcheck.rs | 171 +++++- .../src/app/remote_content_manager/mod.rs | 505 +++++++++++++++++- clash-lib/src/config/internal/proxy.rs | 57 +- 6 files changed, 837 insertions(+), 17 deletions(-) diff --git a/clash-bin/Cargo.toml b/clash-bin/Cargo.toml index 456f977d..1eedc315 100644 --- a/clash-bin/Cargo.toml +++ b/clash-bin/Cargo.toml @@ -8,7 +8,18 @@ edition = { workspace = true } [features] default = ["standard", "aws-lc-rs"] -standard = ["shadowsocks", "trojan", "anytls", "ws", "tls", "hysteria", "reality", "port", "tun"] +standard = [ + "shadowsocks", + "trojan", + "anytls", + "ws", + "tls", + "hysteria", + "reality", + "port", + "tun", + "extended-health-check", +] plus = ["standard", "onion"] onion = [] @@ -33,6 +44,7 @@ mixed_port = ["clash-lib/mixed_port"] aws-lc-rs = ["dep:aws-lc-rs", "clash-lib/aws-lc-rs"] ring = ["clash-lib/ring"] tracing = ["clash-lib/tracing"] +extended-health-check = ["clash-lib/extended-health-check"] perf = ["plus"] diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index 61f1b092..92f44876 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -139,12 +139,22 @@ sha1 = "0.11" byteorder = "1" [features] -default = ["zero_copy", "tls", "aws-lc-rs", "tun", "port", "reality"] +default = [ + "zero_copy", + "tls", + "aws-lc-rs", + "tun", + "port", + "reality", + "extended-health-check", +] zero_copy = [] tls = [] +extended-health-check = [] + aws-lc-rs = [ "dep:aws-lc-rs", "rustls/aws-lc-rs", diff --git a/clash-lib/src/app/outbound/manager.rs b/clash-lib/src/app/outbound/manager.rs index c037f940..c3ea3aa1 100644 --- a/clash-lib/src/app/outbound/manager.rs +++ b/clash-lib/src/app/outbound/manager.rs @@ -26,8 +26,8 @@ use crate::{ }, }, config::internal::proxy::{ - OutboundGroupProtocol, OutboundProxyProtocol, OutboundProxyProviderDef, - PROXY_DIRECT, PROXY_GLOBAL, PROXY_REJECT, + HealthCheckProbe, OutboundGroupProtocol, OutboundProxyProtocol, + OutboundProxyProviderDef, PROXY_DIRECT, PROXY_GLOBAL, PROXY_REJECT, }, proxy::{ AnyOutboundHandler, direct, @@ -809,17 +809,94 @@ impl OutboundManager { } } + let health = file.health_check; + if matches!( + health.probe, + HealthCheckProbe::Download | HealthCheckProbe::Sse + ) && !cfg!(feature = "extended-health-check") + { + return Err(Error::InvalidConfig(format!( + "file provider `{name}` uses download health probe, \ + but extended-health-check is disabled" + ))); + } + if health.probe == HealthCheckProbe::Websocket + && !cfg!(all( + feature = "extended-health-check", + feature = "ws" + )) + { + return Err(Error::InvalidConfig(format!( + "file provider `{name}` uses WebSocket health probe, \ + but extended-health-check or ws is disabled" + ))); + } + let minimum_bytes = health.minimum_bytes.unwrap_or(65_536); + if health.probe == HealthCheckProbe::Download + && minimum_bytes == 0 + { + return Err(Error::InvalidConfig(format!( + "file provider `{name}` download health probe requires \ + minimum-bytes greater than zero" + ))); + } + let minimum_events = health.minimum_events.unwrap_or(3); + if health.probe == HealthCheckProbe::Sse && minimum_events == 0 { + return Err(Error::InvalidConfig(format!( + "file provider `{name}` SSE health probe requires \ + minimum-events greater than zero" + ))); + } + let maximum_first_byte = Duration::from_millis( + health.maximum_first_byte_ms.unwrap_or(3_000), + ); + if health.probe == HealthCheckProbe::Sse + && maximum_first_byte.is_zero() + { + return Err(Error::InvalidConfig(format!( + "file provider `{name}` SSE health probe requires \ + maximum-first-byte-ms greater than zero" + ))); + } + let expected_echo = health + .expect_echo + .unwrap_or_else(|| "chimera-health".to_owned()); + if health.probe == HealthCheckProbe::Websocket + && expected_echo.is_empty() + { + return Err(Error::InvalidConfig(format!( + "file provider `{name}` WebSocket health probe requires \ + non-empty expect-echo" + ))); + } + let interval = if health.enable == Some(false) { + 0 + } else { + health.interval.or(file.interval).unwrap_or_default() + }; + let health_check = HealthCheck::new( + proxies.clone(), + health + .url + .unwrap_or_else(|| DEFAULT_LATENCY_TEST_URL.to_owned()), + interval, + health.lazy.unwrap_or(true), + self.proxy_manager.clone(), + ) + .with_probe( + health.probe, + minimum_bytes, + minimum_events, + maximum_first_byte, + expected_echo, + health.timeout.map(Duration::from_secs), + ); + let provider = Arc::new(RwLock::new( PlainProvider::new( name.clone(), proxies.clone(), - HealthCheck::new( - proxies, - DEFAULT_LATENCY_TEST_URL.to_owned(), - file.interval.unwrap_or_default(), - true, - self.proxy_manager.clone(), - ), + health_check, ) .map_err(|x| { Error::InvalidConfig(format!( diff --git a/clash-lib/src/app/remote_content_manager/healthcheck.rs b/clash-lib/src/app/remote_content_manager/healthcheck.rs index 6df9a3d6..36db6d62 100644 --- a/clash-lib/src/app/remote_content_manager/healthcheck.rs +++ b/clash-lib/src/app/remote_content_manager/healthcheck.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use tokio::time::Instant; use tracing::debug; +use crate::config::internal::proxy::HealthCheckProbe; use crate::proxy::AnyOutboundHandler; use super::ProxyManager; @@ -17,6 +18,12 @@ pub struct HealthCheck { url: String, interval: u64, lazy: bool, + probe: HealthCheckProbe, + minimum_bytes: usize, + minimum_events: usize, + maximum_first_byte: std::time::Duration, + expected_echo: String, + timeout: Option, proxy_manager: ProxyManager, inner: Arc>, } @@ -33,6 +40,12 @@ impl HealthCheck { url, interval, lazy, + probe: HealthCheckProbe::Http, + minimum_bytes: 0, + minimum_events: 0, + maximum_first_byte: std::time::Duration::ZERO, + expected_echo: String::new(), + timeout: None, proxy_manager, inner: Arc::new(tokio::sync::RwLock::new(HealCheckInner { last_check: tokio::time::Instant::now(), @@ -42,23 +55,154 @@ impl HealthCheck { } } + pub fn with_probe( + mut self, + probe: HealthCheckProbe, + minimum_bytes: usize, + minimum_events: usize, + maximum_first_byte: std::time::Duration, + expected_echo: String, + timeout: Option, + ) -> Self { + self.probe = probe; + self.minimum_bytes = minimum_bytes; + self.minimum_events = minimum_events; + self.maximum_first_byte = maximum_first_byte; + self.expected_echo = expected_echo; + self.timeout = timeout; + self + } + + async fn run_check( + proxy_manager: &ProxyManager, + proxies: &Vec, + url: &str, + probe: HealthCheckProbe, + minimum_bytes: usize, + minimum_events: usize, + maximum_first_byte: std::time::Duration, + expected_echo: &str, + timeout: Option, + ) { + #[cfg(not(feature = "extended-health-check"))] + let _ = ( + minimum_bytes, + minimum_events, + maximum_first_byte, + expected_echo, + ); + #[cfg(all(feature = "extended-health-check", not(feature = "ws")))] + let _ = expected_echo; + + match probe { + HealthCheckProbe::Http => { + proxy_manager.check(proxies, url, timeout).await; + } + #[cfg(feature = "extended-health-check")] + HealthCheckProbe::Download => { + proxy_manager + .check_download(proxies, url, timeout, minimum_bytes) + .await; + } + #[cfg(feature = "extended-health-check")] + HealthCheckProbe::Sse => { + for proxy in proxies { + if let Err(error) = proxy_manager + .sse_test( + proxy.clone(), + url, + timeout, + minimum_events, + maximum_first_byte, + ) + .await + { + tracing::warn!( + "SSE healthcheck {} -> {} failed: {}", + proxy.name(), + url, + error + ); + } + } + } + #[cfg(not(feature = "extended-health-check"))] + HealthCheckProbe::Sse => { + tracing::warn!( + "SSE health probe requires extended-health-check feature" + ); + } + #[cfg(all(feature = "extended-health-check", feature = "ws"))] + HealthCheckProbe::Websocket => { + for proxy in proxies { + if let Err(error) = proxy_manager + .websocket_test(proxy.clone(), url, timeout, expected_echo) + .await + { + tracing::warn!( + "WebSocket healthcheck {} -> {} failed: {}", + proxy.name(), + url, + error + ); + } + } + } + #[cfg(not(all(feature = "extended-health-check", feature = "ws")))] + HealthCheckProbe::Websocket => { + tracing::warn!( + "WebSocket health probe requires extended-health-check and ws features" + ); + } + #[cfg(not(feature = "extended-health-check"))] + HealthCheckProbe::Download => { + tracing::warn!( + "download health probe requires extended-health-check feature" + ); + } + } + } + pub async fn kick_off(&self) { let proxy_manager = self.proxy_manager.clone(); let interval = self.interval; let lazy = self.lazy; + let probe = self.probe; + let minimum_bytes = self.minimum_bytes; + let timeout = self.timeout; + let minimum_events = self.minimum_events; + let maximum_first_byte = self.maximum_first_byte; + let expected_echo = self.expected_echo.clone(); let proxies = self.inner.read().await.proxies.clone(); { let url = self.url.clone(); let proxies = proxies.clone(); tokio::spawn(async move { - proxy_manager.check(&proxies, &url, None).await; + Self::run_check( + &proxy_manager, + &proxies, + &url, + probe, + minimum_bytes, + minimum_events, + maximum_first_byte, + &expected_echo, + timeout, + ) + .await; }); } let inner = self.inner.clone(); let proxy_manager = self.proxy_manager.clone(); let url = self.url.clone(); + let probe = self.probe; + let minimum_bytes = self.minimum_bytes; + let timeout = self.timeout; + let minimum_events = self.minimum_events; + let maximum_first_byte = self.maximum_first_byte; + let expected_echo = self.expected_echo.clone(); let task_handle = tokio::spawn(async move { let mut ticker = tokio::time::interval(tokio::time::Duration::from_secs(interval)); @@ -69,7 +213,17 @@ impl HealthCheck { let now = tokio::time::Instant::now(); let last_check = inner.read().await.last_check; if !lazy || now.duration_since(last_check).as_secs() >= interval { - proxy_manager.check(&proxies, &url, None).await; + Self::run_check( + &proxy_manager, + &proxies, + &url, + probe, + minimum_bytes, + minimum_events, + maximum_first_byte, + &expected_echo, + timeout, + ).await; let mut w = inner.write().await; w.last_check = now; } @@ -87,7 +241,18 @@ impl HealthCheck { pub async fn check(&self) { let proxies = self.inner.read().await.proxies.clone(); - self.proxy_manager.check(&proxies, &self.url, None).await; + Self::run_check( + &self.proxy_manager, + &proxies, + &self.url, + self.probe, + self.minimum_bytes, + self.minimum_events, + self.maximum_first_byte, + &self.expected_echo, + self.timeout, + ) + .await; } // pub async fn update(&self, proxies: Vec) { diff --git a/clash-lib/src/app/remote_content_manager/mod.rs b/clash-lib/src/app/remote_content_manager/mod.rs index 65c0b14b..22b7f81f 100644 --- a/clash-lib/src/app/remote_content_manager/mod.rs +++ b/clash-lib/src/app/remote_content_manager/mod.rs @@ -9,7 +9,11 @@ use std::{ use bytes::Bytes; use chrono::{DateTime, Utc}; +#[cfg(all(feature = "extended-health-check", feature = "ws"))] +use futures::SinkExt; use futures::{FutureExt, StreamExt, stream::FuturesOrdered}; +#[cfg(feature = "extended-health-check")] +use http_body_util::BodyExt; use http_body_util::Empty; use hyper::Request; use hyper_util::rt::TokioIo; @@ -17,6 +21,11 @@ use serde::Serialize; use tokio::sync::RwLock; use tracing::warn; +#[cfg(all(feature = "extended-health-check", feature = "ws"))] +use tokio::io::{AsyncRead, AsyncWrite}; +#[cfg(all(feature = "extended-health-check", feature = "ws"))] +use tokio_tungstenite::{client_async, tungstenite::Message}; + #[cfg(feature = "tun")] use crate::app::net::DEFAULT_OUTBOUND_INTERFACE; #[cfg(feature = "tls")] @@ -170,12 +179,221 @@ impl ProxyManager { results } + #[cfg(feature = "extended-health-check")] + pub async fn check_download( + &self, + outbounds: &Vec, + url: &str, + timeout: Option, + minimum_bytes: usize, + ) -> Vec> { + let mut tasks = Vec::new(); + for outbound in outbounds { + let outbound = outbound.clone(); + let url = url.to_owned(); + let manager = self.clone(); + tasks.push(tokio::spawn(async move { + let proxy_name = outbound.name().to_owned(); + manager + .download_test(outbound, &url, timeout, minimum_bytes) + .await + .inspect_err(|error| { + warn!( + "download healthcheck {} -> {} failed: {}", + proxy_name, url, error + ); + }) + })); + } + + let joined: Vec<_> = tasks + .into_iter() + .collect::>() + .collect() + .await; + joined + .into_iter() + .map(|result| { + result.unwrap_or_else(|error| { + Err(std::io::Error::other(error.to_string())) + }) + }) + .collect() + } + pub async fn url_test( &self, outbound: AnyOutboundHandler, url: &str, timeout: Option, ) -> std::io::Result<(Duration, Duration)> { + self.url_test_inner(outbound, url, timeout, None, None) + .await + } + + #[cfg(feature = "extended-health-check")] + pub async fn download_test( + &self, + outbound: AnyOutboundHandler, + url: &str, + timeout: Option, + minimum_bytes: usize, + ) -> std::io::Result<(Duration, Duration)> { + self.url_test_inner(outbound, url, timeout, Some(minimum_bytes), None) + .await + } + + #[cfg(feature = "extended-health-check")] + pub async fn sse_test( + &self, + outbound: AnyOutboundHandler, + url: &str, + timeout: Option, + minimum_events: usize, + maximum_first_byte: Duration, + ) -> std::io::Result<(Duration, Duration)> { + self.url_test_inner( + outbound, + url, + timeout, + None, + Some((minimum_events, maximum_first_byte)), + ) + .await + } + + #[cfg(all(feature = "extended-health-check", feature = "ws"))] + pub async fn websocket_test( + &self, + outbound: AnyOutboundHandler, + url: &str, + timeout: Option, + expected_echo: &str, + ) -> std::io::Result<(Duration, Duration)> { + let name = outbound.name().to_owned(); + let timeout = timeout.unwrap_or(Duration::from_secs(5)); + let uri: http::Uri = url.parse().map_err(std::io::Error::other)?; + let host = uri + .host() + .ok_or_else(|| std::io::Error::other("url has no host"))? + .to_owned(); + let port = uri.port_u16().unwrap_or_else(|| { + if uri.scheme_str() == Some("wss") { + 443 + } else { + 80 + } + }); + + #[cfg(feature = "tun")] + let iface = DEFAULT_OUTBOUND_INTERFACE.read().await.clone(); + #[cfg(not(feature = "tun"))] + let iface = None; + let sess = Session { + network: Network::Tcp, + typ: Type::Tunnel, + destination: SocksAddr::Domain(host.clone(), port), + so_mark: self.fw_mark, + iface, + ..Default::default() + }; + + let connect_started = tokio::time::Instant::now(); + let stream = tokio::time::timeout( + timeout, + outbound.connect_stream(&sess, self.dns_resolver.clone()), + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "websocket healthcheck connect timeout", + ) + })??; + let connect_delay = connect_started.elapsed(); + let probe_started = tokio::time::Instant::now(); + + let result = match uri.scheme_str() { + Some("ws") => tokio::time::timeout( + timeout, + websocket_echo(stream, url, expected_echo), + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "websocket healthcheck echo timeout", + ) + })?, + #[cfg(feature = "tls")] + Some("wss") => { + let tls_config = rustls::ClientConfig::builder() + .with_root_certificates(GLOBAL_ROOT_STORE.clone()) + .with_no_client_auth(); + let connector = + tokio_rustls::TlsConnector::from(Arc::new(tls_config)); + let tls_stream = tokio::time::timeout( + timeout, + connector.connect( + host.try_into().map_err(|_| { + std::io::Error::other("invalid WebSocket SNI host") + })?, + stream, + ), + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "websocket healthcheck TLS timeout", + ) + })? + .map_err(std::io::Error::other)?; + tokio::time::timeout( + timeout, + websocket_echo(tls_stream, url, expected_echo), + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "websocket healthcheck echo timeout", + ) + })? + } + #[cfg(not(feature = "tls"))] + Some("wss") => Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "wss healthcheck requires tls feature", + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "websocket healthcheck requires ws or wss URL", + )), + }; + + let probe_delay = probe_started.elapsed(); + let ok = result.is_ok(); + self.report_delay( + &name, + ok, + Some(if ok { probe_delay } else { Duration::default() }), + ) + .await; + result.map(|_| (probe_delay, connect_delay + probe_delay)) + } + + async fn url_test_inner( + &self, + outbound: AnyOutboundHandler, + url: &str, + timeout: Option, + minimum_bytes: Option, + sse: Option<(usize, Duration)>, + ) -> std::io::Result<(Duration, Duration)> { + #[cfg(not(feature = "extended-health-check"))] + let _ = (minimum_bytes, sse); + let name = outbound.name().to_owned(); let timeout = timeout.unwrap_or(Duration::from_secs(5)); #[cfg(feature = "tun")] @@ -304,9 +522,95 @@ impl ProxyManager { "unsupported scheme", )), }; + let probe_result = match request_result { + Ok(response) if !response.status().is_success() => { + Err(std::io::Error::other(format!( + "healthcheck returned HTTP {}", + response.status() + ))) + } + #[cfg(feature = "extended-health-check")] + Ok(mut response) if sse.is_some() => { + let (minimum_events, maximum_first_byte) = sse.unwrap(); + tokio::time::timeout(timeout, async { + let first = tokio::time::timeout( + maximum_first_byte, + response.body_mut().frame(), + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "healthcheck SSE first byte timeout", + ) + })?; + + let mut buffer = Vec::new(); + let mut events = 0usize; + let mut frame = first; + loop { + match frame { + Some(frame) => { + let frame = frame.map_err(std::io::Error::other)?; + if let Some(data) = frame.data_ref() { + buffer.extend_from_slice(data); + events += drain_sse_events(&mut buffer); + if events >= minimum_events { + return Ok(()); + } + } + } + None => { + return Err(std::io::Error::other(format!( + "healthcheck SSE ended after {events} events; \ + expected at least {minimum_events}" + ))); + } + } + frame = response.body_mut().frame().await; + } + }) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "healthcheck SSE timeout", + ) + })? + } + #[cfg(feature = "extended-health-check")] + Ok(mut response) if minimum_bytes.is_some() => { + let minimum_bytes = minimum_bytes.unwrap_or_default(); + tokio::time::timeout(timeout, async { + let mut received = 0usize; + while let Some(frame) = response.body_mut().frame().await { + let frame = frame.map_err(std::io::Error::other)?; + if let Some(data) = frame.data_ref() { + received = received.saturating_add(data.len()); + if received >= minimum_bytes { + return Ok(()); + } + } + } + Err(std::io::Error::other(format!( + "healthcheck download ended after {received} bytes; \ + expected at least {minimum_bytes}" + ))) + }) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "healthcheck download timeout", + ) + })? + } + Ok(_) => Ok(()), + Err(error) => Err(error), + }; let request_delay = request_started.elapsed(); - let ok = request_result.is_ok(); + let ok = probe_result.is_ok(); self.report_alive(&name, ok).await; self.report_delay( &name, @@ -319,7 +623,7 @@ impl ProxyManager { ) .await; - request_result.map(|_| { + probe_result.map(|_| { ( request_delay, connect_delay + tls_handshake_delay + request_delay, @@ -335,3 +639,200 @@ impl ProxyManager { // self.fw_mark // } } + +#[cfg(feature = "extended-health-check")] +fn drain_sse_events(buffer: &mut Vec) -> usize { + let mut count = 0; + loop { + let lf = buffer + .windows(2) + .position(|window| window == b"\n\n") + .map(|position| (position, 2)); + let crlf = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| (position, 4)); + let delimiter = match (lf, crlf) { + (Some(left), Some(right)) => { + Some(if left.0 < right.0 { left } else { right }) + } + (left, right) => left.or(right), + }; + let Some((position, length)) = delimiter else { + break; + }; + buffer.drain(..position + length); + count += 1; + } + count +} + +#[cfg(all(feature = "extended-health-check", feature = "ws"))] +async fn websocket_echo( + stream: S, + url: &str, + expected_echo: &str, +) -> std::io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let (mut websocket, response) = client_async(url, stream) + .await + .map_err(std::io::Error::other)?; + if response.status() != http::StatusCode::SWITCHING_PROTOCOLS { + return Err(std::io::Error::other(format!( + "websocket healthcheck returned HTTP {}", + response.status() + ))); + } + + websocket + .send(Message::Text(expected_echo.to_owned().into())) + .await + .map_err(std::io::Error::other)?; + while let Some(message) = websocket.next().await { + match message.map_err(std::io::Error::other)? { + Message::Text(text) if text.as_str() == expected_echo => return Ok(()), + Message::Binary(data) if data.as_ref() == expected_echo.as_bytes() => { + return Ok(()); + } + Message::Close(_) => { + return Err(std::io::Error::other( + "websocket healthcheck closed before echo", + )); + } + _ => {} + } + } + Err(std::io::Error::other( + "websocket healthcheck ended before echo", + )) +} + +#[cfg(all(test, feature = "extended-health-check"))] +mod tests { + use std::{sync::Arc, time::Duration}; + + use httpmock::{Method::GET, MockServer}; + + use super::ProxyManager; + use crate::{ + app::dns::SystemResolver, + config::internal::proxy::PROXY_DIRECT, + proxy::{AnyOutboundHandler, direct}, + }; + + fn direct_manager() -> (ProxyManager, AnyOutboundHandler) { + let resolver = Arc::new(SystemResolver::new(false).unwrap()); + let manager = ProxyManager::new(resolver, None); + let outbound = Arc::new(direct::Handler::new(PROXY_DIRECT)); + (manager, outbound) + } + + #[tokio::test] + async fn download_probe_requires_minimum_response_bytes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/download"); + then.status(200).body(vec![b'x'; 64]); + }); + let (manager, outbound) = direct_manager(); + + manager + .download_test( + outbound, + &server.url("/download"), + Some(Duration::from_secs(2)), + 64, + ) + .await + .unwrap(); + + assert!(manager.alive(PROXY_DIRECT).await); + } + + #[tokio::test] + async fn short_download_marks_proxy_unhealthy() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/short"); + then.status(200).body(vec![b'x'; 16]); + }); + let (manager, outbound) = direct_manager(); + + let error = manager + .download_test( + outbound, + &server.url("/short"), + Some(Duration::from_secs(2)), + 64, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("expected at least 64")); + assert!(!manager.alive(PROXY_DIRECT).await); + } + + #[tokio::test] + async fn sse_probe_requires_complete_events() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/events"); + then.status(200).body("data: first\n\ndata: second\n\n"); + }); + let (manager, outbound) = direct_manager(); + + manager + .sse_test( + outbound, + &server.url("/events"), + Some(Duration::from_secs(2)), + 2, + Duration::from_secs(1), + ) + .await + .unwrap(); + + assert!(manager.alive(PROXY_DIRECT).await); + } + + #[test] + fn parses_lf_and_crlf_sse_event_boundaries() { + let mut buffer = b"data: one\r\n\r\ndata: two\n\npartial".to_vec(); + assert_eq!(super::drain_sse_events(&mut buffer), 2); + assert_eq!(buffer, b"partial"); + } + + #[cfg(feature = "ws")] + #[tokio::test] + async fn websocket_probe_requires_matching_echo() { + use futures::{SinkExt, StreamExt}; + use tokio::net::TcpListener; + use tokio_tungstenite::accept_async; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + if let Some(message) = websocket.next().await { + websocket.send(message.unwrap()).await.unwrap(); + } + }); + let (manager, outbound) = direct_manager(); + + manager + .websocket_test( + outbound, + &format!("ws://{address}/echo"), + Some(Duration::from_secs(2)), + "chimera-health", + ) + .await + .unwrap(); + + server.await.unwrap(); + assert!(manager.alive(PROXY_DIRECT).await); + } +} diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index 648fc472..5ca5bba0 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -434,6 +434,25 @@ pub struct HealthCheck { pub url: Option, pub interval: Option, pub lazy: Option, + #[serde(rename = "type", default)] + pub probe: HealthCheckProbe, + pub minimum_bytes: Option, + pub minimum_events: Option, + pub maximum_first_byte_ms: Option, + pub expect_echo: Option, + pub timeout: Option, +} + +#[derive( + serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, +)] +#[serde(rename_all = "kebab-case")] +pub enum HealthCheckProbe { + #[default] + Http, + Download, + Sse, + Websocket, } #[cfg(feature = "trojan")] @@ -496,7 +515,43 @@ pub enum Hysteria2Obfs { #[cfg(test)] mod tests { - use super::OutboundProxyProtocol; + use super::{HealthCheck, HealthCheckProbe, OutboundProxyProtocol}; + + #[test] + fn download_health_check_config_parses() { + let health: HealthCheck = serde_yaml::from_str( + r#" +enable: true +url: https://probe.example/64k.bin +interval: 300 +timeout: 10 +type: download +minimum-bytes: 65536 +"#, + ) + .expect("health check config should parse"); + + assert_eq!(health.probe, HealthCheckProbe::Download); + assert_eq!(health.minimum_bytes, Some(65_536)); + assert_eq!(health.timeout, Some(10)); + } + + #[test] + fn streaming_health_check_configs_parse() { + let sse: HealthCheck = serde_yaml::from_str( + "type: sse\nminimum-events: 3\nmaximum-first-byte-ms: 3000", + ) + .unwrap(); + assert_eq!(sse.probe, HealthCheckProbe::Sse); + assert_eq!(sse.minimum_events, Some(3)); + assert_eq!(sse.maximum_first_byte_ms, Some(3000)); + + let websocket: HealthCheck = + serde_yaml::from_str("type: websocket\nexpect-echo: chimera-health") + .unwrap(); + assert_eq!(websocket.probe, HealthCheckProbe::Websocket); + assert_eq!(websocket.expect_echo.as_deref(), Some("chimera-health")); + } #[test] fn outbound_vless_parses_xhttp_opts() { From e77c87f3b8dcd9148a0f86b9e62f03879a1fcd14 Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:12:08 +0800 Subject: [PATCH 10/70] fix(net): handle transient interface state Use rtnetlink to inspect interface readiness while tolerating short carrier transitions during startup. --- clash-lib/src/app/net/mod.rs | 203 ++++++++++++++++++++++++++--------- nix/module.nix | 2 + 2 files changed, 152 insertions(+), 53 deletions(-) diff --git a/clash-lib/src/app/net/mod.rs b/clash-lib/src/app/net/mod.rs index 3d6de7c1..3665bbef 100644 --- a/clash-lib/src/app/net/mod.rs +++ b/clash-lib/src/app/net/mod.rs @@ -301,7 +301,7 @@ pub async fn init_net_config( interface: Option<&Interface>, ) -> Result<()> { *DEFAULT_OUTBOUND_INTERFACE.write().await = - resolve_outbound_interface(interface)?; + resolve_outbound_interface(interface).await?; *TUN_SOMARK.write().await = tun_somark; trace!( "default outbound interface: {:?}, tun somark: {:?}", @@ -458,9 +458,29 @@ pub fn get_interface_by_ip(ip: IpAddr) -> Option { } #[cfg(all(feature = "tun", target_os = "linux"))] -fn validate_linux_interface_flags( +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinuxOperState { + Unknown, + Down, + Up, + Other, +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct LinuxInterfaceState { + index: u32, + name: String, + admin_up: bool, + lower_up: Option, + oper_state: Option, + carrier: Option, +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +fn validate_linux_interface_state( interface: &OutboundInterface, - flags: u32, + state: &LinuxInterfaceState, ) -> Result<()> { let families = match (interface.addr_v4.is_some(), interface.addr_v6.is_some()) { (true, true) => "IPv4/IPv6", @@ -469,53 +489,101 @@ fn validate_linux_interface_flags( (false, false) => "none", }; - for (flag, state) in [ - (libc::IFF_UP as u32, "UP"), - (libc::IFF_RUNNING as u32, "RUNNING"), - ] { - if flags & flag == 0 { - return Err(Error::InvalidConfig(format!( - "configured outbound interface \"{}\" is not {state}: \ - index={}, family={families}", - interface.name, interface.index - ))); - } + if !state.admin_up { + return Err(Error::InvalidConfig(format!( + "configured outbound interface \"{}\" is not administratively UP: \ + index={}, family={families}, admin_up={}, lower_up={:?}, \ + oper_state={:?}, carrier={:?}; enable the interface or choose \ + another interface-name", + state.name, + state.index, + state.admin_up, + state.lower_up, + state.oper_state, + state.carrier, + ))); } Ok(()) } #[cfg(all(feature = "tun", target_os = "linux"))] -fn validate_configured_interface_state(interface: &OutboundInterface) -> Result<()> { - let flags_path = format!("/sys/class/net/{}/flags", interface.name); - let raw_flags = std::fs::read_to_string(&flags_path).map_err(|error| { +async fn inspect_linux_interface_state( + interface: &OutboundInterface, +) -> Result { + use futures::TryStreamExt; + use rtnetlink::packet_route::link::{LinkAttribute, LinkFlags, State}; + + let handle = route_netlink_handle().await.map_err(|error| { Error::InvalidConfig(format!( - "failed to inspect configured outbound interface \"{}\": \ - index={}, error={error}", + "failed to inspect configured outbound interface \"{}\" via \ + rtnetlink: index={}, error={error}", interface.name, interface.index )) })?; - let flags = u32::from_str_radix(raw_flags.trim().trim_start_matches("0x"), 16) + let mut links = handle.link().get().match_index(interface.index).execute(); + let message = links + .try_next() + .await .map_err(|error| { - Error::InvalidConfig(format!( - "failed to parse state for configured outbound interface \"{}\": \ - index={}, error={error}", - interface.name, interface.index - )) - })?; + Error::InvalidConfig(format!( + "failed to inspect configured outbound interface \"{}\" via \ + rtnetlink: index={}, error={error}", + interface.name, interface.index + )) + })? + .ok_or_else(|| { + Error::InvalidConfig(format!( + "configured outbound interface \"{}\" disappeared during \ + rtnetlink inspection: index={}", + interface.name, interface.index + )) + })?; + + let mut oper_state = None; + let mut carrier = None; + for attribute in &message.attributes { + match attribute { + LinkAttribute::OperState(value) => { + oper_state = Some(match value { + State::Unknown => LinuxOperState::Unknown, + State::Down | State::LowerLayerDown => LinuxOperState::Down, + State::Up => LinuxOperState::Up, + _ => LinuxOperState::Other, + }); + } + LinkAttribute::Carrier(value) => carrier = Some(*value != 0), + _ => {} + } + } - validate_linux_interface_flags(interface, flags) + Ok(LinuxInterfaceState { + index: message.header.index, + name: interface.name.clone(), + admin_up: message.header.flags.contains(LinkFlags::Up), + lower_up: Some(message.header.flags.contains(LinkFlags::LowerUp)), + oper_state, + carrier, + }) +} + +#[cfg(all(feature = "tun", target_os = "linux"))] +async fn validate_configured_interface_state( + interface: &OutboundInterface, +) -> Result<()> { + let state = inspect_linux_interface_state(interface).await?; + validate_linux_interface_state(interface, &state) } #[cfg(all(feature = "tun", not(target_os = "linux")))] -fn validate_configured_interface_state( +async fn validate_configured_interface_state( _interface: &OutboundInterface, ) -> Result<()> { Ok(()) } #[cfg(feature = "tun")] -pub fn resolve_outbound_interface( +pub async fn resolve_outbound_interface( interface: Option<&Interface>, ) -> Result> { let Some(interface) = interface else { @@ -539,7 +607,7 @@ pub fn resolve_outbound_interface( ))); } - validate_configured_interface_state(&configured)?; + validate_configured_interface_state(&configured).await?; Ok(Some(configured)) } @@ -678,11 +746,13 @@ mod tests { } } - #[test] - fn configured_interface_does_not_fall_back_when_missing() { + #[tokio::test] + async fn configured_interface_does_not_fall_back_when_missing() { let interface = Interface::Name("__chimera_missing_interface__".to_owned()); - let error = resolve_outbound_interface(Some(&interface)).unwrap_err(); + let error = resolve_outbound_interface(Some(&interface)) + .await + .unwrap_err(); assert!(matches!( error, @@ -693,39 +763,66 @@ mod tests { )); } - #[test] - fn unconfigured_interface_does_not_guess_an_outbound() { - assert!(resolve_outbound_interface(None).unwrap().is_none()); + #[tokio::test] + async fn unconfigured_interface_does_not_guess_an_outbound() { + assert!(resolve_outbound_interface(None).await.unwrap().is_none()); } #[cfg(target_os = "linux")] #[test] - fn configured_interface_must_be_up_and_running() { + fn netlink_interface_state_requires_admin_up() { let interface = test_interface(); - - let down = super::validate_linux_interface_flags(&interface, 0).unwrap_err(); + let state = super::LinuxInterfaceState { + index: 42, + name: "test0".to_owned(), + admin_up: false, + lower_up: Some(true), + oper_state: Some(super::LinuxOperState::Up), + carrier: Some(true), + }; + let down = + super::validate_linux_interface_state(&interface, &state).unwrap_err(); assert!(matches!( down, Error::InvalidConfig(message) - if message.contains("is not UP") + if message.contains("is not administratively UP") && message.contains("index=42") && message.contains("family=IPv4") )); + } - let not_running = - super::validate_linux_interface_flags(&interface, libc::IFF_UP as u32) - .unwrap_err(); - assert!(matches!( - not_running, - Error::InvalidConfig(message) - if message.contains("is not RUNNING") - )); + #[cfg(target_os = "linux")] + #[test] + fn sysfs_0x1003_does_not_override_healthy_netlink_state() { + let interface = test_interface(); + let sysfs_flags = 0x1003_u32; + assert_eq!(sysfs_flags & libc::IFF_RUNNING as u32, 0); - super::validate_linux_interface_flags( - &interface, - (libc::IFF_UP | libc::IFF_RUNNING) as u32, - ) - .unwrap(); + let state = super::LinuxInterfaceState { + index: 42, + name: "test0".to_owned(), + admin_up: true, + lower_up: Some(true), + oper_state: Some(super::LinuxOperState::Up), + carrier: Some(true), + }; + super::validate_linux_interface_state(&interface, &state).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn unknown_operstate_and_carrier_do_not_mean_down() { + let interface = test_interface(); + let state = super::LinuxInterfaceState { + index: 42, + name: "test0".to_owned(), + admin_up: true, + lower_up: None, + oper_state: Some(super::LinuxOperState::Unknown), + carrier: None, + }; + + super::validate_linux_interface_state(&interface, &state).unwrap(); } #[cfg(target_os = "linux")] diff --git a/nix/module.nix b/nix/module.nix index ee8fb013..f6b3c4e7 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -98,6 +98,8 @@ in systemd.services.chimera-client = { description = "Chimera Client rule-based proxy service"; documentation = [ "https://github.com/mfsga/Chimera_Client" ]; + startLimitIntervalSec = 60; + startLimitBurst = 5; wantedBy = [ "multi-user.target" ]; wants = [ "network-online.target" ]; after = [ From 928903edbbde08977371d3a893e0a1eee1de7567 Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:12:24 +0800 Subject: [PATCH 11/70] fix(nix): improve development and TUN startup Speed up direnv activation and wait for TUN route readiness before declaring the service available. --- .envrc | 2 +- nix/module.nix | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.envrc b/.envrc index bc0f4a9b..3550a30f 100644 --- a/.envrc +++ b/.envrc @@ -1 +1 @@ -use flake path:. +use flake diff --git a/nix/module.nix b/nix/module.nix index f6b3c4e7..53d85c75 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -33,6 +33,21 @@ let args: lib.replaceStrings [ "%%d" ] [ "%d" ] (utils.escapeSystemdExecArgs args); + + tunReadinessCheck = pkgs.writeShellScript "chimera-client-tun-ready" '' + attempt=0 + while [ "$attempt" -lt 300 ]; do + if ${pkgs.iproute2}/bin/ip -4 route show table 2468 \ + | ${pkgs.gnugrep}/bin/grep -q '^default dev '; then + exit 0 + fi + attempt=$((attempt + 1)) + ${pkgs.coreutils}/bin/sleep 0.1 + done + + echo "Chimera Client did not install its TUN route within 30 seconds" >&2 + exit 1 + ''; in { options.services.chimera-client = { @@ -118,6 +133,7 @@ in escapeExecArgs (commonArgs ++ [ "--test-config" ]) ); ExecStart = escapeExecArgs (commonArgs ++ cfg.extraArgs); + ExecStartPost = lib.optional cfg.tun.enable tunReadinessCheck; Restart = "on-failure"; RestartSec = "3s"; From e399b077fa4ad0fe391a524b55086263afe57bac Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:12:42 +0800 Subject: [PATCH 12/70] fix(vless): preserve streaming state across polls Retain handshake progress and Vision passthrough state across fragmented asynchronous reads. --- clash-lib/src/proxy/vless/stream.rs | 271 ++++++++++++++++------ clash-lib/src/proxy/vless/vision_unpad.rs | 32 +++ 2 files changed, 238 insertions(+), 65 deletions(-) diff --git a/clash-lib/src/proxy/vless/stream.rs b/clash-lib/src/proxy/vless/stream.rs index 97c6c7af..8b7130a6 100644 --- a/clash-lib/src/proxy/vless/stream.rs +++ b/clash-lib/src/proxy/vless/stream.rs @@ -15,11 +15,21 @@ const VLESS_COMMAND_TCP: u8 = 1; const VLESS_COMMAND_UDP: u8 = 2; const XTLS_VISION_FLOW: &str = "xtls-rprx-vision"; +struct PendingWrite { + data: BytesMut, + written: usize, + user_len: usize, +} + pub struct VlessStream { inner: AnyStream, handshake_done: bool, handshake_sent: bool, response_received: bool, + pending_write: Option, + response_header: [u8; 2], + response_header_read: usize, + response_additional_remaining: Option, uuid: uuid::Uuid, destination: SocksAddr, is_udp: bool, @@ -45,6 +55,10 @@ impl VlessStream { handshake_done: false, handshake_sent: false, response_received: false, + pending_write: None, + response_header: [0; 2], + response_header_read: 0, + response_additional_remaining: None, uuid, destination: destination.clone(), is_udp, @@ -82,74 +96,141 @@ impl VlessStream { } } - async fn send_handshake_with_data(&mut self, data: &[u8]) -> io::Result { - if self.handshake_sent { - return Ok(0); - } - + fn prepare_handshake_with_data(&mut self, data: &[u8]) { debug!( "VLESS handshake starting for destination: {}", self.destination ); - let mut buf = self.build_handshake_header(); - buf.put_slice(data); + let mut out = self.build_handshake_header(); + out.put_slice(data); + self.pending_write = Some(PendingWrite { + data: out, + written: 0, + user_len: data.len(), + }); + } - // Send handshake + first data - tokio::io::AsyncWriteExt::write_all(&mut self.inner, &buf) - .await - .map_err(|e| { - error!("Failed to send VLESS handshake: {}", e); - e - })?; + fn poll_send_pending_handshake( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + let Some(mut pending) = self.pending_write.take() else { + return Poll::Ready(Ok(0)); + }; + + while pending.written < pending.data.len() { + match Pin::new(&mut self.inner) + .poll_write(cx, &pending.data[pending.written..]) + { + Poll::Ready(Ok(0)) => { + self.pending_write = Some(pending); + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write VLESS handshake", + ))); + } + Poll::Ready(Ok(n)) => pending.written += n, + Poll::Ready(Err(e)) => { + self.pending_write = Some(pending); + error!("Failed to send VLESS handshake: {}", e); + return Poll::Ready(Err(e)); + } + Poll::Pending => { + self.pending_write = Some(pending); + return Poll::Pending; + } + } + } self.handshake_sent = true; - debug!("VLESS handshake sent with {} bytes of data", data.len()); - - Ok(data.len()) + debug!( + "VLESS handshake sent with {} bytes of data", + pending.user_len + ); + Poll::Ready(Ok(pending.user_len)) } - async fn receive_response(&mut self) -> io::Result<()> { + fn poll_receive_response( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { if self.response_received { - return Ok(()); + return Poll::Ready(Ok(())); } debug!("VLESS waiting for response"); - // Read response (VLESS response is just version + additional info length + - // additional info) - let mut response = [0u8; 2]; - tokio::io::AsyncReadExt::read_exact(&mut self.inner, &mut response) - .await - .map_err(|e| { - error!("Failed to read VLESS response: {}", e); - e - })?; - - if response[0] != VLESS_VERSION { - error!("Invalid VLESS response version: {}", response[0]); - return Err(io::Error::new( + while self.response_header_read < self.response_header.len() { + let mut read_buf = + ReadBuf::new(&mut self.response_header[self.response_header_read..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Ready(Ok(())) => { + let n = read_buf.filled().len(); + if n == 0 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "unexpected EOF while reading VLESS response header", + ))); + } + self.response_header_read += n; + } + Poll::Ready(Err(e)) => { + error!("Failed to read VLESS response: {}", e); + return Poll::Ready(Err(e)); + } + Poll::Pending => return Poll::Pending, + } + } + + if self.response_header[0] != VLESS_VERSION { + error!( + "Invalid VLESS response version: {}", + self.response_header[0] + ); + return Poll::Ready(Err(io::Error::new( io::ErrorKind::InvalidData, - format!("invalid VLESS response version: {}", response[0]), - )); + format!( + "invalid VLESS response version: {}", + self.response_header[0] + ), + ))); } - let additional_info_len = response[1]; + let mut remaining = self + .response_additional_remaining + .get_or_insert(self.response_header[1] as usize) + .to_owned(); + let original_additional_len = self.response_header[1] as usize; + + while remaining > 0 { + let mut discard = [0u8; 256]; + let len = remaining.min(discard.len()); + let mut read_buf = ReadBuf::new(&mut discard[..len]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Ready(Ok(())) => { + let n = read_buf.filled().len(); + if n == 0 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "unexpected EOF while reading VLESS additional info", + ))); + } + remaining -= n; + self.response_additional_remaining = Some(remaining); + } + Poll::Ready(Err(e)) => { + error!("Failed to read VLESS additional info: {}", e); + return Poll::Ready(Err(e)); + } + Poll::Pending => return Poll::Pending, + } + } - if additional_info_len > 0 { - let mut additional_info = vec![0u8; additional_info_len as usize]; - tokio::io::AsyncReadExt::read_exact( - &mut self.inner, - &mut additional_info, - ) - .await - .map_err(|e| { - error!("Failed to read VLESS additional info: {}", e); - e - })?; + if original_additional_len > 0 { debug!( "VLESS additional info received: {} bytes", - additional_info_len + original_additional_len ); } @@ -157,7 +238,7 @@ impl VlessStream { self.handshake_done = true; debug!("VLESS handshake completed successfully"); - Ok(()) + Poll::Ready(Ok(())) } } @@ -188,9 +269,7 @@ impl AsyncRead for VlessStream { // Must receive response before reading for non-Vision flows. if self.handshake_sent && !self.response_received && !vision_flow { - let fut = self.receive_response(); - tokio::pin!(fut); - match fut.poll(cx) { + match self.poll_receive_response(cx) { Poll::Ready(Ok(())) => {} Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), Poll::Pending => return Poll::Pending, @@ -207,15 +286,14 @@ impl AsyncWrite for VlessStream { cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { - // Send handshake with first write + // Send handshake with first write. Keep the composed handshake buffer in + // the stream state so retries after Pending do not duplicate or lose the + // VLESS header / first payload bytes. if !self.handshake_sent { - let fut = self.send_handshake_with_data(buf); - tokio::pin!(fut); - match fut.poll(cx) { - Poll::Ready(Ok(n)) => return Poll::Ready(Ok(n)), - Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), - Poll::Pending => return Poll::Pending, + if self.pending_write.is_none() { + self.prepare_handshake_with_data(buf); } + return self.poll_send_pending_handshake(cx); } Pin::new(&mut self.inner).poll_write(cx, buf) @@ -238,20 +316,31 @@ impl AsyncWrite for VlessStream { #[cfg(test)] mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use super::{VlessStream, XTLS_VISION_FLOW}; use crate::session::SocksAddr; - #[test] - fn vless_vision_flow_addon_is_encoded_into_request_header() { - let (io, _) = tokio::io::duplex(1); - let stream = VlessStream::new( + const TEST_UUID: &str = "5415d8e0-df92-3655-afa4-b79de66413f5"; + + fn test_stream( + io: tokio::io::DuplexStream, + flow: Option, + ) -> VlessStream { + VlessStream::new( Box::new(io), - "5415d8e0-df92-3655-afa4-b79de66413f5", + TEST_UUID, &SocksAddr::Domain("example.com".to_owned(), 443), false, - Some(XTLS_VISION_FLOW.to_owned()), + flow, ) - .expect("stream should build"); + .expect("stream should build") + } + + #[test] + fn vless_vision_flow_addon_is_encoded_into_request_header() { + let (io, _) = tokio::io::duplex(1); + let stream = test_stream(io, Some(XTLS_VISION_FLOW.to_owned())); let header = stream.build_handshake_header(); @@ -259,4 +348,56 @@ mod tests { assert_eq!(&header[18..20], &[0x0a, 16]); assert_eq!(&header[20..36], XTLS_VISION_FLOW.as_bytes()); } + + #[tokio::test] + async fn pending_first_write_sends_handshake_and_payload_once() { + let (client, mut server) = tokio::io::duplex(1); + let mut stream = test_stream(client, Some(XTLS_VISION_FLOW.to_owned())); + let expected_header = stream.build_handshake_header().to_vec(); + let payload = b"first application payload"; + let expected_len = expected_header.len() + payload.len(); + + let server_task = tokio::spawn(async move { + let mut received = vec![0; expected_len]; + server + .read_exact(&mut received) + .await + .expect("complete request"); + received + }); + + stream.write_all(payload).await.expect("first write"); + stream.flush().await.expect("flush"); + let received = server_task.await.expect("server task"); + + assert_eq!(&received[..expected_header.len()], expected_header); + assert_eq!(&received[expected_header.len()..], payload); + } + + #[tokio::test] + async fn fragmented_response_header_is_resumed_without_losing_state() { + let (client, mut server) = tokio::io::duplex(1); + let mut stream = test_stream(client, None); + let payload = b"request"; + let request_len = stream.build_handshake_header().len() + payload.len(); + + let server_task = tokio::spawn(async move { + let mut request = vec![0; request_len]; + server.read_exact(&mut request).await.expect("request"); + server.write_all(&[0]).await.expect("response version"); + server.write_all(&[2]).await.expect("addon length"); + server.write_all(&[0xaa]).await.expect("first addon byte"); + server.write_all(&[0xbb]).await.expect("second addon byte"); + server.write_all(b"response").await.expect("response body"); + }); + + stream.write_all(payload).await.expect("request write"); + let mut response = [0; 8]; + stream + .read_exact(&mut response) + .await + .expect("response body"); + assert_eq!(&response, b"response"); + server_task.await.expect("server task"); + } } diff --git a/clash-lib/src/proxy/vless/vision_unpad.rs b/clash-lib/src/proxy/vless/vision_unpad.rs index 601a60c6..f7eb581f 100644 --- a/clash-lib/src/proxy/vless/vision_unpad.rs +++ b/clash-lib/src/proxy/vless/vision_unpad.rs @@ -53,6 +53,7 @@ enum UnpadState { content: Vec, remaining_padding_len: u16, }, + Passthrough, Done, } @@ -98,6 +99,7 @@ impl VisionUnpadder { return Ok(UnpadResult::default()); } if &data[..16] != expected_uuid { + self.state = UnpadState::Passthrough; return Ok(UnpadResult { content: data.to_vec(), command: None, @@ -250,6 +252,12 @@ impl VisionUnpadder { } } } + UnpadState::Passthrough => { + return Ok(UnpadResult { + content: data.to_vec(), + command: None, + }); + } UnpadState::Done => { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, @@ -260,3 +268,27 @@ impl VisionUnpadder { } } } + +#[cfg(test)] +mod tests { + use super::VisionUnpadder; + + #[test] + fn uuid_mismatch_switches_to_permanent_passthrough() { + let expected_uuid = [0x11; 16]; + let mut unpadder = VisionUnpadder::new(expected_uuid); + let first = [0x22; 16]; + + let first_result = unpadder.unpad(&first).expect("first block"); + assert_eq!(first_result.content, first); + assert!(first_result.command.is_none()); + assert!(!unpadder.is_waiting_for_uuid()); + + let fragmented_follow_up = b"short"; + let second_result = unpadder + .unpad(fragmented_follow_up) + .expect("follow-up block"); + assert_eq!(second_result.content, fragmented_follow_up); + assert!(second_result.command.is_none()); + } +} From 3ce60b32b19a14869b26f149e3988bd731fe05ae Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:13:12 +0800 Subject: [PATCH 13/70] feat(net): harden protected socket routing on Windows Select interfaces by destination, improve Windows route/socket handling, add Windows interface support, and enforce protected proxy socket usage with regression guards. --- Cargo.lock | 1 + clash-lib/Cargo.toml | 1 + .../src/app/dispatcher/dispatcher_impl.rs | 7 +- clash-lib/src/app/net/mod.rs | 252 ++++++++++++++- clash-lib/src/proxy/anytls/inbound/handler.rs | 49 ++- clash-lib/src/proxy/converters/vless.rs | 1 + clash-lib/src/proxy/mod.rs | 1 + clash-lib/src/proxy/tun/routes/mod.rs | 5 + clash-lib/src/proxy/tun/routes/windows.rs | 2 +- clash-lib/src/proxy/utils/platform/win.rs | 4 +- clash-lib/src/proxy/utils/socket_helpers.rs | 218 +++++++++++-- .../tests/socket_protection_guard_tests.rs | 294 +++++++++++++++--- 12 files changed, 746 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a3b69a..af188f8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -821,6 +821,7 @@ dependencies = [ "sock2proc", "socket2", "subtle", + "syn", "sysinfo", "tempfile", "thiserror 2.0.18", diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index 92f44876..7d421bd2 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -220,6 +220,7 @@ mockall = "0.14.0" tokio-test = "0.4.5" serial_test = "3.5" httpmock = "0.8.3" +syn = { version = "2", features = ["full", "visit"] } [lints] workspace = true diff --git a/clash-lib/src/app/dispatcher/dispatcher_impl.rs b/clash-lib/src/app/dispatcher/dispatcher_impl.rs index 278166a4..fb094301 100644 --- a/clash-lib/src/app/dispatcher/dispatcher_impl.rs +++ b/clash-lib/src/app/dispatcher/dispatcher_impl.rs @@ -295,9 +295,10 @@ impl Dispatcher { // SS2022 and dual-stack UDP inbounds can surface IPv4 targets as // IPv4-mapped IPv6. Keep the canonical IP before fake-IP reverse // lookup may replace the destination with a domain. - if let SocksAddr::Ip(addr) = &mut packet.dst_addr { - *addr = addr.to_canonical(); - sess.resolved_ip = Some(addr.ip()); + if let SocksAddr::Ip(addr) = &packet.dst_addr { + let canonical = addr.to_canonical(); + packet.dst_addr = SocksAddr::Ip(canonical); + sess.resolved_ip = Some(canonical.ip()); } let dest = match reverse_lookup(&resolver, &packet.dst_addr).await { diff --git a/clash-lib/src/app/net/mod.rs b/clash-lib/src/app/net/mod.rs index 3665bbef..a2f3072f 100644 --- a/clash-lib/src/app/net/mod.rs +++ b/clash-lib/src/app/net/mod.rs @@ -24,6 +24,20 @@ pub static TUN_SOMARK: LazyLock>> = #[cfg(all(feature = "tun", target_os = "linux"))] static ROUTE_NETLINK_HANDLE: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); +#[cfg(all(feature = "tun", target_os = "windows"))] +static WINDOWS_TUN_INTERFACE_INDEX: LazyLock>> = + LazyLock::new(Default::default); +#[cfg(all(feature = "tun", target_os = "windows"))] +static WINDOWS_FALLBACK_OUTBOUND_INTERFACES: LazyLock< + tokio::sync::RwLock, +> = LazyLock::new(Default::default); + +#[cfg(all(feature = "tun", target_os = "windows"))] +#[derive(Debug, Clone, Default)] +struct WindowsFallbackOutboundInterfaces { + ipv4: Option, + ipv6: Option, +} #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum AddressFamily { @@ -214,6 +228,79 @@ pub async fn route_for_destination( }) } +#[cfg(all(feature = "tun", target_os = "windows"))] +fn windows_sockaddr_to_ip( + address: &windows::Win32::Networking::WinSock::SOCKADDR_INET, +) -> Option { + use windows::Win32::Networking::WinSock::{AF_INET, AF_INET6}; + + let family = unsafe { address.si_family }; + let address = if family == AF_INET { + IpAddr::V4(unsafe { address.Ipv4.sin_addr }.into()) + } else if family == AF_INET6 { + IpAddr::V6(unsafe { address.Ipv6.sin6_addr }.into()) + } else { + return None; + }; + + (!address.is_unspecified()).then_some(address) +} + +#[cfg(all(feature = "tun", target_os = "windows"))] +pub async fn route_for_destination( + destination: IpAddr, + _fwmark: Option, +) -> std::result::Result { + use windows::Win32::{ + NetworkManagement::IpHelper::{GetBestRoute2, MIB_IPFORWARD_ROW2}, + Networking::WinSock::SOCKADDR_INET, + }; + + let destination_address = SOCKADDR_INET::from(SocketAddr::new(destination, 0)); + let mut route = MIB_IPFORWARD_ROW2::default(); + let mut preferred_source = SOCKADDR_INET::default(); + let result = unsafe { + GetBestRoute2( + None, + 0, + None, + &destination_address, + 0, + &mut route, + &mut preferred_source, + ) + }; + result + .to_hresult() + .ok() + .map_err(|error| RouteSelectionError::QueryFailed { + destination, + message: error.message(), + })?; + + let interface = get_interface_by_index(route.InterfaceIndex).ok_or( + RouteSelectionError::InterfaceNotFound { + destination, + interface_index: route.InterfaceIndex, + }, + )?; + + Ok(RouteDecision { + family: if destination.is_ipv4() { + AddressFamily::Ipv4 + } else { + AddressFamily::Ipv6 + }, + destination, + interface_index: route.InterfaceIndex, + interface_name: interface.name, + gateway: windows_sockaddr_to_ip(&route.NextHop), + preferred_source: windows_sockaddr_to_ip(&preferred_source), + table: 0, + metric: Some(route.Metric), + }) +} + #[cfg(all(feature = "tun", target_os = "linux"))] fn build_interface_route( destination: ipnet::IpNet, @@ -256,7 +343,10 @@ pub async fn add_route_to_interface( }) } -#[cfg(any(not(feature = "tun"), not(target_os = "linux")))] +#[cfg(any( + not(feature = "tun"), + not(any(target_os = "linux", target_os = "windows")) +))] pub async fn route_for_destination( _destination: IpAddr, _fwmark: Option, @@ -295,14 +385,74 @@ fn is_candidate_outbound_v6(addr: Ipv6Addr) -> bool { addr.is_unique_local() || is_global_unicast_like(addr) } +#[cfg(all(feature = "tun", target_os = "windows"))] +async fn resolve_windows_fallback_interface( + destination: IpAddr, +) -> Option { + route_for_destination(destination, None) + .await + .ok() + .and_then(|route| get_interface_by_index(route.interface_index)) +} + +#[cfg(all(feature = "tun", target_os = "windows"))] +async fn refresh_windows_fallback_outbound_interfaces() { + // GetBestRoute2 only inspects the route table; these probes do not send any + // network traffic. Capture both families before the TUN split routes exist. + let ipv4 = + resolve_windows_fallback_interface(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))) + .await; + let ipv6 = resolve_windows_fallback_interface(IpAddr::V6(Ipv6Addr::new( + 0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111, + ))) + .await; + let interfaces = WindowsFallbackOutboundInterfaces { ipv4, ipv6 }; + trace!(?interfaces, "cached Windows fallback outbound interfaces"); + *WINDOWS_FALLBACK_OUTBOUND_INTERFACES.write().await = interfaces; +} + +#[cfg(all(feature = "tun", target_os = "windows"))] +pub(crate) async fn set_windows_tun_interface_index(index: Option) { + *WINDOWS_TUN_INTERFACE_INDEX.write().await = index; +} + +#[cfg(all(feature = "tun", target_os = "windows"))] +pub(crate) async fn windows_tun_interface_index() -> Option { + *WINDOWS_TUN_INTERFACE_INDEX.read().await +} + +#[cfg(all(feature = "tun", target_os = "windows"))] +pub(crate) async fn windows_fallback_outbound_interface( + destination: IpAddr, +) -> Option { + let interfaces = WINDOWS_FALLBACK_OUTBOUND_INTERFACES.read().await; + if destination.is_ipv4() { + interfaces.ipv4.clone() + } else { + interfaces.ipv6.clone() + } +} + #[cfg(feature = "tun")] pub async fn init_net_config( tun_somark: Option, interface: Option<&Interface>, ) -> Result<()> { - *DEFAULT_OUTBOUND_INTERFACE.write().await = - resolve_outbound_interface(interface).await?; + let configured_interface = resolve_outbound_interface(interface).await?; + #[cfg(target_os = "windows")] + let should_cache_fallback = configured_interface.is_none(); + *DEFAULT_OUTBOUND_INTERFACE.write().await = configured_interface; *TUN_SOMARK.write().await = tun_somark; + #[cfg(target_os = "windows")] + { + set_windows_tun_interface_index(None).await; + if should_cache_fallback { + refresh_windows_fallback_outbound_interfaces().await; + } else { + *WINDOWS_FALLBACK_OUTBOUND_INTERFACES.write().await = + WindowsFallbackOutboundInterfaces::default(); + } + } trace!( "default outbound interface: {:?}, tun somark: {:?}", *DEFAULT_OUTBOUND_INTERFACE.read().await, @@ -315,6 +465,12 @@ pub async fn init_net_config( pub async fn clear_net_config() { *DEFAULT_OUTBOUND_INTERFACE.write().await = None; *TUN_SOMARK.write().await = None; + #[cfg(target_os = "windows")] + { + set_windows_tun_interface_index(None).await; + *WINDOWS_FALLBACK_OUTBOUND_INTERFACES.write().await = + WindowsFallbackOutboundInterfaces::default(); + } } /// Represents a parsed outbound interface for use in runtime. @@ -419,7 +575,7 @@ pub fn get_interface_by_name(name: &str) -> Option { Some(outbound) } -#[cfg(feature = "tun")] +#[cfg(all(feature = "tun", any(target_os = "linux", target_os = "windows")))] fn get_interface_by_index(index: u32) -> Option { network_interface::NetworkInterface::show() .ok()? @@ -575,7 +731,69 @@ async fn validate_configured_interface_state( validate_linux_interface_state(interface, &state) } -#[cfg(all(feature = "tun", not(target_os = "linux")))] +#[cfg(all(feature = "tun", target_os = "windows"))] +async fn validate_configured_interface_state( + interface: &OutboundInterface, +) -> Result<()> { + use windows::Win32::NetworkManagement::{ + IpHelper::{GetIfEntry2, MIB_IF_ROW2}, + Ndis::{ + IfOperStatusDown, IfOperStatusLowerLayerDown, IfOperStatusNotPresent, + IfOperStatusTesting, MediaConnectStateDisconnected, + NET_IF_ADMIN_STATUS_UP, + }, + }; + + let mut row = MIB_IF_ROW2 { + InterfaceIndex: interface.index, + ..Default::default() + }; + let result = unsafe { GetIfEntry2(&mut row) }; + result.to_hresult().ok().map_err(|error| { + Error::InvalidConfig(format!( + "failed to inspect configured outbound interface \"{}\" via \ + GetIfEntry2: index={}, error={}", + interface.name, + interface.index, + error.message() + )) + })?; + + if row.AdminStatus != NET_IF_ADMIN_STATUS_UP { + return Err(Error::InvalidConfig(format!( + "configured outbound interface \"{}\" is not administratively UP: \ + index={}, admin_status={:?}; enable the interface or choose another \ + interface-name", + interface.name, interface.index, row.AdminStatus + ))); + } + + if row.OperStatus == IfOperStatusDown + || row.OperStatus == IfOperStatusLowerLayerDown + || row.OperStatus == IfOperStatusNotPresent + || row.OperStatus == IfOperStatusTesting + { + return Err(Error::InvalidConfig(format!( + "configured outbound interface \"{}\" is not operational: index={}, \ + oper_status={:?}, media_state={:?}; connect the interface or choose \ + another interface-name", + interface.name, interface.index, row.OperStatus, row.MediaConnectState + ))); + } + + if row.MediaConnectState == MediaConnectStateDisconnected { + return Err(Error::InvalidConfig(format!( + "configured outbound interface \"{}\" has no connected media: \ + index={}, oper_status={:?}, media_state={:?}; connect the interface \ + or choose another interface-name", + interface.name, interface.index, row.OperStatus, row.MediaConnectState + ))); + } + + Ok(()) +} + +#[cfg(all(feature = "tun", not(any(target_os = "linux", target_os = "windows"))))] async fn validate_configured_interface_state( _interface: &OutboundInterface, ) -> Result<()> { @@ -728,7 +946,9 @@ impl Interface { #[cfg(all(test, feature = "tun"))] mod tests { - use super::{Interface, OutboundInterface, resolve_outbound_interface}; + #[cfg(target_os = "linux")] + use super::OutboundInterface; + use super::{Interface, resolve_outbound_interface}; use crate::Error; #[cfg(target_os = "linux")] @@ -768,6 +988,26 @@ mod tests { assert!(resolve_outbound_interface(None).await.unwrap().is_none()); } + #[cfg(target_os = "windows")] + #[test] + fn windows_sockaddr_conversion_preserves_family_and_ignores_unspecified() { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use windows::Win32::Networking::WinSock::SOCKADDR_INET; + + let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)); + let ipv6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 10)); + let ipv4_sockaddr = SOCKADDR_INET::from(SocketAddr::new(ipv4, 0)); + let ipv6_sockaddr = SOCKADDR_INET::from(SocketAddr::new(ipv6, 0)); + let unspecified = SOCKADDR_INET::from(SocketAddr::new( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + 0, + )); + + assert_eq!(super::windows_sockaddr_to_ip(&ipv4_sockaddr), Some(ipv4)); + assert_eq!(super::windows_sockaddr_to_ip(&ipv6_sockaddr), Some(ipv6)); + assert_eq!(super::windows_sockaddr_to_ip(&unspecified), None); + } + #[cfg(target_os = "linux")] #[test] fn netlink_interface_state_requires_admin_up() { diff --git a/clash-lib/src/proxy/anytls/inbound/handler.rs b/clash-lib/src/proxy/anytls/inbound/handler.rs index 63fc1380..385ea4ed 100644 --- a/clash-lib/src/proxy/anytls/inbound/handler.rs +++ b/clash-lib/src/proxy/anytls/inbound/handler.rs @@ -7,9 +7,11 @@ use super::{ UDP_OVER_TCP_V2_MAGIC_HOST, read_frame, write_frame, }, }; +#[cfg(all(target_os = "linux", feature = "tun"))] +use crate::app::net::TUN_SOMARK; use crate::{ Dispatcher, - proxy::AnyStream, + proxy::{AnyStream, utils::new_protected_tcp_stream}, session::{Network, Session, SocksAddr, Type}, }; use bytes::BufMut; @@ -30,18 +32,55 @@ const RELAY_BUFFER_SIZE: usize = 16 * 1024; /// /// The 32 bytes already consumed for the password check are prepended before /// piping the rest of the (decrypted) application stream to the backend. +async fn connect_fallback_backend( + fallback_addr: &str, +) -> std::io::Result { + let endpoints = tokio::net::lookup_host(fallback_addr).await?; + #[cfg(all(target_os = "linux", feature = "tun"))] + let so_mark = *TUN_SOMARK.read().await; + #[cfg(all(target_os = "linux", not(feature = "tun")))] + let so_mark = None; + let mut errors = Vec::new(); + + for endpoint in endpoints { + match new_protected_tcp_stream( + endpoint, + None, + #[cfg(target_os = "linux")] + so_mark, + ) + .await + { + Ok(stream) => return Ok(stream), + Err(error) => errors.push(format!("{endpoint}: {error}")), + } + } + + if errors.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("fallback address {fallback_addr} resolved to no endpoints"), + )) + } else { + Err(std::io::Error::other(format!( + "all fallback endpoints failed: {}", + errors.join("; ") + ))) + } +} + async fn handle_fallback( mut tls_stream: tokio_rustls::server::TlsStream, already_read: &[u8], fallback_addr: &str, src_addr: SocketAddr, ) { - let mut backend = match tokio::net::TcpStream::connect(fallback_addr).await { - Ok(s) => s, - Err(e) => { + let mut backend = match connect_fallback_backend(fallback_addr).await { + Ok(stream) => stream, + Err(error) => { debug!( "anytls fallback: failed to connect to {fallback_addr} for \ - {src_addr}: {e}" + {src_addr}: {error}" ); return; } diff --git a/clash-lib/src/proxy/converters/vless.rs b/clash-lib/src/proxy/converters/vless.rs index f8504916..99f01b96 100644 --- a/clash-lib/src/proxy/converters/vless.rs +++ b/clash-lib/src/proxy/converters/vless.rs @@ -419,6 +419,7 @@ fn resolve_xhttp_min_posts_interval_ms(xhttp_opts: &XhttpOpt) -> Option { .and_then(|extra| extra.sc_min_posts_interval_ms) } +#[cfg(feature = "reality")] fn resolve_xhttp_upload_server_name(s: &OutboundVless) -> String { s.xhttp_opts .as_ref() diff --git a/clash-lib/src/proxy/mod.rs b/clash-lib/src/proxy/mod.rs index 9a69d254..f9cc231a 100644 --- a/clash-lib/src/proxy/mod.rs +++ b/clash-lib/src/proxy/mod.rs @@ -58,6 +58,7 @@ use self::{group::GroupProxyAPIResponse, utils::RemoteConnector}; pub use group::{fallback, relay, selector, urltest}; pub use options::HandlerCommonOptions; +#[cfg(feature = "http_port")] #[derive(thiserror::Error, Debug)] pub enum ProxyError { #[error(transparent)] diff --git a/clash-lib/src/proxy/tun/routes/mod.rs b/clash-lib/src/proxy/tun/routes/mod.rs index 5f255e8e..6172d7be 100644 --- a/clash-lib/src/proxy/tun/routes/mod.rs +++ b/clash-lib/src/proxy/tun/routes/mod.rs @@ -31,6 +31,8 @@ use tracing::warn; use crate::config::internal::config::TunConfig; use crate::app::net::get_interface_by_name; +#[cfg(windows)] +use crate::app::net::set_windows_tun_interface_index; #[allow(dead_code)] pub async fn maybe_add_routes( @@ -160,6 +162,9 @@ pub async fn maybe_add_routes( add_route(&tun_iface, r)?; } } + + #[cfg(windows)] + set_windows_tun_interface_index(Some(tun_iface.index)).await; } Ok(()) diff --git a/clash-lib/src/proxy/tun/routes/windows.rs b/clash-lib/src/proxy/tun/routes/windows.rs index b66db219..8660ff98 100644 --- a/clash-lib/src/proxy/tun/routes/windows.rs +++ b/clash-lib/src/proxy/tun/routes/windows.rs @@ -248,7 +248,7 @@ pub fn add_excluded_route( "failed to add excluded route {} via interface index {}: {}", dest, best_route.InterfaceIndex, err ); - Err(std::io::Error::other(err.message().to_string())) + Err(std::io::Error::other(err.message())) } fn get_guid(iface: &OutboundInterface) -> Option { diff --git a/clash-lib/src/proxy/utils/platform/win.rs b/clash-lib/src/proxy/utils/platform/win.rs index 3fe99e01..f16ba4fe 100644 --- a/clash-lib/src/proxy/utils/platform/win.rs +++ b/clash-lib/src/proxy/utils/platform/win.rs @@ -35,9 +35,7 @@ impl SocketProtector for DefaultSocketProtector { return Ok(()); }; - let handle = SOCKET(handle.try_into().map_err(|_| { - io::Error::other("invalid socket handle for Windows protector") - })?); + let handle = SOCKET(handle); let family = match socket_family(handle) { Ok(family) => family, Err(err) => { diff --git a/clash-lib/src/proxy/utils/socket_helpers.rs b/clash-lib/src/proxy/utils/socket_helpers.rs index fa981719..1b44df4a 100644 --- a/clash-lib/src/proxy/utils/socket_helpers.rs +++ b/clash-lib/src/proxy/utils/socket_helpers.rs @@ -1,3 +1,5 @@ +#[cfg(all(feature = "tun", target_os = "windows"))] +use std::net::IpAddr; use std::{ io, net::{Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -8,14 +10,20 @@ use socket2::TcpKeepalive; use tokio::net::{TcpListener, TcpSocket, TcpStream, UdpSocket}; use tokio::time::timeout; +#[cfg(all(feature = "tun", target_os = "windows"))] +use tracing::warn; use tracing::{debug, error, instrument, trace}; #[cfg(feature = "tun")] use crate::app::net::DEFAULT_OUTBOUND_INTERFACE; use crate::app::net::OutboundInterface; -#[cfg(all(feature = "tun", target_os = "linux"))] +#[cfg(all(feature = "tun", any(target_os = "linux", target_os = "windows")))] use crate::app::net::{get_interface_by_name, route_for_destination}; -#[cfg(all(feature = "tun", target_os = "linux"))] +#[cfg(all(feature = "tun", target_os = "windows"))] +use crate::app::net::{ + windows_fallback_outbound_interface, windows_tun_interface_index, +}; +#[cfg(all(feature = "tun", any(target_os = "linux", target_os = "windows")))] use crate::common::errors::new_io_error; use crate::proxy::utils::platform::{ maybe_protect_socket, must_bind_socket_on_interface, @@ -184,36 +192,151 @@ async fn effective_interface_for_destination( #[cfg(target_os = "linux")] so_mark: Option, ) -> std::io::Result> { let default_iface = default_outbound_interface().await; - let mut effective_iface = - select_effective_iface(explicit, &default_iface).cloned(); + let effective_iface = select_effective_iface(explicit, &default_iface).cloned(); #[cfg(target_os = "linux")] - if let Some(destination) = destination.filter(|_| effective_iface.is_none()) - && !destination.is_loopback() - { - let route = route_for_destination(destination, so_mark) - .await - .map_err(new_io_error)?; - debug!( - destination = %destination, - interface = %route.interface_name, - interface_index = route.interface_index, - gateway = ?route.gateway, - preferred_source = ?route.preferred_source, - table = route.table, - metric = ?route.metric, - fwmark = ?so_mark, - "selected outbound route" - ); - effective_iface = - Some(get_interface_by_name(&route.interface_name).ok_or_else(|| { - new_io_error(format!( - "route interface \"{}\" disappeared before connecting \ - to {destination}", - route.interface_name - )) - })?); - } + let effective_iface = { + let mut effective_iface = effective_iface; + if let Some(destination) = destination.filter(|_| effective_iface.is_none()) + && !destination.is_loopback() + { + let route = route_for_destination(destination, so_mark) + .await + .map_err(new_io_error)?; + debug!( + destination = %destination, + interface = %route.interface_name, + interface_index = route.interface_index, + gateway = ?route.gateway, + preferred_source = ?route.preferred_source, + table = route.table, + metric = ?route.metric, + fwmark = ?so_mark, + "selected outbound route" + ); + effective_iface = Some( + get_interface_by_name(&route.interface_name).ok_or_else(|| { + new_io_error(format!( + "route interface \"{}\" disappeared before connecting \ + to {destination}", + route.interface_name + )) + })?, + ); + } + effective_iface + }; + + #[cfg(target_os = "windows")] + let effective_iface = { + let mut effective_iface = effective_iface; + if let Some(destination) = destination.filter(|_| effective_iface.is_none()) + && !destination.is_loopback() + { + let tun_interface_index = windows_tun_interface_index().await; + let selected = match route_for_destination(destination, None).await { + Ok(route) if tun_interface_index == Some(route.interface_index) => { + let fallback = windows_fallback_outbound_interface(destination) + .await + .ok_or_else(|| { + new_io_error(format!( + "Windows route for {destination} resolves to the \ + Chimera TUN interface (index {}) and no pre-TUN \ + fallback interface is available", + route.interface_index + )) + })?; + if fallback.index == route.interface_index { + return Err(new_io_error(format!( + "cached Windows fallback interface for {destination} \ + is the Chimera TUN interface itself (index {})", + route.interface_index + ))); + } + warn!( + destination = %destination, + tun_interface_index = route.interface_index, + fallback_interface = %fallback.name, + fallback_interface_index = fallback.index, + "Windows best route points at TUN; using the pre-TUN fallback interface" + ); + Some(fallback) + } + Ok(route) => { + debug!( + destination = %destination, + interface = %route.interface_name, + interface_index = route.interface_index, + gateway = ?route.gateway, + preferred_source = ?route.preferred_source, + metric = ?route.metric, + "selected Windows outbound route" + ); + if let Some(interface) = + get_interface_by_name(&route.interface_name) + { + Some(interface) + } else if let Some(fallback) = + windows_fallback_outbound_interface(destination).await + { + warn!( + destination = %destination, + route_interface = %route.interface_name, + fallback_interface = %fallback.name, + fallback_interface_index = fallback.index, + "Windows route interface disappeared; using the pre-TUN fallback interface" + ); + Some(fallback) + } else if tun_interface_index.is_none() { + warn!( + destination = %destination, + route_interface = %route.interface_name, + "Windows route interface disappeared; leaving the socket unbound for system routing" + ); + None + } else { + return Err(new_io_error(format!( + "route interface \"{}\" disappeared before connecting \ + to {destination}, and no pre-TUN fallback interface is \ + available", + route.interface_name + ))); + } + } + Err(error) => { + if let Some(fallback) = + windows_fallback_outbound_interface(destination).await + { + warn!( + destination = %destination, + fallback_interface = %fallback.name, + fallback_interface_index = fallback.index, + error = %error, + "Windows route lookup failed; using the pre-TUN fallback interface" + ); + Some(fallback) + } else if tun_interface_index.is_none() { + warn!( + destination = %destination, + error = %error, + "Windows route lookup failed before TUN routing; leaving the socket unbound for system routing" + ); + None + } else { + return Err(new_io_error(error.to_string())); + } + } + }; + effective_iface = selected; + } + effective_iface + }; + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + let effective_iface = { + let _ = destination; + effective_iface + }; Ok(effective_iface) } @@ -265,9 +388,40 @@ pub async fn new_protected_dual_stack_udp_socket( #[cfg(feature = "tun")] { let default_iface = default_outbound_interface().await; - let effective_iface = select_effective_iface(iface, &default_iface); + #[cfg(target_os = "windows")] + let automatic_iface = if select_effective_iface(iface, &default_iface) + .is_none() + && windows_tun_interface_index().await.is_some() + { + let mut fallback = windows_fallback_outbound_interface(IpAddr::V4( + Ipv4Addr::UNSPECIFIED, + )) + .await; + if fallback.is_none() { + fallback = windows_fallback_outbound_interface(IpAddr::V6( + Ipv6Addr::UNSPECIFIED, + )) + .await; + } + if let Some(fallback) = fallback.as_ref() { + warn!( + interface = %fallback.name, + interface_index = fallback.index, + "pinning multi-destination Windows UDP socket to the pre-TUN fallback interface" + ); + } + fallback + } else { + None + }; + #[cfg(target_os = "windows")] + let effective_iface = select_effective_iface(iface, &default_iface) + .cloned() + .or(automatic_iface); + #[cfg(not(target_os = "windows"))] + let effective_iface = select_effective_iface(iface, &default_iface).cloned(); return new_dual_stack_udp_socket( - effective_iface, + effective_iface.as_ref(), #[cfg(target_os = "linux")] so_mark, ); diff --git a/clash-lib/tests/socket_protection_guard_tests.rs b/clash-lib/tests/socket_protection_guard_tests.rs index 57aaf9c6..6d5325cc 100644 --- a/clash-lib/tests/socket_protection_guard_tests.rs +++ b/clash-lib/tests/socket_protection_guard_tests.rs @@ -1,72 +1,218 @@ use std::{fs, path::Path}; -const ALLOWED_DIRECT_CONNECT_FILES: &[&str] = &[ - "clash-lib/src/proxy/tun/mod.rs", - "clash-lib/src/proxy/utils/socket_helpers.rs", -]; - -const ALLOWED_DIRECT_BIND_FILES: &[&str] = &[ - "clash-lib/src/proxy/direct/mod.rs", - "clash-lib/src/proxy/direct/datagram.rs", - "clash-lib/src/proxy/tun/mod.rs", - "clash-lib/src/proxy/utils/socket_helpers.rs", -]; +use syn::{ + Attribute, Expr, ExprCall, ImplItemFn, ItemFn, ItemImpl, ItemMod, Meta, Token, + TraitItemFn, + parse::Parser, + punctuated::Punctuated, + visit::{self, Visit}, +}; + +const TEST_ONLY_SOURCE_PREFIXES: &[&str] = + &["clash-lib/src/proxy/utils/test_utils/"]; + +#[derive(Clone, Copy)] +enum ForbiddenCall { + TcpConnect, + UdpBind, +} + +impl ForbiddenCall { + fn type_name(self) -> &'static str { + match self { + Self::TcpConnect => "TcpStream", + Self::UdpBind => "UdpSocket", + } + } + + fn method_name(self) -> &'static str { + match self { + Self::TcpConnect => "connect", + Self::UdpBind => "bind", + } + } + + fn display_name(self) -> &'static str { + match self { + Self::TcpConnect => "TcpStream::connect", + Self::UdpBind => "UdpSocket::bind", + } + } +} #[test] fn proxy_runtime_tcp_connects_use_socket_helper() { - assert_no_forbidden_runtime_usage( - Path::new(env!("CARGO_MANIFEST_DIR")).join("src/proxy"), - "TcpStream::connect", - ALLOWED_DIRECT_CONNECT_FILES, - ); + assert_no_forbidden_runtime_usage(ForbiddenCall::TcpConnect); } #[test] fn proxy_runtime_udp_binds_use_socket_helper() { - assert_no_forbidden_runtime_usage( - Path::new(env!("CARGO_MANIFEST_DIR")).join("src/proxy"), - "UdpSocket::bind", - ALLOWED_DIRECT_BIND_FILES, - ); + assert_no_forbidden_runtime_usage(ForbiddenCall::UdpBind); } -fn assert_no_forbidden_runtime_usage( - root: impl AsRef, - needle: &str, - allowed_files: &[&str], -) { +fn assert_no_forbidden_runtime_usage(forbidden: ForbiddenCall) { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir.parent().expect("workspace root"); + let source_root = manifest_dir.join("src/proxy"); let mut violations = Vec::new(); - visit_rs_files(root.as_ref(), &mut |path| { + + visit_rs_files(&source_root, &mut |path| { let rel = path - .strip_prefix(manifest_dir.parent().expect("workspace root")) + .strip_prefix(workspace_root) .expect("path should be under workspace") .to_string_lossy() .replace('\\', "/"); - if allowed_files.iter().any(|allowed| *allowed == rel) { + if TEST_ONLY_SOURCE_PREFIXES + .iter() + .any(|prefix| rel.starts_with(prefix)) + { return; } - let content = fs::read_to_string(path).expect("read source file"); - let runtime_content = content - .split("\n#[cfg(test)]") - .next() - .unwrap_or(content.as_str()); - for (idx, line) in runtime_content.lines().enumerate() { - if line.contains(needle) { - violations.push(format!("{rel}:{}: {}", idx + 1, line.trim())); - } + let source = fs::read_to_string(path).expect("read source file"); + let calls = find_forbidden_runtime_calls(&source, forbidden) + .unwrap_or_else(|error| panic!("failed to parse {rel}: {error}")); + for call in calls { + violations.push(format!("{rel}: {call}")); } }); assert!( violations.is_empty(), - "runtime proxy code must use socket_helpers for {needle}; violations:\n{}", + "runtime proxy code must use socket_helpers instead of {}; violations:\n{}", + forbidden.display_name(), violations.join("\n") ); } +fn find_forbidden_runtime_calls( + source: &str, + forbidden: ForbiddenCall, +) -> syn::Result> { + let file = syn::parse_file(source)?; + if attributes_require_test(&file.attrs) { + return Ok(Vec::new()); + } + + let mut visitor = RuntimeCallVisitor { + forbidden, + violations: Vec::new(), + }; + visitor.visit_file(&file); + Ok(visitor.violations) +} + +struct RuntimeCallVisitor { + forbidden: ForbiddenCall, + violations: Vec, +} + +impl<'ast> Visit<'ast> for RuntimeCallVisitor { + fn visit_item_mod(&mut self, node: &'ast ItemMod) { + if attributes_require_test(&node.attrs) { + return; + } + visit::visit_item_mod(self, node); + } + + fn visit_item_fn(&mut self, node: &'ast ItemFn) { + if attributes_require_test(&node.attrs) { + return; + } + visit::visit_item_fn(self, node); + } + + fn visit_item_impl(&mut self, node: &'ast ItemImpl) { + if attributes_require_test(&node.attrs) { + return; + } + visit::visit_item_impl(self, node); + } + + fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) { + if attributes_require_test(&node.attrs) { + return; + } + visit::visit_impl_item_fn(self, node); + } + + fn visit_trait_item_fn(&mut self, node: &'ast TraitItemFn) { + if attributes_require_test(&node.attrs) { + return; + } + visit::visit_trait_item_fn(self, node); + } + + fn visit_expr_call(&mut self, node: &'ast ExprCall) { + if let Expr::Path(function) = node.func.as_ref() + && path_ends_with_call( + &function.path, + self.forbidden.type_name(), + self.forbidden.method_name(), + ) + { + self.violations + .push(self.forbidden.display_name().to_owned()); + } + visit::visit_expr_call(self, node); + } +} + +fn path_ends_with_call( + path: &syn::Path, + type_name: &str, + method_name: &str, +) -> bool { + let mut segments = path.segments.iter().rev(); + segments + .next() + .is_some_and(|segment| segment.ident == method_name) + && segments + .next() + .is_some_and(|segment| segment.ident == type_name) +} + +fn attributes_require_test(attributes: &[Attribute]) -> bool { + attributes.iter().any(attribute_requires_test) +} + +fn attribute_requires_test(attribute: &Attribute) -> bool { + if !attribute.path().is_ident("cfg") { + return false; + } + + let Meta::List(list) = &attribute.meta else { + return false; + }; + syn::parse2::(list.tokens.clone()) + .map(|condition| cfg_condition_requires_test(&condition)) + .unwrap_or(false) +} + +fn cfg_condition_requires_test(condition: &Meta) -> bool { + match condition { + Meta::Path(path) => path.is_ident("test"), + Meta::List(list) if list.path.is_ident("all") => parse_cfg_children(list) + .is_some_and(|children| { + children.iter().any(cfg_condition_requires_test) + }), + Meta::List(list) if list.path.is_ident("any") => parse_cfg_children(list) + .is_some_and(|children| { + !children.is_empty() + && children.iter().all(cfg_condition_requires_test) + }), + Meta::List(_) | Meta::NameValue(_) => false, + } +} + +fn parse_cfg_children(list: &syn::MetaList) -> Option> { + Punctuated::::parse_terminated + .parse2(list.tokens.clone()) + .ok() + .map(|children| children.into_iter().collect()) +} + fn visit_rs_files(dir: &Path, f: &mut impl FnMut(&Path)) { for entry in fs::read_dir(dir).expect("read source directory") { let entry = entry.expect("read directory entry"); @@ -78,3 +224,73 @@ fn visit_rs_files(dir: &Path, f: &mut impl FnMut(&Path)) { } } } + +#[test] +fn guard_ignores_cfg_test_and_cfg_all_test_modules() { + let source = r#" + #[cfg(test)] + mod tests { + fn direct_connect() { + let _ = TcpStream::connect("127.0.0.1:1"); + } + } + + #[cfg(all(test, feature = "example"))] + mod feature_tests { + fn direct_bind() { + let _ = UdpSocket::bind("127.0.0.1:0"); + } + } + "#; + + assert!( + find_forbidden_runtime_calls(source, ForbiddenCall::TcpConnect) + .unwrap() + .is_empty() + ); + assert!( + find_forbidden_runtime_calls(source, ForbiddenCall::UdpBind) + .unwrap() + .is_empty() + ); +} + +#[test] +fn guard_does_not_ignore_cfg_not_test_code() { + let source = r#" + #[cfg(not(test))] + fn runtime_connect() { + let _ = tokio::net::TcpStream::connect("127.0.0.1:1"); + } + "#; + + assert_eq!( + find_forbidden_runtime_calls(source, ForbiddenCall::TcpConnect) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn guard_detects_fully_qualified_runtime_calls() { + let source = r#" + fn runtime_networking() { + let _ = tokio::net::TcpStream::connect("127.0.0.1:1"); + let _ = std::net::UdpSocket::bind("127.0.0.1:0"); + } + "#; + + assert_eq!( + find_forbidden_runtime_calls(source, ForbiddenCall::TcpConnect) + .unwrap() + .len(), + 1 + ); + assert_eq!( + find_forbidden_runtime_calls(source, ForbiddenCall::UdpBind) + .unwrap() + .len(), + 1 + ); +} From 0753f5c6c769a91d4902317836a4cbceb4f76b4f Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:13:48 +0800 Subject: [PATCH 14/70] fix(hy2): improve address failover and UDP hopping Retry all resolved endpoints, support the Chimera server variant, and make UDP port hopping asynchronous and memory-safe. --- clash-lib/src/proxy/hysteria2/mod.rs | 183 +++++------- clash-lib/src/proxy/hysteria2/udp_hop.rs | 359 +++++++++++++++++++---- 2 files changed, 381 insertions(+), 161 deletions(-) diff --git a/clash-lib/src/proxy/hysteria2/mod.rs b/clash-lib/src/proxy/hysteria2/mod.rs index a25f70ff..741ea421 100644 --- a/clash-lib/src/proxy/hysteria2/mod.rs +++ b/clash-lib/src/proxy/hysteria2/mod.rs @@ -12,7 +12,7 @@ use std::{ fmt::{Debug, Formatter}, fs, io::{self, BufReader}, - net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + net::SocketAddr, num::ParseIntError, path::Path, pin::Pin, @@ -130,64 +130,6 @@ impl Handler { std::time::Duration::from_secs(300); const MIN_INITIAL_CWND_PACKETS: u64 = 2; - fn default_bind_addr(server_addr: SocketAddr) -> SocketAddr { - match server_addr { - SocketAddr::V4(_) => SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0), - SocketAddr::V6(_) => SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 0), - } - } - - fn select_bind_addr(server_addr: SocketAddr, sess: &Session) -> SocketAddr { - let default_bind_addr = Self::default_bind_addr(server_addr); - - let Some(iface) = sess.iface.as_ref() else { - return default_bind_addr; - }; - - match server_addr { - SocketAddr::V4(_) => match iface.addr_v4 { - Some(bind_ip) => { - let bind_addr = SocketAddr::new(bind_ip.into(), 0); - debug!( - server_addr = %server_addr, - iface = %iface.name, - bind_addr = %bind_addr, - "hysteria2 socket bound to interface address" - ); - bind_addr - } - None => { - warn!( - server_addr = %server_addr, - iface = %iface.name, - "hysteria2 connect-via has no IPv4 address on selected interface, using wildcard bind" - ); - default_bind_addr - } - }, - SocketAddr::V6(_) => match iface.addr_v6 { - Some(bind_ip) => { - let bind_addr = SocketAddr::new(bind_ip.into(), 0); - debug!( - server_addr = %server_addr, - iface = %iface.name, - bind_addr = %bind_addr, - "hysteria2 socket bound to interface address" - ); - bind_addr - } - None => { - warn!( - server_addr = %server_addr, - iface = %iface.name, - "hysteria2 connect-via has no IPv6 address on selected interface, using wildcard bind" - ); - default_bind_addr - } - }, - } - } - fn append_custom_ca( root_store: &mut RootCertStore, source: &str, @@ -310,41 +252,6 @@ impl Handler { } } - async fn resolve_server_addr( - &self, - resolver: ThreadSafeDNSResolver, - ) -> anyhow::Result { - match self.opts.addr.clone() { - SocksAddr::Ip(ip) => Ok(ip), - SocksAddr::Domain(domain, port) => { - let ip = resolver - .resolve(domain.as_str(), false) - .await? - .ok_or_else(|| anyhow!("resolve domain {domain} failed"))?; - Ok(SocketAddr::new(ip, port)) - } - } - } - - fn create_udp_socket( - server_addr: SocketAddr, - sess: &Session, - ) -> io::Result { - let domain = match server_addr { - SocketAddr::V4(_) => socket2::Domain::IPV4, - SocketAddr::V6(_) => socket2::Domain::IPV6, - }; - let bind_addr = Self::select_bind_addr(server_addr, sess); - let socket = socket2::Socket::new(domain, socket2::Type::DGRAM, None)?; - #[cfg(target_os = "linux")] - if let Some(so_mark) = sess.so_mark { - socket.set_mark(so_mark)?; - } - socket.set_nonblocking(true)?; - socket.bind(&bind_addr.into())?; - Ok(socket.into()) - } - fn tls_server_name(&self, server_addr: SocketAddr) -> String { self.opts .sni @@ -402,27 +309,67 @@ impl Handler { &self, sess: &Session, resolver: ThreadSafeDNSResolver, - ) -> anyhow::Result<(Connection, SendRequest)> { + ) -> anyhow::Result<( + Connection, + SendRequest, + tokio::task::JoinHandle<()>, + )> { tracing::trace!( "hysteria2 new_authed_connection_inner: starting connection to {:?}", self.opts.addr ); // Everytime we enstablish a new session, we should lookup the server // address. maybe it changed since it use ddns - let server_socket_addr = match self.opts.addr.clone() { - SocksAddr::Ip(ip) => ip, + let server_socket_addrs = match self.opts.addr.clone() { + SocksAddr::Ip(ip) => vec![ip], SocksAddr::Domain(d, port) => { // Proxy server domains must resolve to real endpoint IPs. Using // enhanced DNS here may return a fake-ip address and make QUIC // connect to the virtual pool instead of the Hysteria2 server. - let ip = resolver - .resolve(d.as_str(), false) + resolver + .resolve_all(d.as_str(), false) .await? - .ok_or_else(|| anyhow!("resolve domain {} failed", d))?; - SocketAddr::new(ip, port) + .into_iter() + .map(|ip| SocketAddr::new(ip, port)) + .collect() } }; + if server_socket_addrs.is_empty() { + return Err(anyhow!("resolve domain {} failed", self.opts.addr)); + } + let mut last_error = None; + for server_socket_addr in server_socket_addrs { + match self + .new_authed_connection_to(sess, server_socket_addr) + .await + { + Ok(connection) => return Ok(connection), + Err(error) => { + warn!( + server = %server_socket_addr, + error = %error, + "hysteria2 connection attempt failed" + ); + last_error = Some(error); + } + } + } + + Err(last_error.unwrap_or_else(|| { + anyhow!("no address available for {}", self.opts.addr) + })) + } + + async fn new_authed_connection_to( + &self, + sess: &Session, + server_socket_addr: SocketAddr, + ) -> anyhow::Result<( + Connection, + SendRequest, + tokio::task::JoinHandle<()>, + )> { // todo: Here maybe we should use a AsyncUdpSocket which implement salamander obfs // and port hopping let create_socket = || async { @@ -487,22 +434,32 @@ impl Handler { .connect(server_socket_addr, self.opts.sni.as_deref().unwrap_or(""))? .await?; tracing::trace!("hysteria2 QUIC connection established"); - let (guard, cc_rx, udp) = Self::auth(&session, &self.opts.password).await?; + let (guard, driver_task, cc_rx, udp) = + Self::auth(&session, &self.opts.password).await?; tracing::trace!("hysteria2 authentication successful, udp={}", udp); *self.support_udp.write().unwrap() = udp; Self::configure_brutal_cc(&session, self.select_brutal_bps(cc_rx)); - Ok((session, guard)) + Ok((session, guard, driver_task)) } async fn auth( conn: &quinn::Connection, passwd: &str, - ) -> anyhow::Result<(SendRequest, CcRx, bool)> { + ) -> anyhow::Result<( + SendRequest, + tokio::task::JoinHandle<()>, + CcRx, + bool, + )> { let h3_conn = h3_quinn::Connection::new(conn.clone()); - let (_, mut sender) = + let (mut driver, mut sender) = h3::client::builder().build::<_, _, Bytes>(h3_conn).await?; + let driver_task = tokio::spawn(async move { + let err = driver.wait_idle().await; + tracing::debug!("hysteria2 h3 driver ended: {err}"); + }); let req = http::Request::post("https://hysteria/auth") .header("Hysteria-Auth", passwd) @@ -536,7 +493,7 @@ impl Handler { .to_str()? .parse()?; - Ok((sender, cc_rx, support_udp)) + Ok((sender, driver_task, cc_rx, support_udp)) } async fn connect_tcp( @@ -590,7 +547,7 @@ impl Handler { }) { Some(s) => Ok(s.clone()), None => { - let (session, guard) = self + let (session, guard, driver_task) = self .new_authed_connection_inner(sess, resolver) .await .map_err(|e| { @@ -604,6 +561,7 @@ impl Handler { let hyst_conn = HysteriaConnection::new_with_task_loop( session, self.opts.udp_mtu, + driver_task, ); *quinn_conn_lock = Some(hyst_conn.clone()); *self.guard.lock().await = Some(guard); @@ -616,6 +574,7 @@ impl Handler { pub struct HysteriaConnection { pub conn: Arc, pub udp_sessions: Arc>>, + h3_driver_task: Option>, // config pub udp_mtu: Option, @@ -625,11 +584,13 @@ impl HysteriaConnection { pub fn new_with_task_loop( conn: Arc, udp_mtu: Option, + h3_driver_task: tokio::task::JoinHandle<()>, ) -> Arc { let s = Arc::new(Self { conn, udp_sessions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), udp_mtu: udp_mtu.map(|x| x as usize), + h3_driver_task: Some(h3_driver_task), }); tokio::spawn(Self::spawn_tasks(s.clone())); @@ -736,6 +697,14 @@ impl HysteriaConnection { } } +impl Drop for HysteriaConnection { + fn drop(&mut self) { + if let Some(driver_task) = self.h3_driver_task.take() { + driver_task.abort(); + } + } +} + impl DialWithConnector for Handler {} #[async_trait::async_trait] diff --git a/clash-lib/src/proxy/hysteria2/udp_hop.rs b/clash-lib/src/proxy/hysteria2/udp_hop.rs index c1f9222b..8d072c95 100644 --- a/clash-lib/src/proxy/hysteria2/udp_hop.rs +++ b/clash-lib/src/proxy/hysteria2/udp_hop.rs @@ -2,9 +2,8 @@ use std::{ fmt::Debug, io, net::SocketAddr, - ops::{Deref, DerefMut, Sub}, pin::Pin, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, MutexGuard}, task::{Context, Poll}, time::{Duration, Instant}, }; @@ -16,11 +15,48 @@ use crate::{ proxy::{converters::hysteria2::PortGenerator, utils::new_protected_udp_socket}, }; +struct PreviousSocket { + socket: Arc, + expires_at: Instant, +} + struct HopState { - prev_conn: Option>, + prev_conn: Option, cur_conn: Arc, + generation: u64, last: Instant, new_hop_port: u16, + pending_hop: + Option>>>, +} + +struct UdpHopPoller { + hop: Arc, + generation: u64, + inner: Pin>, +} + +impl Debug for UdpHopPoller { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UdpHopPoller") + .field("generation", &self.generation) + .finish() + } +} + +impl UdpPoller for UdpHopPoller { + fn poll_writable( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let this = self.as_mut().get_mut(); + let (generation, socket) = this.hop.get_current_conn(); + if generation != this.generation { + this.inner = socket.create_io_poller(); + this.generation = generation; + } + this.inner.as_mut().poll_writable(cx) + } } /// A udp socket hopper, it can hop to a new port when the time interval is @@ -47,6 +83,7 @@ pub struct UdpHop { impl UdpHop { const DEFAULT_INTERVAL: Duration = Duration::from_secs(30); + const PREVIOUS_SOCKET_GRACE: Duration = Duration::from_secs(5); pub async fn new( server_addr: SocketAddr, @@ -68,8 +105,10 @@ impl UdpHop { let state = HopState { prev_conn: None, cur_conn: TokioRuntime.wrap_udp_socket(socket)?, + generation: 0, last: Instant::now(), new_hop_port: server_addr.port(), + pending_hop: None, } .into(); @@ -85,57 +124,132 @@ impl UdpHop { }) } - fn hop(&self) -> u16 { - let mut lock = self.state.lock().unwrap(); - let HopState { - prev_conn, - cur_conn, - last, - new_hop_port, - } = lock.deref_mut(); + fn lock_state(&self) -> MutexGuard<'_, HopState> { + self.state.lock().unwrap_or_else(|poisoned| { + tracing::warn!("recovering poisoned hysteria2 UDP hop state"); + poisoned.into_inner() + }) + } - let now = Instant::now(); - let to_hop = now.sub(*last) > self.interval; + fn expire_previous_socket(state: &mut HopState, now: Instant) { + if state + .prev_conn + .as_ref() + .is_some_and(|previous| now >= previous.expires_at) + { + state.prev_conn = None; + } + } - if to_hop && prev_conn.is_none() { - *last = now; - tracing::trace!("port hopping"); + fn finish_pending_hop(&self, state: &mut HopState, now: Instant) { + use tokio::sync::oneshot::error::TryRecvError; - futures::executor::block_on(new_protected_udp_socket( + let completed = match state.pending_hop.as_mut() { + Some(receiver) => match receiver.try_recv() { + Ok(result) => Some(result), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Closed) => Some(Err(io::Error::other( + "hysteria2 UDP hop socket task was cancelled", + ))), + }, + None => None, + }; + + let Some(result) = completed else { + return; + }; + state.pending_hop = None; + + match result { + Ok(new_conn) => { + let old_conn = std::mem::replace(&mut state.cur_conn, new_conn); + state.generation = state.generation.wrapping_add(1); + state.prev_conn = Some(PreviousSocket { + socket: old_conn, + expires_at: now + Self::PREVIOUS_SOCKET_GRACE, + }); + state.new_hop_port = self.port_range.get(); + tracing::trace!( + port = state.new_hop_port, + "hysteria2 UDP port hop activated" + ); + } + Err(error) => { + tracing::error!(%error, "hysteria2 UDP port hop socket creation failed"); + } + } + } + + fn schedule_hop(&self, state: &mut HopState, now: Instant) { + if state.pending_hop.is_some() + || now.duration_since(state.last) <= self.interval + { + return; + } + + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + state.last = now; + tracing::error!( + "cannot schedule hysteria2 UDP port hop outside a Tokio runtime" + ); + return; + }; + + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + let iface = self.iface.clone(); + let family_hint = self.family_hint; + #[cfg(target_os = "linux")] + let so_mark = self.so_mark; + + runtime.spawn(async move { + let result = new_protected_udp_socket( None, - self.iface.as_ref(), + iface.as_ref(), #[cfg(target_os = "linux")] - self.so_mark, - Some(self.family_hint), - )) + so_mark, + Some(family_hint), + ) + .await .and_then(|udp| udp.into_std()) - .and_then(|udp| TokioRuntime.wrap_udp_socket(udp)) - .map(|new_conn| { - *new_hop_port = self.port_range.get(); - *prev_conn = Some(std::mem::replace(cur_conn, new_conn)); - }) - .unwrap_or_else(|e| { - tracing::error!("port hopping err {}", e); - }); - } - *new_hop_port + .and_then(|udp| TokioRuntime.wrap_udp_socket(udp)); + let _ = result_tx.send(result); + }); + + state.pending_hop = Some(result_rx); + state.last = now; + tracing::trace!("preparing hysteria2 UDP port hop socket"); + } + + fn hop(&self) -> u16 { + let now = Instant::now(); + let mut state = self.lock_state(); + Self::expire_previous_socket(&mut state, now); + self.finish_pending_hop(&mut state, now); + self.schedule_hop(&mut state, now); + state.new_hop_port } fn get_conn( &self, ) -> (Option>, Arc) { - let lock = self.state.lock().unwrap(); - let HopState { - prev_conn, - cur_conn, - .. - } = lock.deref(); - (prev_conn.clone(), cur_conn.clone()) + let mut state = self.lock_state(); + Self::expire_previous_socket(&mut state, Instant::now()); + ( + state + .prev_conn + .as_ref() + .map(|previous| previous.socket.clone()), + state.cur_conn.clone(), + ) } - fn drop_prcv_conn(&self) { - let mut lock = self.state.lock().unwrap(); - lock.deref_mut().prev_conn.take(); + fn get_current_conn(&self) -> (u64, Arc) { + let state = self.lock_state(); + (state.generation, state.cur_conn.clone()) + } + + fn drop_prev_conn(&self) { + self.lock_state().prev_conn.take(); } } @@ -149,23 +263,21 @@ impl Debug for UdpHop { impl AsyncUdpSocket for UdpHop { fn create_io_poller(self: Arc) -> Pin> { - let cur = self.get_conn().1; - cur.create_io_poller() + let (generation, socket) = self.get_current_conn(); + let inner = socket.create_io_poller(); + Box::pin(UdpHopPoller { + hop: self, + generation, + inner, + }) } fn try_send(&self, transmit: &Transmit) -> io::Result<()> { let port = self.hop(); - let cur = self.get_conn().1; - - // here just need change send addr, it is not necessary to change send - // contents, so we can use unsafe - unsafe { - let prt = transmit as *const Transmit as *mut Transmit; - (*prt).destination.set_port(port); - } - - cur.try_send(transmit) + let mut hopped_transmit = transmit.clone(); + hopped_transmit.destination.set_port(port); + cur.try_send(&hopped_transmit) } // fn poll_send( @@ -231,7 +343,7 @@ impl AsyncUdpSocket for UdpHop { }; if should_drop { - self.drop_prcv_conn(); + self.drop_prev_conn(); } meta.iter_mut() .take(len) @@ -267,3 +379,142 @@ impl AsyncUdpSocket for UdpHop { self.get_conn().1.may_fragment() } } + +#[cfg(test)] +mod tests { + use std::{ + net::{IpAddr, Ipv4Addr}, + sync::atomic::{AtomicU16, Ordering}, + }; + + use super::*; + + #[derive(Debug)] + struct FakeSocket { + id: u16, + observed_port: Arc, + polled_socket: Arc, + } + + #[derive(Debug)] + struct FakePoller { + id: u16, + polled_socket: Arc, + } + + impl UdpPoller for FakePoller { + fn poll_writable( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.polled_socket.store(self.id, Ordering::SeqCst); + Poll::Ready(Ok(())) + } + } + + impl AsyncUdpSocket for FakeSocket { + fn create_io_poller(self: Arc) -> Pin> { + Box::pin(FakePoller { + id: self.id, + polled_socket: self.polled_socket.clone(), + }) + } + + fn try_send(&self, transmit: &Transmit) -> io::Result<()> { + self.observed_port + .store(transmit.destination.port(), Ordering::SeqCst); + Ok(()) + } + + fn poll_recv( + &self, + _cx: &mut Context<'_>, + _bufs: &mut [io::IoSliceMut<'_>], + _meta: &mut [quinn::udp::RecvMeta], + ) -> Poll> { + Poll::Pending + } + + fn local_addr(&self) -> io::Result { + Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)) + } + } + + fn fake_socket( + id: u16, + observed_port: Arc, + polled_socket: Arc, + ) -> Arc { + Arc::new(FakeSocket { + id, + observed_port, + polled_socket, + }) + } + + fn test_hop(socket: Arc, hop_port: u16) -> UdpHop { + UdpHop { + state: Mutex::new(HopState { + prev_conn: None, + cur_conn: socket, + generation: 0, + last: Instant::now(), + new_hop_port: hop_port, + pending_hop: None, + }), + init_port: 443, + port_range: PortGenerator::new(hop_port), + interval: Duration::from_secs(60), + family_hint: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443), + iface: None, + #[cfg(target_os = "linux")] + so_mark: None, + } + } + + #[test] + fn try_send_rewrites_a_clone_without_mutating_the_caller() { + let observed_port = Arc::new(AtomicU16::new(0)); + let polled_socket = Arc::new(AtomicU16::new(0)); + let hop = + test_hop(fake_socket(1, observed_port.clone(), polled_socket), 8443); + let transmit = Transmit { + destination: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443), + ecn: None, + contents: b"hello", + segment_size: None, + src_ip: None, + }; + + hop.try_send(&transmit).expect("send should succeed"); + + assert_eq!(transmit.destination.port(), 443); + assert_eq!(observed_port.load(Ordering::SeqCst), 8443); + } + + #[test] + fn writable_poller_follows_the_current_socket_generation() { + use futures::task::noop_waker_ref; + + let observed_port = Arc::new(AtomicU16::new(0)); + let polled_socket = Arc::new(AtomicU16::new(0)); + let hop = Arc::new(test_hop( + fake_socket(1, observed_port.clone(), polled_socket.clone()), + 443, + )); + let mut poller = hop.clone().create_io_poller(); + let mut cx = Context::from_waker(noop_waker_ref()); + + assert!(poller.as_mut().poll_writable(&mut cx).is_ready()); + assert_eq!(polled_socket.load(Ordering::SeqCst), 1); + + { + let mut state = hop.lock_state(); + state.cur_conn = fake_socket(2, observed_port, polled_socket.clone()); + state.generation += 1; + } + + assert!(poller.as_mut().poll_writable(&mut cx).is_ready()); + assert_eq!(polled_socket.load(Ordering::SeqCst), 2); + } +} From fbf9c8bf3d031d1b32b058cb7dac83566baebe03 Mon Sep 17 00:00:00 2001 From: MFSGA Date: Wed, 29 Jul 2026 18:14:33 +0800 Subject: [PATCH 15/70] feat(dashboard): add and embed the web control panel Add the React dashboard, embed prebuilt assets in the API binary, document the workflow, and align release and throughput CI. --- .github/dependabot.yaml | 9 +- .github/workflows/ci.yml | 38 +- .github/workflows/proxy-throughput.yml | 20 + Cargo.lock | 36 + clash-bin/Cargo.toml | 4 +- clash-dashboard/.gitignore | 24 + clash-dashboard/README.md | 54 + clash-dashboard/components.json | 25 + clash-dashboard/eslint.config.js | 28 + clash-dashboard/index.html | 28 + clash-dashboard/package-lock.json | 6762 +++++++++++++++++ clash-dashboard/package.json | 47 + clash-dashboard/public/favicon.svg | 1 + clash-dashboard/public/logo.png | Bin 0 -> 40120 bytes clash-dashboard/src/App.tsx | 42 + clash-dashboard/src/assets/hero.png | Bin 0 -> 13057 bytes clash-dashboard/src/assets/logo.png | Bin 0 -> 40120 bytes clash-dashboard/src/components/Layout.tsx | 96 + .../src/components/ProxyGroups.tsx | 305 + .../src/components/ThemeToggle.tsx | 43 + .../src/components/TrafficChart.tsx | 106 + clash-dashboard/src/components/ui/badge.tsx | 42 + clash-dashboard/src/components/ui/button.tsx | 50 + clash-dashboard/src/components/ui/card.tsx | 99 + .../src/components/ui/scroll-area.tsx | 52 + .../src/components/ui/separator.tsx | 23 + clash-dashboard/src/components/ui/table.tsx | 116 + clash-dashboard/src/components/ui/tabs.tsx | 82 + clash-dashboard/src/components/ui/tooltip.tsx | 64 + clash-dashboard/src/hooks/useFlows.ts | 41 + clash-dashboard/src/hooks/useTheme.ts | 35 + clash-dashboard/src/hooks/useTraffic.ts | 51 + clash-dashboard/src/hooks/useWebSocket.ts | 84 + clash-dashboard/src/index.css | 315 + clash-dashboard/src/lib/api.ts | 279 + clash-dashboard/src/lib/settings.ts | 26 + clash-dashboard/src/lib/utils.ts | 6 + clash-dashboard/src/main.tsx | 10 + clash-dashboard/src/pages/Connections.tsx | 204 + clash-dashboard/src/pages/DNS.tsx | 172 + clash-dashboard/src/pages/Flows.tsx | 616 ++ clash-dashboard/src/pages/Logs.tsx | 152 + clash-dashboard/src/pages/Overview.tsx | 419 + clash-dashboard/src/pages/Providers.tsx | 464 ++ clash-dashboard/src/pages/Proxies.tsx | 502 ++ clash-dashboard/src/pages/ProxyList.tsx | 387 + clash-dashboard/src/pages/Rules.tsx | 378 + clash-dashboard/src/pages/Settings.tsx | 171 + clash-dashboard/tsconfig.app.json | 30 + clash-dashboard/tsconfig.json | 13 + clash-dashboard/tsconfig.node.json | 24 + clash-dashboard/vite.config.ts | 20 + clash-lib/Cargo.toml | 8 + clash-lib/build.rs | 106 + clash-lib/src/app/api/embedded_dashboard.rs | 63 + clash-lib/src/app/api/mod.rs | 3 + clash-lib/src/app/api/runner.rs | 9 + docs/20260728-171039-builtin-dashboard.md | 238 + 58 files changed, 13018 insertions(+), 4 deletions(-) create mode 100644 clash-dashboard/.gitignore create mode 100644 clash-dashboard/README.md create mode 100644 clash-dashboard/components.json create mode 100644 clash-dashboard/eslint.config.js create mode 100644 clash-dashboard/index.html create mode 100644 clash-dashboard/package-lock.json create mode 100644 clash-dashboard/package.json create mode 100644 clash-dashboard/public/favicon.svg create mode 100644 clash-dashboard/public/logo.png create mode 100644 clash-dashboard/src/App.tsx create mode 100644 clash-dashboard/src/assets/hero.png create mode 100644 clash-dashboard/src/assets/logo.png create mode 100644 clash-dashboard/src/components/Layout.tsx create mode 100644 clash-dashboard/src/components/ProxyGroups.tsx create mode 100644 clash-dashboard/src/components/ThemeToggle.tsx create mode 100644 clash-dashboard/src/components/TrafficChart.tsx create mode 100644 clash-dashboard/src/components/ui/badge.tsx create mode 100644 clash-dashboard/src/components/ui/button.tsx create mode 100644 clash-dashboard/src/components/ui/card.tsx create mode 100644 clash-dashboard/src/components/ui/scroll-area.tsx create mode 100644 clash-dashboard/src/components/ui/separator.tsx create mode 100644 clash-dashboard/src/components/ui/table.tsx create mode 100644 clash-dashboard/src/components/ui/tabs.tsx create mode 100644 clash-dashboard/src/components/ui/tooltip.tsx create mode 100644 clash-dashboard/src/hooks/useFlows.ts create mode 100644 clash-dashboard/src/hooks/useTheme.ts create mode 100644 clash-dashboard/src/hooks/useTraffic.ts create mode 100644 clash-dashboard/src/hooks/useWebSocket.ts create mode 100644 clash-dashboard/src/index.css create mode 100644 clash-dashboard/src/lib/api.ts create mode 100644 clash-dashboard/src/lib/settings.ts create mode 100644 clash-dashboard/src/lib/utils.ts create mode 100644 clash-dashboard/src/main.tsx create mode 100644 clash-dashboard/src/pages/Connections.tsx create mode 100644 clash-dashboard/src/pages/DNS.tsx create mode 100644 clash-dashboard/src/pages/Flows.tsx create mode 100644 clash-dashboard/src/pages/Logs.tsx create mode 100644 clash-dashboard/src/pages/Overview.tsx create mode 100644 clash-dashboard/src/pages/Providers.tsx create mode 100644 clash-dashboard/src/pages/Proxies.tsx create mode 100644 clash-dashboard/src/pages/ProxyList.tsx create mode 100644 clash-dashboard/src/pages/Rules.tsx create mode 100644 clash-dashboard/src/pages/Settings.tsx create mode 100644 clash-dashboard/tsconfig.app.json create mode 100644 clash-dashboard/tsconfig.json create mode 100644 clash-dashboard/tsconfig.node.json create mode 100644 clash-dashboard/vite.config.ts create mode 100644 clash-lib/build.rs create mode 100644 clash-lib/src/app/api/embedded_dashboard.rs create mode 100644 docs/20260728-171039-builtin-dashboard.md diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 1e332b28..c36c68d6 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -16,4 +16,11 @@ updates: actions-dependencies: patterns: - "*" - + - package-ecosystem: "npm" + directory: "/clash-dashboard" + schedule: + interval: monthly + groups: + npm-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cbe1cb7..1a4d3389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,37 @@ env: # Arm builder https://github.blog/changelog/2024-09-03-github-actions-arm64-linux-and-windows-runners-are-now-generally-available/ jobs: + dashboard: + name: Dashboard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: clash-dashboard/package-lock.json + - name: Install dashboard dependencies + working-directory: clash-dashboard + run: npm ci + - name: Lint dashboard + working-directory: clash-dashboard + run: npm run lint + - name: Build dashboard + working-directory: clash-dashboard + run: npm run build + - name: Mark dashboard as prebuilt + run: touch clash-dashboard/dist/chimera-prebuilt.marker + - name: Upload dashboard build + uses: actions/upload-artifact@v7 + with: + name: dashboard-dist + path: clash-dashboard/dist/ + retention-days: 1 + compile: name: ${{ matrix.release-name || matrix.target || 'Unknown' }} + needs: dashboard runs-on: ${{ matrix.os || 'ubuntu-latest' }} strategy: fail-fast: false @@ -110,6 +139,11 @@ jobs: - uses: actions/checkout@v6 with: submodules: true + - name: Download dashboard build + uses: actions/download-artifact@v8 + with: + name: dashboard-dist + path: clash-dashboard/dist - uses: actions/cache@v5 with: path: | @@ -208,7 +242,7 @@ jobs: uses: actions/upload-artifact@v7 if: ${{ !matrix.no-release }} with: - name: ${{ matrix.release-name || matrix.target }} + name: binary-${{ matrix.release-name || matrix.target }} path: ${{ env.PACKAGE }}-${{ matrix.release-name || matrix.target }}${{ matrix.postfix }} - name: Setup tmate session @@ -235,7 +269,7 @@ jobs: uses: actions/upload-artifact/merge@v7 with: name: binaries - pattern: "*" + pattern: "binary-*" retention-days: 1 - name: Download binaries diff --git a/.github/workflows/proxy-throughput.yml b/.github/workflows/proxy-throughput.yml index 24abf3db..49cfe82c 100644 --- a/.github/workflows/proxy-throughput.yml +++ b/.github/workflows/proxy-throughput.yml @@ -84,6 +84,26 @@ jobs: docker pull p4gefau1t/trojan-go:latest docker pull nicolaka/netshoot:latest + # Protocol servers run in Docker and connect back to the host-side echo + # listeners allocated from this range. Newer hosted-runner images may + # reject bridge traffic in INPUT even though Docker networking is ready. + - name: Allow Docker access to throughput echo servers + run: | + docker_bridge="$(docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.name"}}')" + if [ -z "${docker_bridge}" ]; then + docker_bridge="docker0" + fi + sudo iptables -I INPUT 1 \ + -i "${docker_bridge}" \ + -p tcp \ + --dport 30001:30100 \ + -j ACCEPT + sudo iptables -C INPUT \ + -i "${docker_bridge}" \ + -p tcp \ + --dport 30001:30100 \ + -j ACCEPT + # Download once so parallel clash-rs instances don't race to fetch it - name: Download Country.mmdb run: | diff --git a/Cargo.lock b/Cargo.lock index af188f8a..1a22a55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ dependencies = [ "regex", "ring-compat", "rtnetlink 0.21.0", + "rust-embed", "rustls", "rustls-pemfile", "serde", @@ -3799,6 +3800,41 @@ dependencies = [ "tokio", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/clash-bin/Cargo.toml b/clash-bin/Cargo.toml index 1eedc315..97ccca76 100644 --- a/clash-bin/Cargo.toml +++ b/clash-bin/Cargo.toml @@ -7,7 +7,7 @@ edition = { workspace = true } # authors = { workspace = true } [features] -default = ["standard", "aws-lc-rs"] +default = ["standard", "aws-lc-rs", "dashboard"] standard = [ "shadowsocks", "trojan", @@ -46,6 +46,8 @@ ring = ["clash-lib/ring"] tracing = ["clash-lib/tracing"] extended-health-check = ["clash-lib/extended-health-check"] +dashboard = ["clash-lib/dashboard"] + perf = ["plus"] [dependencies] diff --git a/clash-dashboard/.gitignore b/clash-dashboard/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/clash-dashboard/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/clash-dashboard/README.md b/clash-dashboard/README.md new file mode 100644 index 00000000..3ef32274 --- /dev/null +++ b/clash-dashboard/README.md @@ -0,0 +1,54 @@ +# Clash RS Dashboard + +A web dashboard for [clash-rs](https://github.com/watfaq/clash-rs) proxy software. + +## Stack + +- **Vite** + **React 19** + **TypeScript** +- **Tailwind CSS v4** for styling +- **shadcn/ui** for UI components +- **React Router v7** for client-side routing +- **TanStack Query v5** for REST data fetching +- **uPlot** for real-time traffic charts +- **lucide-react** for icons + +## Development + +```bash +npm install +npm run dev +``` + +The dashboard connects to the clash-rs API at `window.location.origin` by default. To develop against a local clash-rs instance: + +1. Open Settings page in the dashboard +2. Set the API URL to your clash-rs address (e.g., `http://localhost:9090`) +3. Set the secret if configured + +Or set `localStorage['clash-api-url']` before loading the page. + +## Build + +```bash +npm run build +``` + +Output goes to `dist/`. This is a static SPA — no server-side rendering. + +Run the same frontend quality gate used by CI before submitting changes: + +```bash +npm ci +npm run lint +npm run build +``` + +## Embedding in the Binary + +The dashboard is served by clash-rs when the `dashboard` feature flag is enabled. The built `dist/` folder is embedded into the binary at compile time and served under `/ui/`. + +The app uses `base: './'` in Vite config to support being served from any path prefix. + +Normal Cargo builds install and build the dashboard automatically. CI builds it +once, adds `dist/chimera-prebuilt.marker`, and shares that directory with every Rust +target so cross-compilation containers do not need Node.js. diff --git a/clash-dashboard/components.json b/clash-dashboard/components.json new file mode 100644 index 00000000..15addee8 --- /dev/null +++ b/clash-dashboard/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/clash-dashboard/eslint.config.js b/clash-dashboard/eslint.config.js new file mode 100644 index 00000000..e0996a11 --- /dev/null +++ b/clash-dashboard/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + rules: { + // WebSocket-driven views intentionally accumulate snapshots in effects. + 'react-hooks/set-state-in-effect': 'off', + // shadcn-style component modules export their reusable variants as well. + 'react-refresh/only-export-components': 'off', + }, + }, +]) diff --git a/clash-dashboard/index.html b/clash-dashboard/index.html new file mode 100644 index 00000000..56f9d40f --- /dev/null +++ b/clash-dashboard/index.html @@ -0,0 +1,28 @@ + + + + + + + Clash RS Dashboard + + + + +
+ + + diff --git a/clash-dashboard/package-lock.json b/clash-dashboard/package-lock.json new file mode 100644 index 00000000..c5aa60b3 --- /dev/null +++ b/clash-dashboard/package-lock.json @@ -0,0 +1,6762 @@ +{ + "name": "clash-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clash-dashboard", + "version": "0.0.0", + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/geist": "^5.2.9", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", + "@tailwindcss/forms": "^0.5.11", + "@tanstack/react-query": "^5.101.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.22.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "shadcn": "^4.12.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^26.0.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "tailwindcss": "^4.2.4", + "tslib": "^2.8.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.63.0.tgz", + "integrity": "sha512-jjkmzIRu19uH78AjFInqfcALehbDCZZ7M09hurVawyqNxtOXEg2LR73L59y4QnzfYDEzjbhVzGAd2uDHu0D1aQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.4", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "license": "MIT", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/sankey": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/sankey/-/sankey-0.99.0.tgz", + "integrity": "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-sankey": "^0.11.2", + "@types/d3-shape": "^3.1.6", + "d3-sankey": "^0.12.3", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/d3-time-format/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eciesjs": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", + "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.5", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", + "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.12.0.tgz", + "integrity": "sha512-o781ieQziCnXH2FKsEqxp1fnbHdbgAPO9inTSPeZ59hQfsZXuMGp3ul8oFSV5KQS4nbUK9b+DrDE6C7OvfKKQQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "kleur": "^4.1.5", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "tailwind-merge": "^3.0.1", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "undici": "^7.27.2", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + }, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-debounce": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz", + "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", + "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/clash-dashboard/package.json b/clash-dashboard/package.json new file mode 100644 index 00000000..36e64536 --- /dev/null +++ b/clash-dashboard/package.json @@ -0,0 +1,47 @@ +{ + "name": "clash-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/geist": "^5.2.9", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", + "@tailwindcss/forms": "^0.5.11", + "@tanstack/react-query": "^5.101.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.22.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "shadcn": "^4.12.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^26.0.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "tailwindcss": "^4.2.4", + "tslib": "^2.8.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.2" + } +} diff --git a/clash-dashboard/public/favicon.svg b/clash-dashboard/public/favicon.svg new file mode 100644 index 00000000..0906f9ca --- /dev/null +++ b/clash-dashboard/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/clash-dashboard/public/logo.png b/clash-dashboard/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a98104880b8d393d514742d799aceb88d7525c11 GIT binary patch literal 40120 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 literal 0 HcmV?d00001 diff --git a/clash-dashboard/src/App.tsx b/clash-dashboard/src/App.tsx new file mode 100644 index 00000000..657f096c --- /dev/null +++ b/clash-dashboard/src/App.tsx @@ -0,0 +1,42 @@ +import { HashRouter as BrowserRouter, Routes, Route } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Layout } from './components/Layout'; +import { Overview } from './pages/Overview'; +import { ProxyList } from './pages/ProxyList'; +import { Connections } from './pages/Connections'; +import { Flows } from './pages/Flows'; +import { Rules } from './pages/Rules'; +import { Logs } from './pages/Logs'; +import { DNS } from './pages/DNS'; +import { Settings } from './pages/Settings'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 5000, + }, + }, +}); + +export default function App() { + return ( + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/clash-dashboard/src/assets/hero.png b/clash-dashboard/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/clash-dashboard/src/assets/logo.png b/clash-dashboard/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a98104880b8d393d514742d799aceb88d7525c11 GIT binary patch literal 40120 zcmeFYbzfEA(=fad1*N~zZ4lC3hZI4ikxoHcIu1y45D@_ZC8bL`4~K3=K)Sm@x}=-) zEPmG;cwWH$2cHe>wPwxCnx5GqYASO0I8-e9d#syVSJ3SFlBS9hA%N#hFQqZa^z^YT3WJx(z?1qfZq z|Hp*?ACgeG4nc^pd`;UR^=!2gvgXTD^_5zymBy_=; z&8SVWesn>lex-Kd=nsA60{Ke)ot?ELr6_sc1f{4>h6H6MRHwP1yd|pBj#sb8@uu5` zN3X}Su+5w=shBK!)U--ZNw9Fzl4rX4axV$(qT5T5)MhFe;@t{C@g}e>ZwOM8%T^FD z&ifl0>w}Eqid#{a<<$PGE?`_lqCi@!$r4lGlCPe>xj|UjX6G`+PLMSD4kc_y7C*aG zk&xmZHf-rUSa?$3r!dxvT;cDy?lF5;}0g-b!!=KK(p{zW=JsxN-n zMdWGxkc&t(mj%ws7sI|T3t#-jFbkd}F_psccgL`zH}Sn|6<#TVX~UbYdbEO6n2;KT z4?%VY8Jfx~iupb)DO}ku+3=>Axb6*~d|kmMCqLhbV#_pJ6mi-4tYBLA>ifwn&ZO3M z^(W8-9Z=<(kA@k^e`2F*)p-q+Runr8Sz_5bvqRSnza|TY&D-PF*!#tEEFiIGs?w)d z-JP25L56tXz~pSrl6F*^OjXPuQsPPVCk9I9itUE^rE-E{BJ0R|;wR}HWbo-gI)guE z)p`kx5Tbez*p52G44Q6|URb zg#m7#P;G^^8b?ZX`iKN1U)GE-Pl+{*v;vY;%L)8Pkh zzM(w)$vR@*-u6Hw(f5R@Icsw7U%g!>F$UrQ7(o7raQItsEnQX8G4-Hh&43poNNV@O z+Q~iZebL102+QWI&NZTx`%uhtOh{^eOe3iBM?)NI5hLzazI1H!JGFpGbDI-U{W}q4 z%~^~Kb|=Gl&;Sph?E>|U6Rawf_vFyiRqVW?d?B8oq#iau`Ov8XE13hs5(`=<1dR55 zwW3h?z*B&wlnJL$zE*w3k^DF6yK*sxmF>VFl~WZ~il2}-SbHl4JFIbqY+&u+*Hle zV{|T+!6~$lb-vz{hBhO>?OfaazzL&1&(`&0iL;X9TBtC9{VItlD@1jU{+_)9Z3baM zh|RI6gQ1sxJaNWTe^52_9zyk_GlPFO@#%tHf4PZxQ5i@QWb!}Vtl_|S% z<}P+si>1K(Pev)qC4EfqT@fM}P(eBdlwPJ=cV;=;oNCrUKS2o~O>WO^-~A$_YbXZak%r23o+(U2z z{g_DAt>bP^Ofv50C|Z%R!oVvoY@40k-gR&b_O&C&D#Hf=s(z>y^pviyj=LP%)*5E8 z!w3*UwX|cqWbfaH)z^>ilp*==w`2ogH6K@W{rotDniCBayE0KwO?n&xRX;jAyW~Jv z4}hn?1JL@obe{DWudZsN4sAtL_OXwC0Txx)c5sU%j{4|O#E z3RzD+P{`D5hQhZtlZIL*0qG6-Y6X48mTTJOu7#7PA&>pj|C_L1P1ROaP0Gz3T`o(I z1*AU!XZ_GE;I58JG3qV(t-C_VpHo%FH?+C&?(pL^4dq=5T71aFI_bp?ifvLUHP9E) zzUHR))ZS6${^!0sQWR8B%jdtQ7VaXV^-&2%od-e?H&wKJXocq!`?**3!HPi`m;lW$ zjNdr>ip;Iz`3jx2FQJ#?R4y;48V3bc2dv0b?nzw_K+sF%!|r}|E2}uZd^>qK8AP>o zI+3|EXI&qB_yGz8;y^XOt>A9S!dIUh8%8>)2U*7qJA_ntXVT8SqMpV?V98-Z{6#L= zFw~LdhVhCJZ423V<(yu@M^lSPePKZoYbAb23Ns2fb|br|N8R!*2Qv&8GTO9pmJ@Ag zqN1B3msEWaLoD}g9*}|e@E^Y4V;DR>=4NdaL#AVD*ndr z32(ZJn1$!C5mAYOeRBkkj=AG|$PmC441Nf)+cw;m(AIOL&XmduIFQR$g1NTE4mWwkSmXIH$#H&F91nu$NhLa{v07dBUd$7YK7_2c0&Bf8cP1ulC@Q3bz z_j4+a#^m-;x*|BCk71}SKZrl;2KqRijK98h;QWm{oeR8h_`wTa^c0~ z3GvFWZz5f5n4w}@0+Gu)pX*r(^6_8{s+X9MwPwCyN}{T2UvB((?GP>^wL+-=qTbhc zRw8MX9fpfI1jkfZ0}ujfsRn(a1a~0Ok>TBqt&`*TmonHGPMlPbl)D_^|#19cSbZDu_ z{2$)KT{y)>lDby)b_rY7#W-#Kf6w=~L+MS{5CX=cQ$WnO z5i&{g{^CxHs6cQs=6`6Z5vxikX9_h!AdBKXTvp7M8hp5vgU=zUtW2Z1fX~hKDb$$I zC@>P63O_=LQTZVWni;zPrQCz~d*4>$$y{Up=ReRN`bbI^T(drF?9`c(jVV**&qR7? zqCDrT>!VHjFBcqS(d+C|WUCK4XvO z2=D*Vo~z-R;OYmNtQo3onUwl-Z=QPD0hZ$fi>@{VxMRvxTOLPFdl#aTF$lALM7192 zWJ{5L%RWk^%v&Frh(t<(_Ib)3h%uoz=jR)=iQ^+0IFMleJ5-QKHNetxgI<*kST@8b zQXFByX-E(ovmTtk-PNV<2eu1zTN*=o!A+kX%Y+8TmLAgHy>-MU!J%zk6Ph+YtOw)5Kc_c)v8gZ zDMY9;qC`~A??T_i9JWl1P0F|y;M}B;9VR|hh9AaOyWjbD<_RVmN+%UhnfrMI>3qU4 z0H(xFK0}StY=Iu)f9J|-qH5goQxht?2Tsi904Fl*?Gr+YWE%$rCnz?khMx*rFjZLD zJd<&;<4su1qlX5>N;|4@CAo!(%Jg~xFQO**thSQ{ zc0c_!fO{UU%GALWWw?tAd6D1x1~I#Lx*B=rJ_qEM%cg&i-CU@AKa z8^8r3SuQg;!NWQ^!cI{kcyNy)1Td?jE{>j7wWrs0fEnV?Np;wp`)OhurU)R2fqSM( zdleDE9F?C56cr-9cXiSj`12=*J67wuHE<3aO7HB%cwngurx5g|#XRbS0P7SF%lA#v zIy-`!AL1@)%ido1FnuS&4A4t2flbYNV+W?t(gC^WDFT`8oe`U%2@g{^`?t#LLI?CJd$iOb;o=pdjRfxRc>I{IEC*YSqucm=M|Zn>g*!#bN$*0vNC>Dozga z#9c$s+{obWTs`x|PnSm&5AY#8bAT9c;vaK`TgLJK-m2W0rk>%WAfdgRM@$7#NOS7Y zQHDoGI8PBm3t~XU_fi*{8Xs3D?R>NL;fJh!Y;DiKA%Ba-`(Qwv1@+VQj;dur-MkR+G5EoZ|i#@z3 zs}<#FTvpu60gm7XMd^1WM zO4lE?lsL@`9NC*b5#goj|3B7K&0o~9cW=35@NyForQDkkv4qlfVioMog(bvDxZ(?M z#S-tgjliBH(NwjhIkJp{q7DeYQP)aW>kH?qt2>YkA-#Z zVt;b-)_FiL!eo3d2|h|c4hF}c3_wy=+S)GQb?i#=?dwwe-va5EP%5iyc}OOb!rP&x-)q&+%74gE+C z^EtgWlup?CV*gu)F7i)+Bm{AkPxpdzPwDT65Zp1O!iOqKG>Zrb+dc&^-QJnggG<7X zC-nEHw=IFq4a>klmYcx05!gX-gOF6cdgszW!o)UzhwmNe zh+$|;)QNkWZ~`#64;VBm(&?|k{lCf9P`9AV>{Dh_ud!PfnQ{Tio`B>q?ReK#tEECV zniE23pm0p^^d>N4%)R73Vzo>PKM^IIMk_%aX8-4ZyIl*}YF%pUnhm$MPk^aT4x)st zyJwWV*5T~2?6>Y3apwx-GNAjH`0F$Pe;N?}csM#gV;E`osjvxx!dsUsX=~VLruTqj z@E`_+o0_tV_`u@&|0fSpZ#m=L&=!jU;)bMn%IO#grZ3?PpH$jH+d^;eBkz|(HBF6J zgO#xwoCpC(<92}w!eQDhhgtVQ+D%rt(PFXa8y_T#9WV&^0?H0l5~S3y%Ab*sp_cnf zOdBmL%9mV(NlKYnEiKm2Bc)1xY8WZC1Sv7_Qi!@Kf6ud)mA9w_Cdd>p=8HnMAe>du zC2zB0&1wSOYEQgMh;&>DPh!p;xn zrS)$5Cxa6adsMmDQnQ0ly2|ZRVJ)&JH;h7v=G%EO#Fh`EEb2E zU)qQtQhN>fAVEpJ+~5!L5lg#g50h|dnYmKb#3?TDv3O};fMW;OA%p!K(-aHpqNWG` zGu?d%AyPn1Xi+0M6GIE2{WjXHb1`~fA>jZ}ZImBl+seEgZ6-hXe83ur&yS6ek$;^Y zNZtQ#@93o(yY?s5_FQK5%)s;vu$jkoc;lOfvxqU-?d$f(Yu!(Cmt2S6f!)Gj_m}MR z?LYY*f*K$?0K21mV_vGX#jgTYNPq5xLqt+m7Xv6)WemNQ?t$;>Axr-{YuZe0cAUcc z?#!BDLG`5bU*%LgmZ{5c1(2V&$ti7L2T&g;P8Ge3jrgj6P2vy3}xjm zXPauliPlN+K|*;1_pym7l!rhlX~Qq#1>=O>I|{{uM0vbmee<-S&b(AlA^H*%VV^{0 zHUdhy`!7{Ha@)U$cT^2RCf;P0yji)#MWS~hsTy@8%nOF(js~oQy#O0@jk>}CA~n0@ z5M=+yLIIpxs3znkbMd;&x~H_L=n7s6&p+xKz`!mnuWrO72h!pu4kt1RV5`RWs2;LFo zR`M-@ICiQH58`}Q#T1zmVl_;2@c<+%=EK=YzWs?rcG&%jFf3>-zBi`5kKybP1LRP@ z$NnPuLfB7F7VZE%=|&aouar!k;S!`w9MHpl=ehiMkK zL`;uGcV?gzhyb#ZD3;odLC|IDEZAxZ>B?dwUIFSu76J8YQpMUVaeON_O-!VW8uGka zI}wSKb_W+b+DW6PJk?bKqc)Cj?$6K6{5`$=jWw2~U#wlM-M!+nU(XsF%HCaTSfcif zB&@A^Zm}8;hsP*nyVSCGm$P?En_}u~e+}D!bH=m8;;K|P&hFWhr~Dye`b12ZR_t#q z1t+@y0@K7WDfmNUp=rR$DC?+telo}SlD1suR`n$&U1Z0~>1E(gI{%hEI82uLF2Y{a zc`4jXmHu?{^g@gy6UAQx%sst=M?+N=XZ1QeQR~s}V>XKCOh#VIZI!XID%az|K0Mm_ z*}Y>Q#vSe7;lA{)PfGkn5Zr3c8v+VdU0*FkzP(C@d0zB{RHjzh%67c-u}|4;9k2HA z6$D&`@Dcxw+6+;16)Q(L?dG~`*{+z181WQiC)pL-XRO9Ky?-A+>p-4@3yqM5wY`*+ zOY>b-GVZVkh&A2#2DFY>a>tm10_RJ9o@9^ZY_~xcC%W2SE~Cl&*u9wEbs+RD`;Ltm z1iVvX;**p_pS5xwyUlRIRF*}3j1F6F($`7ygiauez5{8852DQ#KMYy^tXLT5%}mbH zgzvtybtDEw%a6$E9S|@^{YY}`Zgkqtel_iuNi0wklJTR%%F&7s@pAy8P!u#uq){>F zV+2HzDuk%pi;tK#-XsRn(qi{m_Wiww5b|uCYxF){Ek6(1$1kmyhbO;`>y2RwCYEUK z1>*Ab?Srq#hW#PfnqR0^!Qa1Krry;k|8y%SCn-1$G*Gg#vf_^(m#hCJ6h<&~hIN8X zXgtmiySEvP)gqPiGMf2~;W8$9<}LPZCSmYpWu2-DmtVAyVIq5XR+gEgSlVID_q%HG zoeW0KzKZj29I&`?;vmYl3k5$H2Ki%S|MR6!t>n}7rI(>>yQCn+J^H-?r-Q-IyZilG zpAJHKh1$_fa#2l)#@b#+817~ORtpi1i-ikAf&z&wHqTcV**G>_qsHA!hlZ);{m&7Y zP@Oq%Lh@X}@AcRVVE-)%7o7BEtO7QzA-uOP47I>|ru=$_lO5BE^!eAdp7G)ocm}9$ zd}O=3D5lqvm28xw$J__RJZ!&!cDY@_1e(q_@ZS54%DqfI4Z=O0aaVTj9tDA_RN&_m z_cYz89e&6SDe_=oc8iqu?&i{X{!jb9b<$glLS^_e)iLbV`&kdIM=b=bkXqEFUEIV^ zZLT&@Rbkj5oVtF#o9YO<3-}H;{I$qL@|4VP7C0IlMq{UQzP}yT92uc!j%MOa^4m(q zY2l++Bi09@TOws8-v%N6^87)DF#nP0Z)>7nz4SF(oMA!N>QH*zapm{XA!b+*(oW@$ z6RS057KXJhQoz%OpAjZ&)v-$iE0!Epi;QWXVGZ75kA$iTi$A~3&MoSzL6J6u|122zZVShG^NSGd=J81NU^p^Q{Z zpi;KDm3AtblClxHV&A`W*tagORmAh4qL$>_gdL-bpvKfT#x_Ajz4bGGDBRM9y^^~- zyBQ~MtV?Tp1e4Hkd}at#r-1Gtb-%QkmNwQ*Y)d|Zg$;7_Sbk8B@Qp_i<$+*?KPI+q zX5`tLAL}EH?-Fq3EiE17DmHZYk2$aLWq+>0)i}sO<)eP&51MmtTW^^^R$ETTu%@|J z%PWxhKjXY^Q2Qjw$d&&3k3C(a+$}3;^y0?H$D6N@tqgoL(fZ%Iw(acm_fp!D$DV`r4Ow7Fu0tVthElx*S5No}XFBl%l89qT#f zz$0xk*WqRPP6k-8!>G+@f&Ee|eN!yv4MuvOx!&Bw#P^gGdWp()Zn>#MPV$udYdqqq zcmhbGyyt+v52i?7@l@0O{)iQv?(54PA^LX{0ciDnP1abPAZf8w?gS;K==?|I>iJ(f zWxDT6J&TU%UL_|Vi=vMETFSKmV}KEHK8xp2jm(wqC8$pclB%;R7`4gdGpPn4jA~=m zV_49e6$P`?#elURi59SGa^GDXjt+e9;TvdU{W>0<1dbnO@^Rmghr ziKOBPh#E{$NDm27LB55WHp9#Lc8RvlRUA6H5>7#DQ?`5m(V9~Of&`ON5)%_>jFp(# zDnnas_C@X9%HvEkcJ30TfDCKBd}HXygLP)RLU-NT?F*LFd`^c3O5@!i%Mr6da?RS$ z)|3?5%s@MNcCsXjwA3_X#SX@#>`}}8M5SwoBnF;3LMX3(G9O+uDN@D-{JVPumQ=?Q z^4F1v0vzX>#h|_E6nnR7TEMr~r&!y-NKf3^)Y^iFORG`EJ8Y7F;q4cY)MEhLox=D? zR&C`=ts%Ibmssp)a+%zLXmt^CyJ?Sw=gM3c^mJ5|^hsgGIy7;dh1t`++>)Tx_HZAZ z!wUBeTYoh1mI%GPys?+BjnAy% zR)s+p`vC^DE?udgl=@1b5+?p8r{FjkODZ1ltdPxfkOltK;JeozcBzAd!$aD?>}=g^ z9g&T3D#BuYbis`5&SZ3ul#aH}0id_LWMpidt?C1ol>e;?5GP-uw9XIKJDN`2BRihL z{!FVR3wy5qH7p)^TaE9PoH-1VWsPCtja6+OgIohRksTpEqEC~xa-&mI0%RL9^f;q~ zsy_3x1i?&XV=>(;@o#+G>^xc>HmdYaTs|{HQhNvI2S?@wwY_bX(1kQ+I^QsHe(lDW zbrRTH`pX;D208d=AzI0kUuUnnme*aUTW^aQ>ECNNlw1pKO}NJItUtgd%|t zb2YdYTC0-lR@y=V`sW%Mx&VjjirOM=)H?fZ$nX0-L8-sL{Pa{^MhC4bdAxlj)44-a zaS(P7;{JX{xmCZEQ}#3y!GP!1_uR%QWOzxZUwQGEYuux7X&RJ9QqZGwLjSU$140p%|S z%HPj@<^S5nbKARVkM1#-I1i8KcEamw-+`Yd*bo%O&zIm}`yQYygZ;B@0J5Gv(u5@~ z&&}g@b!yO~=cc#aG47pZMT%9HuP-KpKzuH$H#RqSh##Vo!%TmN8s6BDcetrbPj2`( zM`*aRKXN&MJU$K&ZaU6vtqTtD2W>eX@dO*&_p)BTnSl!*tf7iRwY(msY+F1oV}#+C zSr_m3ii(Y;w4OVaLUSGh8!o%~hg)}`bll2Be!f_TEhkYLvOzs3Kf60)B?n`ul+P$(^R{`QTG2Qyt3Y_%(4Pmoy~NNs4i3LrFEa}wS>35^sx7`d>C z9`1Cu1hWsSf;y=QDniv|aBbJ`7z4@KZ8JT1t?0Ga`G4Z+Pd>IT)-^8a+#G(J@Twn` zJ|+aEhMpd$%n&j_ZeI_)PO~UfB||n~psR9x?A@|srT+C$vrqjq!tcsGQ5aC!i)>Rj z%fz?Jy%{xwaS0no{LRd38qXqpml_F2)+){QZmRvSBWA1OIhsKmc<2aTmo-eg@`jt^ zEtb@auI$YorI&CVj;NItY`TVa^Ma0&&vt^B+5YDdv+d%>5cJHX)X@kA?W6DJhP^2tEh_CXtpK zzCA-XY)5QtmDJY}a8-z$?3M9>;vC|ar6q5<;eRQ298IyL*er8~*=|iBKXY2z^ z#;K;;K&X#<2fA1o+Ta^oA5$kUw_0e!ykPrrcVz7IgXAws5HxV6h_&>E6aUuwHFsCu5dcIN616p ztf7wyDcwIPZX^w|#r&?1#|!E%(1pgnGrK0sToCjmTb=RK^LqdTiV$;WOU~9_E%`i|+P)thbmU%Mwr~@NJ3iDC>wqe{TdhHom`_$B~#@=~% zH`~+YsM$r64M_<4#i)@zS<8-(AY#XO_$Y<5x0i3nEl$(rLY#0eb8ZuDiags1Hb#ji zYI;e435JqdPfjedKrAW5QHyRYObN$!y*LK*iu_38LvN%^1?&M7#Xrg7M z4;Q3~?sr1eHr|DavOG`>PN-ssufzL5YS1MqpLckJ?dwMy6b0RBc6XE7gAKFCA z(Rp{S-p}7R2hl*yWYEmjlUVhXvb%XImgME5RE4kS;`$wC+?OR{>25Bzm=$y0gej;3 zfC`19HsIu4mFo3JpIY#w!~uyYTqU~{*}gBITHgw!8WxVe@$>C8bPAnrl8ye9 zU#z8so+l(Z_@s6IxFVP}%VNKC;)e>740^J88C9}8 zb5F=nRHMxd@*SzZle@dx%bh^W?DLi0Iq(1`^z3Xb zt83jRc1;j9(v= zgg@vjCmJkio7^56=TRxLP7V=k5FkO ztoqN$Xzj&Q2>}PR?{c^XlXv`5X_wi9uLrAfu0anK$NB)H zOJPK>uaH5~Zy6mbU6g<{gy(@tB967G#8_iVJEtBnIKZq!apPa@V^f7fz1Z(a6%#Ob zf7V@F8;BKjXl1^UpGDZ0D{rz|(RKyLq|?p(TZ zad0eEg@647qUy3q3r;jlr&3+4Oepj0T(Z`XQ}4ckQI`ZMfZ7f0+z{a2j}(gW<{FDfN$ zH%j1OfovByzaR{4M JVB;z5yz5i%kSCdN$l-@|F!u_Zwv~mCJrL+tyNpzTK(0h zY={g^Fz769!_CyNRw>q>zHOry6|d&hOC<4U2Nmm_^VlmBLLGlrV1+4>=FSd5;N(SKX3}{`jWeFOynFNY zNj}h~`c-Wgq&W{w8K}G62Sk6VjTNibOx96+b#6Qo(#cXNckUeojJ4&;-!2-10McK0 z2wvcON^P=7X7jJ)Q(k283FI5^cK|cZkz~+mgcN!NlYI(8JdDG_^K&izp~GjZruN0X zp_1;5?dG^(obyFnHVGAU&q;76NK-Mts!H1;trqzoTS5&15d=NWTvxxVh;Ucr)Lhj* zEJBNL6~Z@mRRwZ zjMrIaV%tK4LDS4*^S6E+k`uk2>Gi)Jrq7ykOa+a8kceL*G+L|@072B3A=}gM7!$GP zRs7Yg;}mGgGzIxN|5uABuh2Hrrq$|ypD-a4P1iyfUIQEtyUf6K3O=a5yo@Q=6*wdQ z0{vHTcm>;KPi3WBnBt-pU;(ZCS{JTu`8vr(J6=mk59<2#7fnt@N7g_4Mq&+=fJRbz zKj`dT@HQt(PIP}mh<4z$^tGwCc~TAFx=t1_MgOvOBhz`_0G-glKqqeu#z5$FuL zJNPO0k+ZSOHGfT?&p6{F_LNumPzQRouGFNPU>x1*2U0VeQUG7%wWq1Wg}AgFz?I;BHfQdz*rb;r8lV7aPGvw4!HUtcV=Cq&bkdLjzL5d%%gKt+& zn4F=9XcVgB09q~|N&KDx9TUMp(VAeHc$;nvUBR#s4go`E$CdiUmKn{!wOS<@$D3`a zPjtW*?Qa*63H|j!K7j=hcqHSX>H7M(zANF-vjJ&1;xsN|=Y3G7+ymM5OMf}^uljBJ z`plk{w7a4Zf)-}UOb=EXapomPCaj^V$4_<0OpDMUvmbKQ$TwU~0VnNM5k>R4v~8JZ zw`lX^)ZvFw19>G&k*23{4~mD1iGO#|OtNYg6ST3caJAWq; zf>hJ?G1K180k{Qfe#k(pWQ@%09#%%sQZE3%hNNJNDHJG7P=Bkad#6Gxj5;Xxu1+ry zKI=Y(%$l0l;D&_&(1Nxa!>mG*TqgMjpnMC`WXmo;K)E+af*Z3;3zug~0pJjxsW}a^ zgsRNNjr2d0PJCn~+XHe}^EF8Kt+KFlD zlFmTpjE*V)m=s^JR2S{Nc*4hmNa z;ajhkjU#pVXodWj_7|8=@51oVj>fRUe@_}tRu}%bgx9x# zKAr3UWOq-4A-*ccL_EPmo2{1>Y`xo6Rh*7d^HHX%fnx2>f_>pANvZ%+x5w@lS@LQM zE~au|8Y&`Qk1Faakhp*e1d@yBZ;D+KW%Y$wf~4_ESHHoy18x^7n_`Icz>A+wJUx)% z9b$P=jeJ%yL&xe#8!alY+gT10$9NfU_=B*JC%CHa=|7H&LV7LvI`J2%)1xlTCfFw{2 zs+X4~vQ}M8VmZcy6>irU)Z7y}8$ z-i}8p=ru=w+n1UTDYPq@IEEoZ2*2f=Be%xIraG$pfJ4)^MPR&U8qu4uLZ)G-s`G4W zbkW64K>z@1lXiA+wlMdz&)zOQtCO|5i@=5Du1oIu%P=Fn^i^UB!uryc{|^;jj50;N zXb^(xvMbQdHTyrhILI$Bx2b4k8#}tP$XJ@)tDjrmZ@82Um+^{trH<6S>f0wbHZCB+ zY;+^M55@o6A}_g_e}^JIfQc#C)s zM2r|r#FFU$#%u9p&kc3S?!q^z*F@ERdf8#1uTSEWGwbj^UT}zsF^xsNQcO4D?C+^q z%`rM?eCv9Sa7I*A2)$#aTI_|zh*EBZLr%`k`>848`^`6V>yCa&o=~1*iDtY;TY8eq zsbLS4+@X-T=zs5f&+25hTTt3N-bzoZwP5!$-t$hvx#~dl+aI}?9ZhQigePQ1Aw2S? zl^{{M3_q25uW{XcOeFgYB7gq+{hJDzHDogDYjqY=aUHFzgu#=634K{!w%SUm&rUY$ z(esxr4yjIUE1(7GzA%^@(cECeijt&|YV^^r{dFxgM|>@I2&oZw)FtaI*f!# zvoacJ`2{E#tH$f0*%o>yD+8Mr4~}OhuTbooM&AJ!TbQD))$nq%wyRk)pOsL6xzP1UAEabTQ9x6lhjplu>vK} z7B8Fk%9xX>qHMbQ>mUSGwxk;NzM*Fj3NaVND7B`qjJlB%X{y?&4RR7L+SV{g$!4)g z(>2bKzL-lY)x1J_V_~1*5j31_ZmCB6UXf~E>c=)wO4DfGMQ?j&rkOn^(nKY49)F|( z(I+l`ORYAqY#;h{Xy>Y~p0V~7G8nr)myE>U=GxHlN5XSuws$8OX<_1!RVN8+ zVMR#jR31w+e#?%#kj_|)uci2L#iz}Gtjdf3{IAzB>1WMwm_BX&bAhPZ*XX~D)m~t0 zRwlAog7HX}iXch^s>o7d@`?AeSWR3wdvOum-JSJv;-}!eOH)p_$g)ja_PWoXHrl%F zg^X?jiR*3L4l4_h0fXxCd+8d@EO|%htH8l|kI(jynrxM>QEyBG!7Tlwl1Eek?hnPf zA{K!jyHlke6(iT!J92NDhHnDqtc(qd>yl!9?P$NV%0?FY;kV>WJ2ow@ZYdah9&Kd! zylPg=h4MCq=)Zi9XgTE?DUaej78|BeG`2BWi+Onh*p^f(a`bB$S0LATvSaM$k;0W=21ZHK z(!gRjf!OhOSU-nW7)G(rHD(@a-b+fc`Nrp5TDfpiy-sZ(iN64*RpzP^SymJQf~lP= z^l2&T4H)-9I(Q!Bw)WhD-T?cs+z5WR)24FHb-Gu`0%h#IG`VQwJxU2ax01TPs>)ID zmyZ(0B4O>0FDOiSh~Hu$jCEwcrI!;t;^HOpw7YyMQhbIs#^z#c(q*nhNG0vR6zM32 z&gFfd1I*K#YFlahPQIL|19~E;_U;#6divSO(+XvQZ>5z>5| zM1xu4qXE>)Ea^o6dB2i2jhDea^D3s*j*@!2$aNY=oV!;-h7#+&eZBqt@YL+68&wXffek?^62j1&X9iJ0s zgth6FG+%U~Z@w8gR47djDS|GK+Qgn1(V*Qi;Rv?Hoao@~7dIs?GBE{Af;cu}ClPF? zue502>{%iGU7vmHwUk{G4r^WXKsQ#Sw;L}{x?bc+zXXF8I0Jqg{r@C4%{VkzV~(rs zbQ#$(yoMF(;BftxjqUc=MVh7CN6-8ytTlwMHoLi2j4tYl(zo$?QasNS1NHUpACct_ zd#>F4RB<(Rw&AzMRj{m5!l*lvr29N0o_BnG-7I|^>BXXH6JK3>tclEtB=sda|IZ$c8~bLjKUzcj@14lUHxpIR5?DPyl)MU*&U-UtzUK8omLfp|AhXTw*mMn$?08l zVf>JBl;8P(pU|&$wkFHh@9SqYKi7yYTAP5<>$K!LHa82vC3%Q>8~xfHo6XK_K1`$Y zRe^_wIoHq9d-u;uIxDLBf69`^P`;eKe5#3VkyYDn48mGaw&f4na>0a~+j%wod!sBc56bP6h6$nq z6Sm19`h}9juy^W@F}t)XU%77#?Aizf9ZPZ_dVY<%N!GCTuAq+NMd7v+DR0H z{L}*808GD1un-i><%h5%4A0lNO0<&w1n5d6hWX^@114*&WrR8?WxeYB3vw@yqjTwD z;<5Ml7R4mf3H;C!zIeojME=Z=MzAgsjPj-r26Dp14xb;JZPfbM*}M#AuCA`ek69b_ zaPifg$?*4f&r=_qY-`t{tJ~(AcRi&Y)3f>zATDL(ReIyU@%lzVid&%xq-+4Q;grJ4uQu^OYZ((Els|?Uj(r=l2p$mhOD@)At(|Xq<$9Ugl9NRO8r41(& zx?-es;M0Wxq@-PX9h#i-CfM?7JAB9J(>!($9^&`EW}+9_Ef;mHFOJ`op5~M_Ri82Z z^1j(w%$3QVh@~#A-5hUP>q{zFJ~;{npJs5rzQ$zD_!n&rG58V4ybGoCJ^!fC@3(OB zG~ld8fv_}ENNLtFdF)i0D!TLN8}{zSxhVFDMW629m~~-#9elI zb)}LK;aH`Qe0l~MYEHs5p^}rP$c8q;mShGCkDgWTPJRF-#KJjzKlGd9+-1sg ze<7T?B{rCItN%;>{@=RJj~C5H>pX)jG0rCQbH-P{8~5C}yp}b9b=E(UJAFx2b%D+Q z>6dCI253h0DSdrrc{m)6lzEadv@Gd7eta?#bxY}6D|A?C6DKT)SBYsjMb6=>GLojY zj@z`mC^Q1o`qhiL8-(TSGkJE5uJ{6;TGOMzufJK~#`{zG8Q9a)qaGd_!aj1Y=ij8Y zcsD8si}h&dzw1s)em}T$^J?4&?ei_>`S*JVU0wckzV=;|`LG7?4fB(0=NIuelECuw zvoPr#WEXVjj894Z_vt1$o`Hoa-XbE9w8I?PR1*XGKBu#OKR6^Z=MJ|Xk$<@w&gut! z(Xx}ODthz;<~)6|faCh*Foyefl;en2Wp0# zAb#aeTHZ$@7aNa{Q~mesh2D1@`R4fj zoZoMzf7c?9Po=s0_}%|q(WdtvhUs;`_fxF=8?%{pB^i zihV;}GAK+$IiB~F`uEQYCh2#abeJL~eQY#O-WMEQdF5&Cp?W1fIyf?2^R|L?jQ!zu zPsb|cz&9@P{w|S^Z-W)TUFQP-^_J0{RQiSnc{t9uFA|tv*(rPpUOgS!!EV0(J-oSd z_kM6I{m%O&xOkl%uOuz1pNl=TFQbIIr0IZa?V9N9v1&$6?82 z;FIsim~l6F=oehi(0Gk^pJ^2NolHn)+swR|vMqU)d{#>D-R#w0I%?C5c8uf@+R@o# z++2v*{2RPm{{kKgqokv~T-}Psy$MJh<%HpuY5Cif)%g$it6#s6WVak%bKE$X3Ku1C znDKX!kBSQ79c|7@7OXCIw97m70g9e=`esX|ukiGto0B0yZ!f3ajXr_%uG-(zH1eV_6^}sYadhn>2<#HOmKe!AS1vHI@z<)mAt3aCO9^;6% zOYqI^nNvpkTC{TY#u#G+MTmdJYzukFeg-j}l2`SgXO zUX!UeY>D=^X3Ro8O^I#m@4riF+-#@*!Mw&8Wcheb39fixt_(G8HPe>e17@I#PJ-Ks z+1S`X`LL0^cCRX_v^%ok^a|!tJLYHUAp;NfvbBGAj*Of^KlQB~3;!=)o0Jy&>n+4I zO1BaE0f9jBfKPLGuU5WIAkV=#kVo~@TpM{47_Z8~~R07`f*=z0up7D#pZqMaP3k;a=oL9&Ij{ih>Sy5wWRE0HrAGclj5 z_LE!+?;dYX=(SS}{rA4h<}a=?z)?Lkzj*%W1VdgZ{6sL0mF=m;BQ9k_%aa5C5jIfE zr&NL&j-8-B7`D|C~xV*zIoPar6-A}UjX(boONvEshpnL(L(59uV=LO1j+^vmrf zROQeER>I!d4i;OJ&jXS<2ZzQ^9{NvWI&1QaGg1boWmPSFp1~=0J8k#Wb~%xw#+-F_I$IV9^_}Gxo>;o){fmNp@2lE6|c` zs(JMjet5ghGRW;V(!8fjAAO4!=w*LQ z2(>(Br)p};N^{>P@p0kX*LnyPV+q%;NFDy#YWVthIRWR1Tu134=;@%c+M z_YAiimQ?x_%d>Rz0&wk_tz~RuGu$xBgE`@?j003!YC!ZFd-wtW;xZI7VK=V&{=MiUb%r3&2u(-RyZlu& zY>!h)m-R9h6bA6~MHh$UEm>5AE`(1&H+-j6BQ;Mja)%p~hrWedMct`&dGc5EaJSDj zx8o~Oh_$Cpn(Jmt($}zq!R*IIFcmLmrE*6=g5hXxBYqI zWZQx9J_%~(d1)@rAt-gbCMA2owJv3NLiX1sZ*||jKX|-37;7%gL37fz}pW!(b3{^$=6ywK@m#RjhgKf4ION*%m#I{m6A zr+9msUEVtCsr(Cirh_rCgQYrcSIkeBkH~+_P6db4b$FzN1I4dC$WXs!| z`8#$9p89WwKHM*T(bsOgqk?%?dwbiant%y(%-5t8ehm{#)HqT)%h3B^)ys&~t*~A6 zWuJO=%HBa{?sM|Rubb4g5-}q@>09C-_|v8Mr@BoJ!NNSTsK=PEzRXDAXv%NjbrP}k zr21+>r%rnKDrFdUyDd`eEVAVbzoGimHxU<@4{n#}m+r+cfH1A4p@3LU(5&Tt$}h#@ z5yz^D_5F+?hr?8MzxfM}TFqNUJ^Wk!RPj-SJ-j~%=EaOvp9rJe9L8rXO7AJ#y#}>m zJXr*4+_5*9m)W50M{zUeky+=BtwGOanh03K5Och6v1WKg#PBP&Mkh%^qyIKe*jc$) z(}lvn{c7MsIi6aSmbZTZx6kfBOP=tma6L1l%(AM%&F<|Qd!zD=%A4Y7HsoQz(kQJe z0&SjEZHGBnH>5c@4ruUeaIy2BpqfgLk11|3eIgN)mm*wmCK2lC;f0TVOHTTz*1?>+d3bAlL_IVv)4x^^fH$=UHFTQH!YIev-?3o;DG~SS~eBm zCQCDHJEdN8kL)iJ9I>3@tSBG6>4sz^Qt=~qWvABPsuz5o(Mtq-ki+x-RW!@4(tK)) zPtYh`p|s3{bEf_H(fHI8G%3ispV-^mL&;z}zw#JU0_ctZs&ugYALbA@{vK^yn(A}@ z6K+m6bzX)=_;M=J*7qurDi=K@d3yxaMF#584`}3`|=HjhH-YBkE_JVwQhzy6h zbHlSo)w;m%AtCD1UoH0fg=}V}E>hFL{H0ky36vq59Js9~(DQ`^L;ZvP9ZVz{W(3HdQHdt{w`92KJ4}c3RqqXV z-!>#rvji$ORx7xc8W(QYu2)*1R(hi*KZwAz6PY}sAfz`{G2!l*uh|C$n zAN96`^0RDAwP`e;nJtY)qE>*sCRW+<(I2eV&{&6_E&T>6J8rATo-$Gd)bBziLLt`a-XxMu@FPgD3mXjQ>>#Y|Tt|;Xo zYc#&}7mnT4IU=o_*qA)aEKZRVyzq)-m%I&LOhs5z-M*VhRdZ3BtJG+tFDFtqQIsJg zSlR9+uf?|m!?7H&hWdsQwTO*9+djVYB4|E^9wEEoJ>o_YEwgE1F_Jx*uOL7A7;#%I z`r5_g;%Rvw=g;4H2dOUHu)%iTU zFddFGtReG|eAxH)m(}S{GK0Mf{-#kimS%OnCnk`tl^MlOIxqCd4zV8S-vKH2&r6N| za05q9-1e$G?aRHwh*pDGo6vfP>+Vo|yEuO$#>nHz+$W@=t2TXT^ODC24&QWowBi0a z+Z-0_e#{lD4i~D&&a*}x9eMo8S!=kLe2tal3io2k=~xVRB-mxQ2LD|gu?CZnZ74$H2SW`BX{=(3_b=#IR6l|bD zS{`?=@qu{OnZKOE9BM zj>rlnl&Sbq9-U1!^@RCkN(h?>cRyIQKwKt}MsrHken-7}n1jA{?grSbov-$H&w+;X z^*1bh4prsciia_zNrtePd-r5ur9bY*iWQ#L-l48yD)tEUI40m{aUl0?<&(|H(uh`o zHT>SyYF#+}ljC_-oxZDKXg#g24-t_4gEp&^`otP#V&AD6h2`$2-sy*K7~3x&ve#Hy zAY6-8TrU0m=24yWh?~cDS+lFK^O_)IqN>-0FKgriTqmmI9fqtm8c{iO(a%dq5pvkY z_3MZ{$5gbyZ*43kKyPr`n-E0!h*2S}&L(%O1^l7QJvA^~lvjsr6##H))BH2P3!-B0 z4o5v;DC5u14P1vOOe;3);xGNNmGdV^m`7fXd{~5fUi!5iFi35$;+|9_fRtzUV>lR?pWUr~4N?Y-t0xtFXNzD@D-r;{W<1BKXr zDCK(T;dd5({b&i3UrzkbHf#zq9YhT`iarjxfO?;0%IUWy-ht>GdAGQrne)kh3xjzG zU;LoT$WnC273Mx(TyqANe7gOm1mQ$b9D2VSqiPrWlmq`}p{{O0cxr;~=iIm>yp-S4 zgtq;~?I?lR$9Wpy#hu)GqKW*Ud&=;I1_jlum|B-Q17XJurk3S!5}x-eDdydU-jaJd z>4;bNvrzam^nmzf`xjnitHLf4^S90eX&7{db^^oNwwyNlSzU>3xL*+jyTW8dOw4{5 zDH~H7H-4&Yr*k6l&sKc3cG+-|X1? zJwK0P3-qm7f84%&g;kYNpFpOl4#|openT~MeL!Sh9)|+fRv9_)*4C6s9dtC6Mq70! zWCp(#J{t*OHhEIz99DDR-3LxQ!F)th20K#dE9YwH`!g|RQm9T{XUl>azk4*Zbj0{v zkGtG;9P4o^zX#I|xLkl52xAxn2!FUBoSey#0Gs9DDry!myrj3gHY zUV$EHO@VJmCFM`zvnBHpNenZnUM6dNDS$K*`fhNjRgc+y*vDK{uL8ezqkCDVaVBVg zcNa~SQRb9*7o}Y)7;yC=N7%^`kdu%)vcxl6<7}_96KBZokQNZ|R?&4*CMBu$wX3 zIO|&cGKN=PQbG2aGWnIJ7txpR?Y~au_oQwxE_f1fI!2zEo>*M_-i#!dCTHN}*MzIz zZZj~H1y}9eTn{0Qi)k(z+>+b+2gUo=h2eti#O6Z5OPfQJ3KB;-K(r5{Ac zDnDdUQH-CtKL@aVcZtuCH9i-O=NyKt7+8u{i}>w^W3&)UrCO72CklvEw1WK$^H)P@ zZ}c1Q*ZkG$yQJ_EBxql5e%>3&Oi|!_ZU7`RibqAt5h^CWQXw5C4Vof!Nn9sn3g1aW z6r#lDg|CN><+4lbYgg1}*A$(dQmwEDEbSW3fQT72lXmsrFs_|!;b}IBB2?VafH3LiiWlH*VAsie*}MPE9DEoNYd_hk^x7k;6*BMHasK^9{-Nf zU%~Tmt>%rWOo$c-?6fo)vdRg-=-|TEKSF>UyXp*iwU=B-S7e3~pGA+Jzbx^z79*hM z-|+M6OWBj~CdJdI~AIUj(kLt_n|2^TRHLE!8?V0u-5E_C&>z z-^>*xCB_=jiY9V{PrEkF+}Zs!qEr0b>$G4e(*-nJGqtL4UUVnFYtkJl&mA7hpFg|0 z_w{--D7#6_N|`*VKIt$+ncc?{guDjcC~Ef4+}IUqf~WfJXcvr*~j}^$N2iJ+uB|_x1Sg0Dv7I)UN+I zIb>-|H$Y@#b9<}qY*sNwN0q}Z;(6)Mh6X%&tLmbpnYq}}h{ibu(x5O&h{0221H0NW z@&78#OH7%*{MXiJ6aUt%X?L|BmwO~q1MFQJgOn|Lre6mohQG16EEv-sFxO{Ci5uB# zr5G*kQaK=`Q#D=~jVBTLOpvX^^g9^9DQ3?!vi{?u+ZOnP4Zz?rv0Iqo?g;9>bZz+` z0>#C}gw2JX!UGE)SVz&>^%$u4vLBQ*;#ru&K0O06^#0j|?oJEGx&XkzQr)!uI^x!K zaYL9ahE$gIO<+BB@Ed1Rs*RI4i<6Ob<$HdQ;8!jHfEPV`Qnv^W)&};`0nYz5(jary z-{Te+>_Un2vPYlk9UqTUH?EoIQWfMfg})RUSEw$K$`y~e0BEfbtas=`<27tOoi{^9 zf;GvpjxH#)(&G#&q*pKLZ1UFH9wOl8|C_4~lB@_HNj}(xG)S-Fc?TD3R$aZR+wfx{ zJqb&R3#J%k$JpZ0Jyx&ciK*$~J8IGGFMb#oGK{}4Ei~Z&LLvB;7uc3FY(ev%E2{g8 zIs{R1N;J?DFooIVw?X2}+G>(b7O(<*e1uLa@_vE^PjHaP(~Sjx8Qgt(2gKoqeh9M0 z`BvD4?rAp z^-W+px!<`Ay^EH;ddMV|`eQ1x=b~!EQaxAw3Oa=S{#!Y#nubx0#2eRct_2=meD7^! zVnL{FO-*)S`mPMnD)WN7qC+JW(pdHD!?C`&A?Nx7=zpRLxndxSkpBTdPtvYud#uuJ z9kWWA3&TF>wEd|2S$=Tb;hF^Z{DSMsSoqMFYm4}hRYzwlt*ni>-zKZiA z_M|)Gep>|DvOC0P0z+NT=8u9D;1r}MJ;7}Luv=ehFdwLIk1{Y7L<~l;8`dliovP*? zGoPzJj(SrF%<_M>jXKhle&BwrGW6coEFO=h3U{0T*3$kX=@IAqmX_3n&|**4&oZ`n z3f!@nUNmRmTxHAT(RXJG!iICu=V%xCF5$`H(_#5@mz$euZ$6CIWWdi>0P7c`%Ka?Q zD>xlAK&6?Z2meeaB`Jpo$yrwQ>uK%GAQ%z~HPuLSL6x7O1v|ep zGCcrOvn3h#1WrK$wABX_eUf&VJT{7%nSfS%4Q!;U)BtSOe`SNO;?V-worNBa)dHhG z2586Ey$mkvndBnWMDdbL!u!+_wX>m-=kN#NHT&e<8SB`tze_C_i{w1l2ZtD&lRtR_ z7V@%mk!-P@^nLCJstjMAlU{kc>Ok1fA{RNAICh{arRr?U;X(@52gH4a<>eCv41{nq zq)hrfi!jBNTZGWq!ksln<)2i#VT7yI1Pw%nOkd~K+!M$+Q~KKR8+_Rdijpk ze=aN7_PNK5m0I-ie#YliX&9GUzlFn36({I9!W1!It6WJYza%_4enfWwg?BiLp~;~3 zHZceh2-56TfKyKa+0@}0u(rT{EjdHcDP>FkuI&E7V*i5`o9O1BnRh(Xew{0|vd`%p zn$7v1EJgkoHn@l{edA2@cufwsI@*}9g|$frVr8Yw1!2kyMiv%?30E0xPeH}I6#iWN z5Qa`QhqqZjBl(BPo75j((=5ClM87686#053(9(4EcuG)`Sl%4m01kOZ0+m019xu7d zo~ibP3vHsFQZT|Vt6eO78m#?hcpUOWzJEdVHL4@o619Rn?{LE|XdA|L)$i5QOg6X6 z*8*&khF2S73#GbNvyc0pT@Fvsa8Jmup27V3w^ ztn%*g=!EZcFIV)2HTnOW67%4*pJYVl!RI0^EUqM<%MCT&jM(_o%2uZZr#2gxSy`zb znf8&h?ylUQDmFtX+GKx99aPB+yk&fxCV{873Jt>lEs{%pNbG?OspTZX>&BDy`QFp5 zI`ARY!+dSgk?eB#w_=YEi{0gY;n6=dt{Uf-2tS}q{n0XW+bJqaT@i#0uH6~32(rpH zx{H>V3u}rs!gW&NoC^!VaZ?Q>8@~|U4-M5yVM@JtG-4`{+|RgHX4d~8Dp2jWyp36% z`p+x1%?GtoO04v6Fu>E(8q3*kb@$YnWjtKPkZ;CGO&8~C$Q^SM=PHk>;$U7d3(mtI zei%cV(VCmMzT1g3V%Vk?`r}##qSQ$gDLV-#V*uGr%Z+6k zsv^^x84E`A_DXlLdbEvi!1H-w{9yqiz`-VOU9{Z+z}Qfn=4HmnbNS^OMZyQAFP#N= z9th1P|1V;+10qJM*6S9m4GYW6pmH#{Sbq1wq5&q`@B4H95(o@l?<3tnM&z3SyC(Ek zsKP?ZIk3fEb*v)T7DO$@Z?24Q6i){}6|wAP?yh>i4=2#sy^kz>K|4jR+^T4%Q{)`i z;K!qrTHG+r72u)O@nn;%p3F$}m<>WrJ&z8XAf#DcU#dMPkxvL^W@J9yqvVN+>d=^y zJ!2I~NR@-#QtMBhf!&0Ca6zwoFXlvQUOaIlj1=M$<&AqUpi1o^Vqg|60w=st8M=qq zdtXolGVVh`?k>Q3_?mQvAmAJc{@iin-fu!UB#tYm7=^tq~t(h(ra0%0Vt#Kt&Tk`7R%9t@@N?|rmh5e6|#NSSte3`0(t9% z*K50GqMsd&>*UJ;_#{QtR}v9F$`52wJ#xJh8ixc;Ef&OnIyE#M{7+htxInT~&-%!!i8*Iajo4EVpnEnb?qio~Us6(kY307_ z+ORtsC#A728>fe}J_5n`^AeM_tL4J;nSV@p31RpP?OG6An3d4``jNVNs8tVcQf3O_ z?!L?3fDK0lIK#YVZS_i?!Fk26jU(Udtn}#QBu8J>#xvqlb0J6KjmCV&Dpq~b8;)28OqfB|P;ddt@ zaSPz+inu-`zqSgxpx3`@CM&N9Qz0e+ff{c$-I%7oT?)Oa#+wSZ4YdPqp$N&I6W_IF zLrs0tcf+L7)UG_r*2mkeErgso%3Aqqn;q18B}4uSPg^SAlb51rWDux)REz$t$)t#m zM6CS@-l}dw@(}6{#@7*hJG%@`rxb!Jsg3X42)NOoK^+!Z=o-rWiPoHT@4Q{@V>NMz z_WrN*huK3XTGQ}%?f`8A=!Ob99{fsN!`_#=Dsi88XeEGUNeT~VYee#%0rWMs=mh)o z=~7j`f1^^^`uNs(*dhrFRct50s|Nu`x7q;?3b`~5M-|EE%3cNvd5}BFsRk0k2n$Q` zrq#D$7fZM{U-2MzPr-3iIT?uH<5Igw*J~fm`~hHb(4i(Mj_J*W z!^s!NOre54)y8mqkFZ*FhXeKf_{;xk3Oqp^W1BJ_4z@229F+rl_D%5`y|9?J=PI=4i(4p3&0>bcjK9OoBkrhS3)pFUYx`G1T!aa ziU4I@4z~9%iucrs>;m8373yH-y+!tCv2k&6sKeu!RKi|U@msR2VVAxTsQE@d_poP` zUaHpP5mz13w8aTfz6y2@r5M!v^8p@D?P{kQjAn6QWMVvtGF#|t}@E4v={G6mw=-+v%XDKT8PjZX| zY(?V_!^3$j?#s??$JUYbg&_Sz9x(5O!cq@%o9w6n<(vSW|=13Yuh^@X;rwc)sqk zNo;U?yE-7D&9FzJoKp?u{bE~KPc4-OA-3N5QlhH*B{U7HU?&2+91A9H0oPZ7fzZ?< zuc_B8GNJ`9rFBFIE)KvQrTX~REFkYSk`R!_K_|z&7s`3_8}|KNH{t0+n>%BShe6ES z&CkZCFxA&ik+)R#`$q%W{S@FIH}7G7tIx!n6d$_R$ZPYUJOUOGEPd;=wKK9mjtnCr zK`o>58BX5>WboN7+~_T1u?RyrI)H-K8&Njat9B7A4lWJ={HY)4?bNR+zFr=3)2|dn zUv<*lLf6`5PGSlFzrju!KMImV@KCZl}=Q-xzOh!@Y~-1in|0`^PeWTyIOZ}quDNi)T4?0sg;X{vSYeo8No zTn-+tICxLoi(>E`D3kM@IIZyC)5_DwRt2*&OOkhz8j2c2_A|w->`|PnRfi(6XZKYx zIHu-%Hod)2K9Lsgch2L1g#vG48Kv!-f6kzkQm6;MMhOVQ|?_HH2iPCayD(<_wPgN>&r z>7=E7*I}C^W~OCz5Xpwa?t+7M(0W@?pQ6xfl*q~~m5yoK%Y}<+-qv9FUZK@pnblXH ziKi@|%oR|-MBnIoUV0<6Su^$CejEXtY7si*@Fw_)K_Xfk@>&{+q;d5+mym^{zu-5r z>^?5|xprlq^0V62A?S z^0T0MmTR6XK%(s1d*2`ZKze;BKd#on+1dD+k#NVJSWo@Vq(?@-_jSZoq{<<4YcN8u zAWL$l+V!N6;}r2`>wylj?!95FC$njx+@!PlF#!1$=hpLBnC6fQ(cH~GdDkq=8hCeX z*zTi!)-95>=&rxIWq_wcy0&@pSIyQwDAtMmG%&pC+0FS1Ko;MvxE`IZ{m6;zSu6uR z13Tj6uvsB)uq?F#iF6OZ9G7sarH<$_? zb%a0Z6Mi5Yg`CYIfYg>mEtx~_;wJ)Lte@*hO0A%a>9zk&NQ)08uCgvoz5|;+h?On) zkB+Ifs%p&FImEMAx{yE2cJIxN)fsA!zH)`Ido;Gm`3C^2uZR8KO;{T@o~52{`@QHW zNx&^o`8YD?p712wIDAHd_q)NJn)6d<5~7lrLk6wSw@*8#HZ|^&jviDmJOs`&O*nH} ztNi2C`*PpLDvT7+&##5-hp~r?VOQn5KqjCXX$n-3Oel=SUr$pLwNjN zEcMzf8{B+Kf*x3jqzN?8rfK|obU$#b?R5`3J8klVBUt@qg2k20*9?DpsPuoNXbjCg z1z#Fwa^J!Sofp5=QD_$E>7xu$&F;4b^U79596j#%Uxg~=+UyG1+SiCYkVGBRjtDqz zAF|A;-cB1YzL2)-G+dEkOA0A#x0#&>Zf;MXq-1l32>|kPJy9yA-KYL9JK3EM4*`1r zvy-@NC&j$P0UXK3(9=}( ztti45%V1t`$Bf}J>FF5=JyL8zx1V*WV)SsN+C8pp@({8sU6XKdE;^oqIPN%nftpr?|7gm zsa;uFfiLYkIRqFjE`fx z$B-J_``C89hp$3Ayij>rdX|qs*?VC*32G{hlrZ7^x-wU1M(8Qk`??u_5fJhi1_Jlx zl-!7tj3Ah&q>4O=;lY~N9=W6Wm)=Vi$S&O&P5^Ex!Dn3=CmWFCOuqb0|O_hz3_)-etH0(C|UoyAHq`5KL5;1dB!vpa#B; zZKC(~IRNBMnvPkJq_fL)G00?S^VJiN8vw&0H_k%NV{DsEo(uNQ#SKCa_d!li<9fO@ zSRhEcc;>+VEurZ=LY&WfNJ(8v8?friXsnVEngISS0IG(a%V1Qf!y~)Jz!cmy> zH>DiVR(K>MQvLZcf*j0aEC8@PA~$Xk_?{N(q|f3M4bg{@JB&0=6_D~ue6W=ufrZ( z?OOkMzAonbIerULr$n#^(~VX`{~Y4<|*7I6ve^;t;$*knnxViHeL z036lQ(C@!#9VXCx8nrFdUBJ_}LntaC2S1;aLp6ao6LF&*vtm+^z}FmT4emdu>CcIl zt*0fjLO|lgGk_rN0a?;z)D{H*jTxf*Mh>3IrF&dFl}R#Mn1ZZoZn|3z+Rt~~{V5H> zj{vlWiBO^h?LmQ|7;Kd4D*NiCu zu-x<8w-AXV6$x`P+F*L1ZYWT_V5AGnegR-v-3T7~9SQau*};Np5iM)_FXab8!9%w} z5W*6`B*`kyFoy!_&wraE&EM(V^oz2u_i%d80DMzk({}jzcrbU6wgxA9dj}-T5^;S)e%rlNb6bS%UtK)YAvW4b<8hrS8=)oMg2%NtwBAkk(KgD4ha0U98b zrSu@EYKj}CoC!|szEPhD2mQvSxHU6N2h*Y4b2CA^W6+`MJ zEc+v8IRN(h7|i>-CWnysCsAO%N7L=48#hsRKpzC7+$`;VW%YQ8geMUEJtI`y#AYS% zR6-nbxKwKvRYK_OPx#lIcUaJQZO#pvTC)s2aRDaiy7Gh9b6FCZ1Ts>nHw`}#rcMQvgraQ>A1!r=rWFvbgKApRS1ys;N&e%$fx<=i9$9g+ zY|I@K{H(~*f^I)i?7K=_%QK5RE(8hjzu)|xgP$|zdX?O)??!>bMk0L83@btne%=c9 z<~8p+Vhx}W27RZ0RnGSl*nbdG4j0EUl6U0BQA`MtyiiCVn>9$Y?P;c*_@L0q72x97 zof0m~oXxBqsU+UO7xObAYdprC&OVSrX{!0u1mv z-!!hxSwz%Zb{@+2#c~(nht2``(p&wLgNyqgHAw(X<+k;)1?v+z*SB*B0NdPZ<$qjIHb;@Q&CdOsNtNsy)P^~W<=Ht0C+3*7s;d2z|r!s0olq(;dkO zZGJ2-FE4Wi3Mnh_djwd1m$o?qM*GtoLNJr0S17oP$=UgCeAqoV@Cn}mv2Ngw%_tP4 zX711^5A9JI@H0*@q-$UO^3PVZKhIuheJIT6xvS;ss7@11uxF-14l5fc8q$Wyr~<0s zlKbrSOUr%@lL!|7$`s+8t3tsXzq>3FT4`?937DgPv@adaw73@- zy+}!cTxD-q`tWH4_qTN6fw|$&Pb17Pt;;e}fHlAqRF!BU0Tfwh0o=SH2&j~plbZgVk&0w*%y z^6Yb%o-bVf+0WvNmaD>yKe3=bd3<=B ziQ=Q?YiF>$S&#fp1iqMSaTDc(LJ18;As*#*@Y9NcAWnWR%8Q)ItiggZNf2i#`N>PqwB(HYiU_)R>qMt?o3oTzo}ZZ3Wlg9aJ~4 znp|zm?)49+JjyEjL1c`89DGrd#8Tk~_k*ud3xKt>RX2{>Uc!{jc>U-nUz;e9? za9%k|o)z|CDqp~og|89mE)__&2Xq0@PQO%m{hn$xXI#@&XVHG&b8;6HydQ!v6Qd zt}X%mOBq1S(c)4DW?nB|Bck9J1dV6z)*$GH2;5ZzjLxoAWyd*l;B{%s#Mj-Gk>Da8 z23-9j+17%<^amc~a;kkl8@slNwzu8Y;>Z9DBvBC&aLKZgnmgdm#@^{y>&_V*PEA#j zavZS#-oq;@RbVhs=K-KvnE)Mq2ppbWHl{Dh4_39&?cnK=YX%mezbP<11jiAmacmv>vzHZG_kn(eJP?@y%7-noNT0ME(8HT?3iPJhD z=NuBFgG)6m2UuZQ%5aL@2*8g677;MbtiNvdL-qse(%Ct?lql&{Lcn-pVx|E8E*TT_ zuw-I0s=Hf5D6ZZ8PA)_<=~asc(+kKV8s;pfBmYGk9jHEXAYU>Av$JG{StS(X>< zcOLr`8qg&-8-YSA1xj9=@b4F*%C)d2Vt|Xtq)EEEitb$Y5Tm37)Gci|eZZ$m5-GDU z?ieI=dwgtcpbz9m5{ZM1ZYP_K1q8{`)3N@!LTu}&>WyR6=VdqP{xP6T*8YM7?6tp} z9?Gy~=S5z0k{5@uel)w!&kL(P;g*MBpD3Mw!ajSjw@?5f!1Xd+e zYi5s(pnD@BRwB5muSjTG2yo|$^$$&T_9FlZk4QOqt6Xa8v#&$~U8Hm7 z!A$%kLxz~D)hB0VPuWyBx<{F z1tzge`Y=JF9AelX9ncDiNOKKJ749F}guAhRB2pw$TsRk1v=tSytDDD3ylq8s73D9P zDA+&5N^u^L?)ZpigA?sB6|Qa6ivE{kdyNg{MX8IWtInnwG7a;e(xn&_fGa2vzu7 zm4Rz6znKA zn{>(Nj9XjWOpV29H@D{TZoo7vtSz|D6as8v)>$?{vo+=vp#q@gUwV2f1Hl34@d^u_ z`yRc0C|`R0?6ajh3$>j%?WJH)o(md%_3q)EJ?FfeG*}q9UdXbb&fJ2NcK0OU-_@#< zfQCn$^M`vE!V5D<8YSO8n0ur(xcUMKtnMdnu;i!|`wx76PUQYj3DD4mOCB#hJTgp$ zi2PZ|^k;>`_MGG6<3RYrflp#$cx>#cP#h0WD^WQ&_TG-N=TXI5pBkNeoBTm3<0>^1 zDWt&C5L#*ts0z~`eXA;q=>V^}l2%rBpLP`hdH8`W+l}7knx@BDbv6Ify=494!))P7 zg$DW38&|}j(R;xIj3Eb-QdR#fe;skDDy7@?)S8P;LSSfyGHhC${T-{;fL1_}_gaLK zpDbG>SYRpc$PyDd6pDvdFs@@&|1ojSn1+`bih76?rvFc1ly=qWd-0Penu|Z45p8{Y z1((R2VnkYqO_laJF-TiQ@0EI%ZhT6@r+p$fJY>(S8OKk%nS~A39VLaD%PT8XGXlMW z9}+)jrr@c$4@#%T6XVL{ihv&q89ja<#?dEN(8Wy@S>cTiWy%?Wo2$@5U@cAs1fV0;44`vI_^+kFj1DIY*E z4$#qQD{GZY^-u5+-C%y#S2x!~Fu=%Z0mtSFrHnwZPoWKkZ&kvyOZTriSkXZG)}qRO zr;ZilXPxC}USPDUcoQENE{&$ow<3e?h{kI~P2-Cm=@W-?kj_sVet;>9q|OK5?3!p| zSJbpzo`K2NL?Sgg^kjW4M8yx3CA8S3177#s?&qbL1I&m+MRWBt;w<&%cBeejaz!;i za3&jycc@4}Ulw`$xLW4Egd)JXZLIagDBTVurRN7(MX&F14~8^5gdb4BJyHPFsa+;k zH5pK+2BEuJd>G$Q-Qdo<4d=|HH|`pM8NQ#nnDyV$eOjS&Fxj@ z*-GIL4@jYOhl*;zO~0xZL`?#zSc=Bi_@DawopR+PK0G9acIj=imveXu{^twLZF4Gx zA7Q+e0k{1>##*{qTRU*M1Dpteq|N>9Q056PD@l{n%mZP~Z?L=qu7>{a^v2t{eS5Q!eswe{m#j^m1v^Vzf%hJ2N_5ev>8bA<* zn{S<2Ag;@qNE_{%NrGJdGh)Dyogq9o)TAYv{GB$(a;q&!VU~bX&OM> zY+a_f==;x1uS~D5&n&B&S=uGOfE{$dkhddLEQWvopU<>}%XvyRxi6>|M4Kr zw_u+j$yTVyz}56&P|J)oOD9p_Xyc^o7|`1t0;viRuu2EVho>i2sLq3Z;RU!-sWLwG z6n1Kq3EqIx-h;D>N%_c}2Qa*Okz`PKra_@lk4AB)#us%i;yO}+O<19PI-Xh2UG@x4 zufPZhzQvhCDLcgUPBZ|&9H3jRu4};9Tj<#VefW<;9I0Cx{v|$d5&O>x%1S9-RjypU z#6Z<{2b9UlAbz%Qxs9@RAHKUsY`ERaRBOwdj%8v6WdrX_0kbP0Yn7hd8h+%e&Iy#2HsAmei5kH8j+u4oS+5OGRAbKR4jhr#dkIL`S zmX5OmGb}-Y6iVW1DE}!Z6%*b{wDou!=_sU*lpYe55yS+9Uy^$Fw-GS3YM>h6MfYf( zw?bh5*WQ(XCAqEPtyyVXjFh;gU3?C z(v+(>+#-kKI3$`SfppA_j7ZHC2{jGP0Ve{*0q^$y7k91u%eP?d=X>7$4&VFjwHKR* zhlNFW?w$^K7ZVyyOo+B9{O^<2>A(>8eEQ*BNIiN3!J&LqX8qC|KkL^Bn{Xmeu<{P)Cu%aG4sI0C96R1Alp|8Wo*-9H!)7h9S zre>hmB&`q$0dxL9(9yEf8CN&!=ZtkD>yNI{0rlZL&)JTp`zwsK>%r~f(turcvmfgS zf1Av2fWx8l-#Y8HSjtlmvi_cbZ#ZLO6Eois^BtIAIh0`w-lO>SU}O>0#bX}^Q7ws@vUrjjk+Mg5Jz1N9Nw?S-9Va{pkU3dGBhd)sxKd7tzJ@0!tX) zhwv!f`8Lx}@&2iop##3g>peuxhm}>om-@AJ>*{<5ZeJZzDtiY=m)cY-^{@Y$DcoV} zENW)IY4PbgZnjew_-=pB`^Tqvc(A`Z8BiV#3(qR6+9HCBF?zbc05dP9oB-*&iGvNN zAUziUuqC1z-hbGZ4(UIC4{r0r?2z5@D{E!#pG=k|F8r9~E+Ur1N@7#J59NcziV$@x zs+;Iq>&R^10{tJiFmX|-PqSpdqMo#N>mm^PU?$Rnm;6~Ins~xguS>l6KH}mA2IJVp z>Ewz7K;XaQxq==Y{fd_iXE$HmHT1{AR8`B^1B~9xb{!D>fyhI4cc(?i6pOvj#^}r} z&tmnH=BirwZwHj{yY?zbM4!tyZ;+0CRTHt7++dP=tn7_O6hxLxXtHVvvQ-B-pR{M* z8jC(yTbp`^o1`1;^SHaTR;o&EF2x@vubyz!1EXp3c)L2DsZ3_x9}D9^wfYbV$4OjM z|3{F7ngS9{Gt-(_Txp9;M6iQ))>S_ZEDOeeM0X|S9-nKl{SH)SW=uxJlegD#vvdT5 zsqFSy7j8G-`or)NK@KFyPIHYxt;&FdGn3^suA&u1WWfC@Mw)9smpi#|&Tc~yZ+YkO zBodFF&{E26Ov?0NG_OoY6*8XpRY^OZ;wGnQdJf7sdRh!9(ay zXI-HFNaW;v>Yr+*5&mNIqrt&;&QiZYd4N4u0}F$~1=$xZ)#-xxNbh1}R-|hFIf?2MX$mdRtkDI3 zS0U0ZjV@-+&1q6&4EBF@Pc9qQTvm;4BHl$`KLGaJaA=l)(MC_5$6bDko7e>I!Ks{C zvM{W1ffX6FjN+gJ_A=E-3!~|_A7EXW2T^YH!yF{izBIXB zLz0yxP3>>bhD`X}Cp?Rv@2~4|O-Q8egSsA}_WAD$kb~aooRTK5j0c#N>H|BfQj-@BOL|yWS?pg`YZ=;cNahP|7 z1$pKGx0xws(9=DdEQ|X4_px#y1rDa6;g@R+Orl4Rz?7h@poo-j*5ezy#wW+Cy1$pkM5oSN?foUeN7hekX}Df4^Wp7PJ=J_Cvkj7Qy*k@ovN-a$de=!FM8)4WrB z=!r>SS>Gx4`AY#&HB}lbg$tm0!5x%S_W2fQw9te0+aj)P;sAZ;vySFW21+65sPwg2>%L^d|P?B`4%Ls_7LB{p7XPU%cZZbLf)$madO$GO zS)M&2iNX}tW1FyTeJQ literal 0 HcmV?d00001 diff --git a/clash-dashboard/src/components/Layout.tsx b/clash-dashboard/src/components/Layout.tsx new file mode 100644 index 00000000..83ced069 --- /dev/null +++ b/clash-dashboard/src/components/Layout.tsx @@ -0,0 +1,96 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getVersion } from '../lib/api'; +import { + LayoutDashboard, Activity, Filter, Terminal, + Server, SlidersHorizontal, GitBranch, Shield, +} from 'lucide-react'; +import logoUrl from '../assets/logo.png'; +import { ThemeToggle } from './ThemeToggle'; + +const navItems = [ + { to: '/', label: 'Overview', icon: LayoutDashboard, end: true }, + { to: '/flows', label: 'Flows', icon: GitBranch }, + { to: '/connections', label: 'Connections', icon: Activity }, + { to: '/proxies', label: 'Proxies', icon: Shield }, + { to: '/rules', label: 'Rules', icon: Filter }, + { to: '/dns', label: 'DNS', icon: Server }, + { to: '/logs', label: 'Logs', icon: Terminal }, + { to: '/settings', label: 'Settings', icon: SlidersHorizontal }, +]; + +export function Layout() { + const { data: version, error } = useQuery({ + queryKey: ['version'], + queryFn: getVersion, + refetchInterval: 10000, + retry: 0, + }); + const isConnected = !!version && !error; + + return ( +
+ {/* Top nav bar — Apple liquid glass */} +
+ {/* Inner content constrained to same max-width as pages */} +
+ {/* Logo — real clash-rs icon from public/favicon.svg */} +
+ clash-rs + + clash-rs + +
+ + {/* Nav */} + + + + + {/* Connection status */} +
+
+
+ {isConnected && ( +
+ )} +
+ + {isConnected ? `v${version?.version}` : 'Offline'} + +
+
+
+ + {/* Page content — centred, max-width capped */} +
+
+ +
+
+
+ ); +} diff --git a/clash-dashboard/src/components/ProxyGroups.tsx b/clash-dashboard/src/components/ProxyGroups.tsx new file mode 100644 index 00000000..982b5f0b --- /dev/null +++ b/clash-dashboard/src/components/ProxyGroups.tsx @@ -0,0 +1,305 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getProxies, getProxyProviders, selectProxy, getGroupDelay } from '../lib/api'; +import { RefreshCw, ChevronDown, ChevronUp, Check } from 'lucide-react'; +import type { Proxy } from '../lib/api'; + +const TEST_URL = 'http://www.gstatic.com/generate_204'; +const TEST_TIMEOUT = 5000; +const GROUP_TYPES = ['Selector', 'URLTest', 'Fallback', 'LoadBalance']; + +function getLatencyColor(delay: number | undefined): string { + if (!delay || delay === 0) return '#8e8e93'; + if (delay < 200) return '#34c759'; + if (delay < 500) return '#ff9500'; + return '#ff3b30'; +} + +function getGroupAccentColor(type: string): string { + if (type === 'Selector') return '#0071e3'; + if (type === 'URLTest') return '#34c759'; + if (type === 'Fallback') return '#ff9500'; + if (type === 'LoadBalance') return '#af52de'; + return '#8e8e93'; +} + +function getTypeBadgeStyle(type: string): { background: string; color: string } { + if (type === 'Selector') return { background: 'rgba(0,113,227,0.1)', color: '#0071e3' }; + if (type === 'URLTest') return { background: 'rgba(52,199,89,0.1)', color: '#34c759' }; + if (type === 'Fallback') return { background: 'rgba(255,149,0,0.1)', color: '#ff9500' }; + if (type === 'LoadBalance') return { background: 'rgba(175,82,222,0.1)', color: '#af52de' }; + return { background: 'var(--color-fill-medium)', color: 'var(--color-text-secondary)' }; +} + +function getLastDelay(history: Proxy['history']): number | undefined { + if (!history || history.length === 0) return undefined; + return history[history.length - 1]?.delay; +} + +interface ProxyGroupsProps { + mode: string; +} + +export function ProxyGroups({ mode }: ProxyGroupsProps) { + const queryClient = useQueryClient(); + const [testingGroups, setTestingGroups] = useState>(new Set()); + const [expanded, setExpanded] = useState>(new Set()); + const [groupLatencyMap, setGroupLatencyMap] = useState>({}); + + const { data, isLoading } = useQuery({ + queryKey: ['proxies'], + queryFn: getProxies, + refetchInterval: 30000, + }); + + const { data: providersData } = useQuery({ + queryKey: ['providers'], + queryFn: getProxyProviders, + refetchInterval: 60000, + }); + + const selectMutation = useMutation({ + mutationFn: ({ group, proxy }: { group: string; proxy: string }) => + selectProxy(group, proxy), + onMutate: async ({ group, proxy }) => { + await queryClient.cancelQueries({ queryKey: ['proxies'] }); + const previous = queryClient.getQueryData<{ proxies: Record }>(['proxies']); + queryClient.setQueryData<{ proxies: Record }>(['proxies'], (old) => { + if (!old) return old; + return { + ...old, + proxies: { ...old.proxies, [group]: { ...old.proxies[group], now: proxy } }, + }; + }); + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.previous) queryClient.setQueryData(['proxies'], ctx.previous); + queryClient.invalidateQueries({ queryKey: ['proxies'] }); + }, + }); + + const proxies = data?.proxies ?? {}; + + // Merge provider proxies so group chips show latency for provider-sourced members + const mergedProxies: Record = {}; + for (const p of Object.values(providersData?.providers ?? {})) { + for (const px of p.proxies ?? []) mergedProxies[px.name] = px; + } + Object.assign(mergedProxies, proxies); // registry entries take precedence + + const globalGroup = proxies['GLOBAL']; + const sortIndex: string[] = globalGroup?.all ?? []; + + function sortByConfig(items: T[]): T[] { + return [...items].sort((a, b) => { + const ia = sortIndex.indexOf(a.name); + const ib = sortIndex.indexOf(b.name); + if (ia !== -1 && ib !== -1) return ia - ib; + if (ia !== -1) return -1; + if (ib !== -1) return 1; + return a.name.localeCompare(b.name); + }); + } + + const allGroups = sortByConfig( + Object.values(proxies).filter((p) => GROUP_TYPES.includes(p.type)) + ); + + const groups = mode === 'global' + ? allGroups.filter((g) => g.name.toUpperCase() === 'GLOBAL') + : mode === 'rule' + ? allGroups.filter((g) => g.name.toUpperCase() !== 'GLOBAL') + : []; + + function toggleExpanded(name: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + } + + async function testGroupDelay(group: Proxy) { + if (!group.all) return; + setTestingGroups((s) => new Set(s).add(group.name)); + setExpanded((prev) => new Set(prev).add(group.name)); + try { + const result = await getGroupDelay(group.name, TEST_URL, TEST_TIMEOUT); + // result is { [groupName]: latencyMs } — store the group's own latency + const ms = result[group.name]; + if (ms !== undefined) { + setGroupLatencyMap((prev) => ({ ...prev, [group.name]: ms })); + } + await Promise.all([ + queryClient.refetchQueries({ queryKey: ['proxies'] }), + queryClient.refetchQueries({ queryKey: ['providers'] }), + ]); + } catch { + // leave existing latency values unchanged on error + } finally { + setTestingGroups((s) => { const next = new Set(s); next.delete(group.name); return next; }); + } + } + + if (isLoading) { + return
Loading proxies…
; + } + + if (mode === 'direct' || groups.length === 0) { + return ( +
+
⚡️
+
+ {mode === 'direct' ? 'Direct mode — no proxy groups' : 'No groups for this mode'} +
+
+ ); + } + + return ( +
+ {groups.map((group) => { + const isTesting = testingGroups.has(group.name); + const isExpanded = expanded.has(group.name); + const isSelector = group.type === 'Selector'; + const accentColor = getGroupAccentColor(group.type); + const typeBadge = getTypeBadgeStyle(group.type); + // Group's own latency: from last test result, falling back to group history + const groupProxy = proxies[group.name]; + const historyLatency = getLastDelay(groupProxy?.history); + const headerLatency = groupLatencyMap[group.name] ?? historyLatency; + const headerLatencyColor = getLatencyColor(headerLatency); + + return ( +
+
+
+
+
toggleExpanded(group.name)} + > +
+ + {group.name} + + + {group.type} + + {group.now && ( + + {group.now} + + )} + {headerLatency !== undefined && ( + + {headerLatency === 0 ? 'Timeout' : `${headerLatency}ms`} + + )} +
+
+ + {isExpanded + ? + : + } +
+
+ + {isExpanded && ( +
+ {!isSelector && ( +

+ Auto-selected — click to force override +

+ )} +
+ {group.all?.map((proxyName) => { + const proxy = mergedProxies[proxyName]; + const history = proxy?.history ?? []; + const latency = getLastDelay(history); + const isSelected = group.now === proxyName; + const latencyColor = getLatencyColor(latency); + const inner = ( + <> +
+ {isSelected && } +
+ {proxyName} +
+
+
+
+ + {latency === undefined ? '—' : latency === 0 ? 'Timeout' : `${latency}ms`} + +
+ + ); + + const chipStyle = { + ...(isSelected + ? { background: '#0071e3', borderColor: '#0071e3' } + : { background: 'var(--color-input-focus-bg)', borderColor: 'var(--color-separator)' }), + }; + + return isSelector ? ( + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ )} +
+
+
+ ); + })} +
+ ); +} diff --git a/clash-dashboard/src/components/ThemeToggle.tsx b/clash-dashboard/src/components/ThemeToggle.tsx new file mode 100644 index 00000000..5b595266 --- /dev/null +++ b/clash-dashboard/src/components/ThemeToggle.tsx @@ -0,0 +1,43 @@ +import { Monitor, Sun, Moon } from 'lucide-react'; +import { useTheme, type Theme } from '../hooks/useTheme'; + +const OPTIONS: { value: Theme; icon: React.ReactNode; label: string }[] = [ + { value: 'system', icon: , label: 'System' }, + { value: 'light', icon: , label: 'Light' }, + { value: 'dark', icon: , label: 'Dark' }, +]; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( +
+ {OPTIONS.map(({ value, icon, label }) => { + const active = theme === value; + return ( + + ); + })} +
+ ); +} diff --git a/clash-dashboard/src/components/TrafficChart.tsx b/clash-dashboard/src/components/TrafficChart.tsx new file mode 100644 index 00000000..27a64aec --- /dev/null +++ b/clash-dashboard/src/components/TrafficChart.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef } from 'react'; +import uPlot from 'uplot'; +import 'uplot/dist/uPlot.min.css'; + +interface TrafficChartProps { + timestamps: number[]; + up: number[]; + down: number[]; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B/s`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB/s`; +} + +function makeGradient(u: uPlot, color: string, alpha: string): CanvasGradient { + const gradient = u.ctx.createLinearGradient(0, 0, 0, u.height); + gradient.addColorStop(0, color + alpha); + gradient.addColorStop(1, color + '00'); + return gradient; +} + +export function TrafficChart({ timestamps, up, down }: TrafficChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const dataRef = useRef({ timestamps, up, down }); + + // Keep dataRef current so resize observer always uses latest data. + useEffect(() => { + dataRef.current = { timestamps, up, down }; + }, [timestamps, up, down]); + + function buildChart(width: number) { + if (!containerRef.current) return; + chartRef.current?.destroy(); + const { timestamps: ts, up: u, down: d } = dataRef.current; + + const opts: uPlot.Options = { + width, + height: 160, + series: [ + {}, + { + label: 'Upload', + stroke: '#60a5fa', + fill: (u: uPlot) => makeGradient(u, '#60a5fa', '20'), + width: 2, + value: (_u: uPlot, v: number | null) => formatBytes(v ?? 0), + }, + { + label: 'Download', + stroke: '#34d399', + fill: (u: uPlot) => makeGradient(u, '#34d399', '20'), + width: 2, + value: (_u: uPlot, v: number | null) => formatBytes(v ?? 0), + }, + ], + axes: [ + { + stroke: 'transparent', + ticks: { show: false }, + grid: { show: false }, + }, + { + stroke: 'rgba(255,255,255,0.4)', + ticks: { show: false }, + grid: { stroke: 'rgba(255,255,255,0.08)', width: 1 }, + values: (_u: uPlot, vals: (number | null)[]) => vals.map((v) => formatBytes(v ?? 0)), + size: 80, + }, + ], + scales: { x: { time: true }, y: { auto: true } }, + cursor: { show: false }, + legend: { show: false }, + padding: [8, 0, 0, 0], + }; + + chartRef.current = new uPlot(opts, [ts, u, d], containerRef.current); + } + + useEffect(() => { + if (!containerRef.current) return; + buildChart(containerRef.current.clientWidth); + + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width; + if (width) buildChart(width); + }); + observer.observe(containerRef.current); + + return () => { + observer.disconnect(); + chartRef.current?.destroy(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + if (chartRef.current) { + chartRef.current.setData([timestamps, up, down]); + } + }, [timestamps, up, down]); + + return
; +} diff --git a/clash-dashboard/src/components/ui/badge.tsx b/clash-dashboard/src/components/ui/badge.tsx new file mode 100644 index 00000000..f178c3e5 --- /dev/null +++ b/clash-dashboard/src/components/ui/badge.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const variantStyles: Record = { + default: 'bg-black/[0.06] text-[#6e6e73]', + secondary: 'bg-black/[0.06] text-[#6e6e73]', + destructive: 'bg-[#ff3b30]/10 text-[#ff3b30]', + outline: 'border border-black/[0.08] text-[#1d1d1f]', + ghost: 'text-[#6e6e73] hover:bg-black/[0.05]', + link: 'text-[#0071e3] underline-offset-4 hover:underline', + blue: 'bg-[#0071e3]/10 text-[#0071e3]', + green: 'bg-[#34c759]/10 text-[#34c759]', + amber: 'bg-[#ff9500]/10 text-[#ff9500]', + violet: 'bg-[#af52de]/10 text-[#af52de]', + gray: 'bg-black/[0.06] text-[#6e6e73]', + red: 'bg-[#ff3b30]/10 text-[#ff3b30]', +} + +export const badgeVariants = ({ variant = 'default' }: { variant?: string } = {}) => + cn( + 'inline-flex items-center text-[11px] font-semibold px-2 py-0.5 rounded-full', + variantStyles[variant] ?? variantStyles.default + ) + +interface BadgeProps extends React.ComponentProps<"span"> { + variant?: keyof typeof variantStyles; +} + +function Badge({ className, variant = 'default', ...props }: BadgeProps) { + return ( + + ) +} + +export { Badge } diff --git a/clash-dashboard/src/components/ui/button.tsx b/clash-dashboard/src/components/ui/button.tsx new file mode 100644 index 00000000..2bfda519 --- /dev/null +++ b/clash-dashboard/src/components/ui/button.tsx @@ -0,0 +1,50 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const variantStyles: Record = { + default: 'bg-[#0071e3] hover:bg-[#0077ed] text-white', + primary: 'bg-[#0071e3] hover:bg-[#0077ed] text-white', + outline: 'border border-black/[0.08] bg-white hover:bg-[#f2f2f7] text-[#1d1d1f]', + secondary: 'bg-black/[0.06] hover:bg-black/[0.1] text-[#1d1d1f]', + ghost: 'text-[#6e6e73] hover:text-[#1d1d1f] hover:bg-black/[0.05]', + destructive: 'bg-red-500 hover:bg-red-600 text-white', + link: 'text-[#0071e3] underline-offset-4 hover:underline', +} + +const sizeStyles: Record = { + default: 'h-8 px-3 text-[15px]', + xs: 'h-6 px-2 text-xs rounded-md', + sm: 'h-7 px-2.5 text-[0.8rem]', + lg: 'h-10 px-4 text-[15px]', + icon: 'size-8', + 'icon-xs': 'size-6', + 'icon-sm': 'size-7', + 'icon-lg': 'size-9', +} + +export const buttonVariants = ({ + variant = 'default', + size = 'default', +}: { variant?: string; size?: string } = {}) => + cn( + 'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none', + variantStyles[variant] ?? variantStyles.default, + sizeStyles[size] ?? sizeStyles.default + ) + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: keyof typeof variantStyles; + size?: keyof typeof sizeStyles; +} + +function Button({ className, variant = 'default', size = 'default', type, ...props }: ButtonProps) { + return ( +