diff --git a/server/Cargo.toml b/server/Cargo.toml index 71d1227..6286dec 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -3,7 +3,7 @@ name = "orbien-server" version.workspace = true edition.workspace = true license.workspace = true -description = "orbien server — TCP proxy over TCP/QUIC" +description = "orbien server" [[bin]] name = "orbien-server" diff --git a/server/src/access/mod.rs b/server/src/access/mod.rs index 1f083f4..df7c0b9 100644 --- a/server/src/access/mod.rs +++ b/server/src/access/mod.rs @@ -1,199 +1,34 @@ -use anyhow::{bail, Result}; -use orbien_core::net::{try_consume_proxy_protocol, PpConsume, PROXY_PROTOCOL_MAX_HEADER}; +use anyhow::Result; use orbien_core::tls::PrefixedStream; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -use std::time::Duration; -use tokio::io::AsyncReadExt; +use std::net::SocketAddr; use tokio::net::TcpStream; -use tokio::time::timeout; -#[derive(Debug, Clone)] -pub struct AccessPolicy { - pub proxy_protocol: bool, - pub trusted_proxy_cidrs: Vec, - pub deny_src_cidrs: Vec, - pub pp_header_timeout: Duration, -} +#[derive(Debug, Clone, Default)] +pub struct AccessPolicy; impl AccessPolicy { - pub fn from_server_config(cfg: &orbien_core::config::ServerConfig) -> Result { - let trusted = cfg - .proxy_protocol_trusted_cidrs - .iter() - .map(|s| Cidr::parse(s)) - .collect::>>()?; - let deny = cfg - .deny_src_cidrs - .iter() - .map(|s| Cidr::parse(s)) - .collect::>>()?; - if cfg.proxy_protocol && trusted.is_empty() { - tracing::warn!( - "proxyProtocol=true but proxyProtocolTrustedCidrs empty — \ - PP accepted from any peer (spoof risk); set trusted CIDRs in production" - ); - } - Ok(Self { - proxy_protocol: cfg.proxy_protocol, - trusted_proxy_cidrs: trusted, - deny_src_cidrs: deny, - pp_header_timeout: Duration::from_secs(cfg.proxy_protocol_timeout_secs.max(1)), - }) - } - - pub fn is_trusted_proxy(&self, ip: IpAddr) -> bool { - if self.trusted_proxy_cidrs.is_empty() { - return true; - } - self.trusted_proxy_cidrs.iter().any(|c| c.contains(ip)) - } - - pub fn is_denied(&self, ip: IpAddr) -> bool { - !self.deny_src_cidrs.is_empty() && self.deny_src_cidrs.iter().any(|c| c.contains(ip)) + pub fn from_server_config(_cfg: &orbien_core::config::ServerConfig) -> Result { + Ok(Self) } } -pub struct VisitorConn { +pub struct IngressConn { pub stream: PrefixedStream, pub peer: SocketAddr, - pub visitor: SocketAddr, + pub source: SocketAddr, pub local: Option, } -pub async fn prepare_visitor( +pub async fn prepare_ingress( stream: TcpStream, peer: SocketAddr, - policy: &AccessPolicy, -) -> Result { + _policy: &AccessPolicy, +) -> Result { let local = stream.local_addr().ok(); - let (stream, mut visitor) = if policy.proxy_protocol && policy.is_trusted_proxy(peer.ip()) { - read_optional_pp(stream, peer, policy.pp_header_timeout).await? - } else { - (PrefixedStream::new(Vec::new(), stream), peer) - }; - - if visitor.ip().is_unspecified() { - visitor = peer; - } - - if policy.is_denied(visitor.ip()) { - tracing::info!(visitor = %visitor.ip(), peer = %peer, "denied by denySrcCidrs"); - bail!("visitor {} denied by denySrcCidrs", visitor.ip()); - } - - Ok(VisitorConn { - stream, + Ok(IngressConn { + stream: PrefixedStream::new(Vec::new(), stream), peer, - visitor, + source: peer, local, }) } - -async fn read_optional_pp( - mut stream: TcpStream, - peer: SocketAddr, - hdr_timeout: Duration, -) -> Result<(PrefixedStream, SocketAddr)> { - let mut buf = vec![0u8; PROXY_PROTOCOL_MAX_HEADER]; - let mut filled = 0usize; - - loop { - match try_consume_proxy_protocol(&buf[..filled])? { - PpConsume::Done(parsed) => { - let leftover = buf[parsed.header_len..filled].to_vec(); - tracing::debug!( - peer = %peer, - visitor = %parsed.src, - header_len = parsed.header_len, - "PROXY protocol accepted from trusted peer" - ); - return Ok((PrefixedStream::new(leftover, stream), parsed.src)); - } - PpConsume::NotProxy => { - let leftover = buf[..filled].to_vec(); - return Ok((PrefixedStream::new(leftover, stream), peer)); - } - PpConsume::Incomplete => { - if filled >= buf.len() { - bail!("PROXY protocol header incomplete / too large"); - } - let n = timeout(hdr_timeout, stream.read(&mut buf[filled..])) - .await - .map_err(|_| anyhow::anyhow!("timeout waiting for PROXY protocol header"))??; - if n == 0 { - bail!("connection closed while reading PROXY protocol"); - } - filled += n; - } - } - } -} - -#[derive(Debug, Clone)] -pub struct Cidr { - addr: IpAddr, - prefix: u8, -} - -impl Cidr { - pub fn parse(s: &str) -> Result { - let s = s.trim(); - if let Some((ip, pref)) = s.split_once('/') { - let addr: IpAddr = ip.parse()?; - let prefix: u8 = pref.parse()?; - let max = match addr { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, - }; - if prefix > max { - bail!("CIDR prefix {prefix} too large for {addr}"); - } - Ok(Self { addr, prefix }) - } else { - let addr: IpAddr = s.parse()?; - let prefix = match addr { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, - }; - Ok(Self { addr, prefix }) - } - } - - pub fn contains(&self, ip: IpAddr) -> bool { - match (self.addr, ip) { - (IpAddr::V4(net), IpAddr::V4(ip)) => ipv4_in_cidr(net, self.prefix, ip), - (IpAddr::V6(net), IpAddr::V6(ip)) => ipv6_in_cidr(net, self.prefix, ip), - _ => false, - } - } -} - -fn ipv4_in_cidr(net: Ipv4Addr, prefix: u8, ip: Ipv4Addr) -> bool { - if prefix == 0 { - return true; - } - let mask = if prefix >= 32 { - u32::MAX - } else { - !((1u32 << (32 - prefix)) - 1) - }; - (u32::from(net) & mask) == (u32::from(ip) & mask) -} - -fn ipv6_in_cidr(net: Ipv6Addr, prefix: u8, ip: Ipv6Addr) -> bool { - if prefix == 0 { - return true; - } - let net_o = net.octets(); - let ip_o = ip.octets(); - let full = (prefix / 8) as usize; - let rem = prefix % 8; - if net_o[..full] != ip_o[..full] { - return false; - } - if rem == 0 { - return true; - } - let mask = 0xffu8 << (8 - rem); - (net_o[full] & mask) == (ip_o[full] & mask) -} diff --git a/server/src/control/session.rs b/server/src/control/session.rs deleted file mode 100644 index 55edb60..0000000 --- a/server/src/control/session.rs +++ /dev/null @@ -1,512 +0,0 @@ -use crate::access::AccessPolicy; -use crate::metrics::{MemMetrics, ServerMetrics}; -use crate::proxy::{ - format_local_addr, HttpProxy, HttpVhost, HttpsProxy, HttpsVhost, ProxyManager, RegisteredProxy, - TcpProxy, UdpProxy, -}; -use anyhow::{anyhow, Result}; -use orbien_core::config::ServerConfig; -use orbien_core::msg::{ - self, CloseProxy, KickOut, Message, NewProxy, NewProxyResp, Ping, Pong, ReqWorkConn, - StartWorkConn, -}; -use orbien_core::transport::DynStream; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::io::AsyncWriteExt; -use tokio::io::{ReadHalf, WriteHalf}; -use tokio::sync::{mpsc, Mutex, Notify}; -use tokio::task::JoinSet; -use tokio::time::sleep; - -type CtrlRead = ReadHalf; -type CtrlWrite = WriteHalf; - -pub struct Control { - pub run_id: String, - pub user: String, - pub hostname: String, - pub os: String, - pub arch: String, - pub version: String, - pub client_ip: String, - pub connected_at: Instant, - cfg: ServerConfig, - reader: Mutex, - writer: Mutex, - work_tx: mpsc::Sender, - work_rx: Mutex>, - work_notify: Notify, - shutdown_notify: Notify, - proxies: Mutex, - bg_tasks: Mutex>, - closed: AtomicBool, - pool_count: usize, - http_vhost: Option>, - https_vhost: Option>, - access: Arc, - pub metrics: Arc, -} - -impl Control { - pub fn new( - run_id: String, - stream: DynStream, - cfg: ServerConfig, - pool_count: usize, - http_vhost: Option>, - https_vhost: Option>, - access: Arc, - user: String, - hostname: String, - os: String, - arch: String, - version: String, - client_ip: String, - metrics: Arc, - ) -> Self { - let (reader, writer) = tokio::io::split(stream); - let (work_tx, work_rx) = mpsc::channel(64); - Self { - run_id, - user, - hostname, - os, - arch, - version, - client_ip, - connected_at: Instant::now(), - cfg, - reader: Mutex::new(reader), - writer: Mutex::new(writer), - work_tx, - work_rx: Mutex::new(work_rx), - work_notify: Notify::new(), - shutdown_notify: Notify::new(), - proxies: Mutex::new(ProxyManager::new()), - bg_tasks: Mutex::new(JoinSet::new()), - closed: AtomicBool::new(false), - pool_count: pool_count.max(1), - http_vhost, - https_vhost, - access, - metrics, - } - } - - pub async fn proxy_summaries(&self) -> Vec { - self.proxies.lock().await.summaries() - } - - pub async fn proxy_count(&self) -> usize { - self.proxies.lock().await.len() - } - - pub async fn run(self: Arc) -> Result<()> { - for _ in 0..self.pool_count { - if self.closed.load(Ordering::SeqCst) { - return Ok(()); - } - self.request_work_conn().await?; - } - - loop { - if self.closed.load(Ordering::SeqCst) { - break; - } - let msg = tokio::select! { - _ = self.shutdown_notify.notified() => { - break; - } - msg = async { - let mut reader = self.reader.lock().await; - msg::read_msg(&mut *reader).await - } => { - match msg { - Ok(m) => m, - Err(e) => { - if !self.closed.load(Ordering::SeqCst) { - tracing::debug!(error = %e, "control read ended"); - } - break; - } - } - } - }; - - match msg { - Message::NewProxy(np) => self.handle_new_proxy(np).await?, - Message::CloseProxy(cp) => self.handle_close_proxy(cp).await?, - Message::Ping(p) => self.handle_ping(p).await?, - other => { - tracing::warn!(ty = other.type_byte(), "ignored control message"); - } - } - } - Ok(()) - } - - pub async fn shutdown(&self) { - if self.closed.swap(true, Ordering::SeqCst) { - self.shutdown_notify.notify_waiters(); - self.work_notify.notify_waiters(); - return; - } - self.shutdown_notify.notify_waiters(); - self.work_notify.notify_waiters(); - { - let mut pm = self.proxies.lock().await; - for (name, ty) in pm.close_all().await { - self.metrics.close_proxy(&name, ty); - } - } - { - let mut writer = self.writer.lock().await; - let _ = writer.shutdown().await; - } - let mut bg = self.bg_tasks.lock().await; - bg.abort_all(); - while bg.join_next().await.is_some() {} - } - - pub async fn kick(&self, reason: impl Into) { - let reason = reason.into(); - { - let mut writer = self.writer.lock().await; - let _ = msg::write_msg( - &mut *writer, - &Message::KickOut(KickOut { - reason: reason.clone(), - }), - ) - .await; - } - tracing::info!(run_id = %self.run_id, %reason, "kicking client"); - self.shutdown().await; - } - - fn note_proxy_registered(&self, name: &str, proxy_type: &str) { - self.metrics - .new_proxy(name, proxy_type, &self.user, &self.run_id); - } - - pub async fn push_work_conn(&self, stream: DynStream) { - let _ = self.work_tx.send(stream).await; - self.work_notify.notify_waiters(); - } - - async fn try_pop_work(&self) -> Option { - let mut rx = self.work_rx.lock().await; - rx.try_recv().ok() - } - - async fn spawn_refill(self: &Arc) { - let ctl = Arc::clone(self); - self.bg_tasks.lock().await.spawn(async move { - if ctl.closed.load(Ordering::SeqCst) { - return; - } - let _ = ctl.request_work_conn().await; - }); - } - - pub async fn get_work_conn(self: &Arc) -> Result { - if let Some(conn) = self.try_pop_work().await { - self.spawn_refill().await; - return Ok(conn); - } - - self.request_work_conn().await?; - - let deadline = Instant::now() + Duration::from_secs(10); - loop { - if self.closed.load(Ordering::SeqCst) { - return Err(anyhow!("control closed while waiting for work conn")); - } - if let Some(conn) = self.try_pop_work().await { - self.spawn_refill().await; - return Ok(conn); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(anyhow!("timeout waiting for work conn")); - } - tokio::select! { - _ = self.work_notify.notified() => {} - _ = sleep(remaining.min(Duration::from_millis(100))) => {} - } - } - } - - async fn request_work_conn(&self) -> Result<()> { - if self.closed.load(Ordering::SeqCst) { - return Err(anyhow!("control closed")); - } - let mut writer = self.writer.lock().await; - msg::write_msg(&mut *writer, &Message::ReqWorkConn(ReqWorkConn {})).await?; - Ok(()) - } - - async fn handle_new_proxy(self: &Arc, np: NewProxy) -> Result<()> { - let resp = match self.register_proxy(&np).await { - Ok(remote_addr) => NewProxyResp { - proxy_name: np.proxy_name.clone(), - remote_addr, - error: String::new(), - }, - Err(e) => NewProxyResp { - proxy_name: np.proxy_name.clone(), - remote_addr: String::new(), - error: e.to_string(), - }, - }; - - let mut writer = self.writer.lock().await; - msg::write_msg(&mut *writer, &Message::NewProxyResp(resp)).await?; - Ok(()) - } - - async fn register_proxy(self: &Arc, np: &NewProxy) -> Result { - match np.proxy_type.as_str() { - "tcp" => self.register_tcp_proxy(np).await, - "http" => self.register_http_proxy(np).await, - "https" => self.register_https_proxy(np).await, - "udp" => self.register_udp_proxy(np).await, - other => Err(anyhow!("unsupported proxy type: {other}")), - } - } - - async fn register_tcp_proxy(self: &Arc, np: &NewProxy) -> Result { - if np.remote_port <= 0 || np.remote_port > 65535 { - return Err(anyhow!("invalid remote_port")); - } - - let limiter = orbien_core::limit::limiter_if_mode( - &np.bandwidth_limit, - &np.bandwidth_limit_mode, - orbien_core::limit::BandwidthLimitMode::Server, - )?; - if let Some(ref l) = limiter { - tracing::info!( - proxy = %np.proxy_name, - bytes_per_sec = l.bytes_per_sec(), - mode = "server", - "bandwidth limit enabled" - ); - } - - let bind_addr = self.cfg.proxy_bind_addr.clone(); - let remote_port = np.remote_port as u16; - let name = np.proxy_name.clone(); - let control = Arc::clone(self); - - let proxy = TcpProxy::start( - name.clone(), - bind_addr, - remote_port, - control, - limiter, - Arc::clone(&self.access), - ) - .await?; - let remote_addr = format!(":{}", remote_port); - - let local_addr = format_local_addr(&np.local_ip, np.local_port); - let mut pm = self.proxies.lock().await; - if let Some(old_ty) = pm - .insert(name.clone(), RegisteredProxy::Tcp(proxy), local_addr) - .await - { - self.metrics.close_proxy(&name, old_ty); - } - self.note_proxy_registered(&name, "tcp"); - tracing::info!(proxy = %np.proxy_name, port = remote_port, "tcp proxy registered"); - Ok(remote_addr) - } - - async fn register_http_proxy(self: &Arc, np: &NewProxy) -> Result { - let vhost = self - .http_vhost - .clone() - .ok_or_else(|| anyhow!("http proxy requires server vhostHTTPPort > 0"))?; - - let limiter = orbien_core::limit::limiter_if_mode( - &np.bandwidth_limit, - &np.bandwidth_limit_mode, - orbien_core::limit::BandwidthLimitMode::Server, - )?; - if let Some(ref l) = limiter { - tracing::info!( - proxy = %np.proxy_name, - bytes_per_sec = l.bytes_per_sec(), - mode = "server", - "bandwidth limit enabled" - ); - } - - let proxy = HttpProxy::register( - np, - Arc::clone(self), - Arc::clone(&vhost), - &self.cfg.sub_domain_host, - limiter, - ) - .await?; - - let remote_addr = proxy - .domains - .iter() - .map(|d| format!("{d}:{}", vhost.listen_port)) - .collect::>() - .join(","); - - let name = np.proxy_name.clone(); - let local_addr = format_local_addr(&np.local_ip, np.local_port); - let mut pm = self.proxies.lock().await; - if let Some(old_ty) = pm - .insert(name.clone(), RegisteredProxy::Http(proxy), local_addr) - .await - { - self.metrics.close_proxy(&name, old_ty); - } - self.note_proxy_registered(&name, "http"); - Ok(remote_addr) - } - - async fn register_https_proxy(self: &Arc, np: &NewProxy) -> Result { - let vhost = self - .https_vhost - .clone() - .ok_or_else(|| anyhow!("https proxy requires server vhostHTTPSPort > 0"))?; - - let limiter = orbien_core::limit::limiter_if_mode( - &np.bandwidth_limit, - &np.bandwidth_limit_mode, - orbien_core::limit::BandwidthLimitMode::Server, - )?; - if let Some(ref l) = limiter { - tracing::info!( - proxy = %np.proxy_name, - bytes_per_sec = l.bytes_per_sec(), - mode = "server", - "bandwidth limit enabled" - ); - } - - let proxy = HttpsProxy::register( - np, - Arc::clone(self), - Arc::clone(&vhost), - &self.cfg.sub_domain_host, - limiter, - ) - .await?; - - let remote_addr = proxy - .domains - .iter() - .map(|d| format!("{d}:{}", vhost.listen_port)) - .collect::>() - .join(","); - - let name = np.proxy_name.clone(); - let local_addr = format_local_addr(&np.local_ip, np.local_port); - let mut pm = self.proxies.lock().await; - if let Some(old_ty) = pm - .insert(name.clone(), RegisteredProxy::Https(proxy), local_addr) - .await - { - self.metrics.close_proxy(&name, old_ty); - } - self.note_proxy_registered(&name, "https"); - Ok(remote_addr) - } - - async fn register_udp_proxy(self: &Arc, np: &NewProxy) -> Result { - if np.remote_port <= 0 || np.remote_port > 65535 { - return Err(anyhow!("invalid remote_port")); - } - - let limiter = orbien_core::limit::limiter_if_mode( - &np.bandwidth_limit, - &np.bandwidth_limit_mode, - orbien_core::limit::BandwidthLimitMode::Server, - )?; - if let Some(ref l) = limiter { - tracing::info!( - proxy = %np.proxy_name, - bytes_per_sec = l.bytes_per_sec(), - mode = "server", - "bandwidth limit enabled" - ); - } - - let bind_addr = self.cfg.proxy_bind_addr.clone(); - let remote_port = np.remote_port as u16; - let name = np.proxy_name.clone(); - let control = Arc::clone(self); - let packet_size = self.cfg.udp_packet_size.max(512); - - let proxy = UdpProxy::start( - name.clone(), - bind_addr, - remote_port, - control, - limiter, - packet_size, - ) - .await?; - let remote_addr = format!(":{}", remote_port); - - let local_addr = format_local_addr(&np.local_ip, np.local_port); - let mut pm = self.proxies.lock().await; - if let Some(old_ty) = pm - .insert(name.clone(), RegisteredProxy::Udp(proxy), local_addr) - .await - { - self.metrics.close_proxy(&name, old_ty); - } - self.note_proxy_registered(&name, "udp"); - tracing::info!(proxy = %np.proxy_name, port = remote_port, "udp proxy registered"); - Ok(remote_addr) - } - - async fn handle_close_proxy(&self, cp: CloseProxy) -> Result<()> { - let mut pm = self.proxies.lock().await; - if let Some(ty) = pm.remove(&cp.proxy_name).await { - self.metrics.close_proxy(&cp.proxy_name, ty); - } - Ok(()) - } - - async fn handle_ping(&self, _p: Ping) -> Result<()> { - let mut writer = self.writer.lock().await; - msg::write_msg(&mut *writer, &Message::Pong(Pong::default())).await?; - Ok(()) - } - - pub async fn start_work_conn( - &self, - mut work: DynStream, - proxy_name: &str, - src_addr: String, - src_port: u16, - dst_addr: String, - dst_port: u16, - ) -> Result { - msg::write_msg( - &mut work, - &Message::StartWorkConn(StartWorkConn { - proxy_name: proxy_name.to_string(), - src_addr, - src_port, - dst_addr, - dst_port, - error: String::new(), - }), - ) - .await?; - Ok(work) - } -} diff --git a/server/src/control/session/data_pool.rs b/server/src/control/session/data_pool.rs new file mode 100644 index 0000000..c5b812c --- /dev/null +++ b/server/src/control/session/data_pool.rs @@ -0,0 +1,91 @@ +use super::Control; +use anyhow::{anyhow, Result}; +use orbien_core::msg::{self, Message, ReqDataConn, StartDataConn}; +use orbien_core::transport::DynStream; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::time::sleep; + +impl Control { + pub async fn push_data_conn(&self, stream: DynStream) { + let _ = self.data_tx.send(stream).await; + self.data_notify.notify_waiters(); + } + + async fn try_pop_data(&self) -> Option { + let mut rx = self.data_rx.lock().await; + rx.try_recv().ok() + } + + async fn spawn_refill(self: &Arc) { + let ctl = Arc::clone(self); + self.bg_tasks.lock().await.spawn(async move { + if ctl.closed.load(Ordering::SeqCst) { + return; + } + let _ = ctl.request_data_conn().await; + }); + } + + pub async fn get_data_conn(self: &Arc) -> Result { + if let Some(conn) = self.try_pop_data().await { + self.spawn_refill().await; + return Ok(conn); + } + + self.request_data_conn().await?; + + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if self.closed.load(Ordering::SeqCst) { + return Err(anyhow!("control closed while waiting for data conn")); + } + if let Some(conn) = self.try_pop_data().await { + self.spawn_refill().await; + return Ok(conn); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(anyhow!("timeout waiting for data conn")); + } + tokio::select! { + _ = self.data_notify.notified() => {} + _ = sleep(remaining.min(Duration::from_millis(100))) => {} + } + } + } + + pub(super) async fn request_data_conn(&self) -> Result<()> { + if self.closed.load(Ordering::SeqCst) { + return Err(anyhow!("control closed")); + } + let mut writer = self.writer.lock().await; + msg::write_msg(&mut *writer, &Message::ReqDataConn(ReqDataConn {})).await?; + Ok(()) + } + + pub async fn start_data_conn( + &self, + mut data: DynStream, + tunnel_name: &str, + src_addr: String, + src_port: u16, + dst_addr: String, + dst_port: u16, + ) -> Result { + msg::write_msg( + &mut data, + &Message::StartDataConn(StartDataConn { + tunnel_name: tunnel_name.to_string(), + src_addr, + src_port, + dst_addr, + dst_port, + error: String::new(), + }), + ) + .await?; + Ok(data) + } +} diff --git a/server/src/control/session/mod.rs b/server/src/control/session/mod.rs new file mode 100644 index 0000000..12dcc79 --- /dev/null +++ b/server/src/control/session/mod.rs @@ -0,0 +1,248 @@ +mod data_pool; +mod register; + +use crate::access::AccessPolicy; +use crate::metrics::{MemMetrics, ServerMetrics}; +use crate::tunnel::{HttpGw, HttpsGw, TunnelManager}; +use anyhow::Result; +use orbien_core::config::ServerConfig; +use orbien_core::msg::{self, KickOut, Message, Ping, Pong}; +use orbien_core::transport::DynStream; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::io::AsyncWriteExt; +use tokio::io::{ReadHalf, WriteHalf}; +use tokio::sync::{mpsc, Mutex, Notify}; +use tokio::task::JoinSet; +use tokio::time::sleep; + +type CtrlRead = ReadHalf; +type CtrlWrite = WriteHalf; + +pub struct Control { + pub session_id: String, + pub user: String, + pub hostname: String, + pub os: String, + pub arch: String, + pub version: String, + pub client_ip: String, + pub connected_at: Instant, + cfg: ServerConfig, + reader: Mutex, + writer: Mutex, + data_tx: mpsc::Sender, + data_rx: Mutex>, + data_notify: Notify, + shutdown_notify: Notify, + tunnels: Mutex, + bg_tasks: Mutex>, + closed: AtomicBool, + pool_count: usize, + http_gw: Option>, + https_gw: Option>, + access: Arc, + pub metrics: Arc, + last_ping_unix: AtomicI64, +} + +impl Control { + pub fn new( + session_id: String, + stream: DynStream, + cfg: ServerConfig, + pool_count: usize, + http_gw: Option>, + https_gw: Option>, + access: Arc, + user: String, + hostname: String, + os: String, + arch: String, + version: String, + client_ip: String, + metrics: Arc, + ) -> Self { + let (reader, writer) = tokio::io::split(stream); + let (data_tx, data_rx) = mpsc::channel(64); + Self { + session_id, + user, + hostname, + os, + arch, + version, + client_ip, + connected_at: Instant::now(), + cfg, + reader: Mutex::new(reader), + writer: Mutex::new(writer), + data_tx, + data_rx: Mutex::new(data_rx), + data_notify: Notify::new(), + shutdown_notify: Notify::new(), + tunnels: Mutex::new(TunnelManager::new()), + bg_tasks: Mutex::new(JoinSet::new()), + closed: AtomicBool::new(false), + pool_count: pool_count.max(1), + http_gw, + https_gw, + access, + metrics, + last_ping_unix: AtomicI64::new( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + ), + } + } + + pub async fn tunnel_summaries(&self) -> Vec { + self.tunnels.lock().await.summaries() + } + + pub async fn tunnel_count(&self) -> usize { + self.tunnels.lock().await.len() + } + + pub async fn run(self: Arc) -> Result<()> { + for _ in 0..self.pool_count { + if self.closed.load(Ordering::SeqCst) { + return Ok(()); + } + self.request_data_conn().await?; + } + + { + let timeout = self.effective_ping_timeout(); + if timeout > 0 { + let this = Arc::clone(&self); + self.bg_tasks.lock().await.spawn(async move { + loop { + if this.closed.load(Ordering::SeqCst) { + break; + } + sleep(Duration::from_secs(1)).await; + let last = this.last_ping_unix.load(Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if last > 0 && now.saturating_sub(last) > timeout { + tracing::warn!( + session_id = %this.session_id, + timeout_secs = timeout, + "heartbeat timeout" + ); + this.shutdown().await; + break; + } + } + }); + } + } + + loop { + if self.closed.load(Ordering::SeqCst) { + break; + } + let msg = tokio::select! { + _ = self.shutdown_notify.notified() => { + break; + } + msg = async { + let mut reader = self.reader.lock().await; + msg::read_msg(&mut *reader).await + } => { + match msg { + Ok(m) => m, + Err(e) => { + if !self.closed.load(Ordering::SeqCst) { + tracing::debug!(error = %e, "control read ended"); + } + break; + } + } + } + }; + + match msg { + Message::NewTunnel(np) => self.handle_new_tunnel(np).await?, + Message::CloseTunnel(cp) => self.handle_close_tunnel(cp).await?, + Message::Ping(p) => self.handle_ping(p).await?, + other => { + tracing::warn!(ty = other.type_byte(), "ignored control message"); + } + } + } + Ok(()) + } + + pub async fn shutdown(&self) { + if self.closed.swap(true, Ordering::SeqCst) { + self.shutdown_notify.notify_waiters(); + self.data_notify.notify_waiters(); + return; + } + self.shutdown_notify.notify_waiters(); + self.data_notify.notify_waiters(); + { + let mut tm = self.tunnels.lock().await; + for (name, ty) in tm.close_all().await { + self.metrics.close_tunnel(&name, ty); + } + } + { + let mut writer = self.writer.lock().await; + let _ = writer.shutdown().await; + } + let mut bg = self.bg_tasks.lock().await; + bg.abort_all(); + while bg.join_next().await.is_some() {} + } + + pub async fn kick(&self, reason: impl Into) { + let reason = reason.into(); + { + let mut writer = self.writer.lock().await; + let _ = msg::write_msg( + &mut *writer, + &Message::KickOut(KickOut { + reason: reason.clone(), + }), + ) + .await; + } + tracing::info!(session_id = %self.session_id, %reason, "kicking client"); + self.shutdown().await; + } + + fn effective_ping_timeout(&self) -> i64 { + let hb_to = self.cfg.transport.heartbeat_timeout; + if hb_to > 0 { + return hb_to; + } + if self.cfg.transport.tcp_mux { + let mux_ka = self.cfg.transport.mux_keepalive_secs; + if mux_ka > 0 { + return mux_ka.saturating_mul(3); + } + } + -1 + } + + async fn handle_ping(&self, _p: Ping) -> Result<()> { + self.last_ping_unix.store( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + Ordering::Relaxed, + ); + let mut writer = self.writer.lock().await; + msg::write_msg(&mut *writer, &Message::Pong(Pong::default())).await?; + Ok(()) + } +} diff --git a/server/src/control/session/register.rs b/server/src/control/session/register.rs new file mode 100644 index 0000000..6c0e192 --- /dev/null +++ b/server/src/control/session/register.rs @@ -0,0 +1,263 @@ +use super::Control; +use crate::metrics::ServerMetrics; +use crate::tunnel::{ + format_local_addr, HttpTunnel, HttpsTunnel, RegisteredTunnel, TcpTunnel, UdpTunnel, +}; +use anyhow::{anyhow, Result}; +use orbien_core::msg::{self, CloseTunnel, Message, NewTunnel, NewTunnelResp}; +use std::sync::Arc; + +impl Control { + fn note_tunnel_registered(&self, name: &str, tunnel_type: &str) { + self.metrics + .new_tunnel(name, tunnel_type, &self.user, &self.session_id); + } + + pub(super) async fn handle_new_tunnel(self: &Arc, np: NewTunnel) -> Result<()> { + let resp = match self.register_tunnel(&np).await { + Ok(remote_addr) => NewTunnelResp { + tunnel_name: np.tunnel_name.clone(), + remote_addr, + error: String::new(), + }, + Err(e) => NewTunnelResp { + tunnel_name: np.tunnel_name.clone(), + remote_addr: String::new(), + error: e.to_string(), + }, + }; + + let mut writer = self.writer.lock().await; + msg::write_msg(&mut *writer, &Message::NewTunnelResp(resp)).await?; + Ok(()) + } + + async fn register_tunnel(self: &Arc, np: &NewTunnel) -> Result { + match np.protocol.as_str() { + "tcp" => self.register_tcp_tunnel(np).await, + "http" => self.register_http_tunnel(np).await, + "https" => self.register_https_tunnel(np).await, + "udp" => self.register_udp_tunnel(np).await, + other => Err(anyhow!("unsupported tunnel protocol: {other}")), + } + } + + async fn register_tcp_tunnel(self: &Arc, np: &NewTunnel) -> Result { + if np.remote_port <= 0 || np.remote_port > 65535 { + return Err(anyhow!("invalid remote_port")); + } + + let limiter = orbien_core::limit::limiter_if_side( + np.bandwidth, + &np.bandwidth_limit_side, + orbien_core::limit::BandwidthLimitSide::Server, + )?; + if let Some(ref l) = limiter { + tracing::info!( + tunnel = %np.tunnel_name, + bytes_per_sec = l.bytes_per_sec(), + mode = "server", + "bandwidth limit enabled" + ); + } + + let bind_addr = self.cfg.proxy_addr.clone(); + let remote_port = np.remote_port as u16; + let name = np.tunnel_name.clone(); + let control = Arc::clone(self); + + { + let mut tm = self.tunnels.lock().await; + if let Some(old_ty) = tm.remove(&name).await { + self.metrics.close_tunnel(&name, old_ty); + } + } + + let tunnel = TcpTunnel::start( + name.clone(), + bind_addr, + remote_port, + control, + limiter, + Arc::clone(&self.access), + ) + .await?; + let remote_addr = format!(":{}", remote_port); + + let local_addr = format_local_addr(&np.local_ip, np.local_port); + let mut tm = self.tunnels.lock().await; + let _ = tm + .insert(name.clone(), RegisteredTunnel::Tcp(tunnel), local_addr) + .await; + self.note_tunnel_registered(&name, "tcp"); + tracing::info!(tunnel = %np.tunnel_name, port = remote_port, "tcp tunnel registered"); + Ok(remote_addr) + } + + async fn register_http_tunnel(self: &Arc, np: &NewTunnel) -> Result { + let gw = self + .http_gw + .clone() + .ok_or_else(|| anyhow!("http tunnel requires server httpGwPort > 0"))?; + + let limiter = orbien_core::limit::limiter_if_side( + np.bandwidth, + &np.bandwidth_limit_side, + orbien_core::limit::BandwidthLimitSide::Server, + )?; + if let Some(ref l) = limiter { + tracing::info!( + tunnel = %np.tunnel_name, + bytes_per_sec = l.bytes_per_sec(), + mode = "server", + "bandwidth limit enabled" + ); + } + + let name = np.tunnel_name.clone(); + { + let mut tm = self.tunnels.lock().await; + if let Some(old_ty) = tm.remove(&name).await { + self.metrics.close_tunnel(&name, old_ty); + } + } + + let tunnel = HttpTunnel::register( + np, + Arc::clone(self), + Arc::clone(&gw), + &self.cfg.root_domain, + limiter, + ) + .await?; + + let remote_addr = tunnel + .domains + .iter() + .map(|d| format!("{d}:{}", gw.listen_port)) + .collect::>() + .join(","); + + let local_addr = format_local_addr(&np.local_ip, np.local_port); + let mut tm = self.tunnels.lock().await; + let _ = tm + .insert(name.clone(), RegisteredTunnel::Http(tunnel), local_addr) + .await; + self.note_tunnel_registered(&name, "http"); + Ok(remote_addr) + } + + async fn register_https_tunnel(self: &Arc, np: &NewTunnel) -> Result { + let gw = self + .https_gw + .clone() + .ok_or_else(|| anyhow!("https tunnel requires server httpsGwPort > 0"))?; + + let limiter = orbien_core::limit::limiter_if_side( + np.bandwidth, + &np.bandwidth_limit_side, + orbien_core::limit::BandwidthLimitSide::Server, + )?; + if let Some(ref l) = limiter { + tracing::info!( + tunnel = %np.tunnel_name, + bytes_per_sec = l.bytes_per_sec(), + mode = "server", + "bandwidth limit enabled" + ); + } + + let name = np.tunnel_name.clone(); + { + let mut tm = self.tunnels.lock().await; + if let Some(old_ty) = tm.remove(&name).await { + self.metrics.close_tunnel(&name, old_ty); + } + } + + let tunnel = HttpsTunnel::register( + np, + Arc::clone(self), + Arc::clone(&gw), + &self.cfg.root_domain, + limiter, + ) + .await?; + + let remote_addr = tunnel + .domains + .iter() + .map(|d| format!("{d}:{}", gw.listen_port)) + .collect::>() + .join(","); + + let local_addr = format_local_addr(&np.local_ip, np.local_port); + let mut tm = self.tunnels.lock().await; + let _ = tm + .insert(name.clone(), RegisteredTunnel::Https(tunnel), local_addr) + .await; + self.note_tunnel_registered(&name, "https"); + Ok(remote_addr) + } + + async fn register_udp_tunnel(self: &Arc, np: &NewTunnel) -> Result { + if np.remote_port <= 0 || np.remote_port > 65535 { + return Err(anyhow!("invalid remote_port")); + } + + let limiter = orbien_core::limit::limiter_if_side( + np.bandwidth, + &np.bandwidth_limit_side, + orbien_core::limit::BandwidthLimitSide::Server, + )?; + if let Some(ref l) = limiter { + tracing::info!( + tunnel = %np.tunnel_name, + bytes_per_sec = l.bytes_per_sec(), + mode = "server", + "bandwidth limit enabled" + ); + } + + let bind_addr = self.cfg.proxy_addr.clone(); + let remote_port = np.remote_port as u16; + let name = np.tunnel_name.clone(); + let control = Arc::clone(self); + let packet_size = self.cfg.udp_packet_size.max(512); + + { + let mut tm = self.tunnels.lock().await; + if let Some(old_ty) = tm.remove(&name).await { + self.metrics.close_tunnel(&name, old_ty); + } + } + + let tunnel = UdpTunnel::start( + name.clone(), + bind_addr, + remote_port, + control, + limiter, + packet_size, + ) + .await?; + let remote_addr = format!(":{}", remote_port); + + let local_addr = format_local_addr(&np.local_ip, np.local_port); + let mut tm = self.tunnels.lock().await; + let _ = tm + .insert(name.clone(), RegisteredTunnel::Udp(tunnel), local_addr) + .await; + self.note_tunnel_registered(&name, "udp"); + tracing::info!(tunnel = %np.tunnel_name, port = remote_port, "udp tunnel registered"); + Ok(remote_addr) + } + + pub(super) async fn handle_close_tunnel(&self, cp: CloseTunnel) -> Result<()> { + let mut tm = self.tunnels.lock().await; + if let Some(ty) = tm.remove(&cp.tunnel_name).await { + self.metrics.close_tunnel(&cp.tunnel_name, ty); + } + Ok(()) + } +} diff --git a/server/src/dashboard/mod.rs b/server/src/dashboard/mod.rs index 0ea762f..4d25dab 100644 --- a/server/src/dashboard/mod.rs +++ b/server/src/dashboard/mod.rs @@ -4,14 +4,14 @@ mod routes; use crate::service::Service; use anyhow::Result; use axum::middleware; -use orbien_core::config::WebServerConfig; +use orbien_core::config::DashboardConfig; use std::sync::Arc; use tokio::net::TcpListener; -pub async fn run(svc: Arc, cfg: WebServerConfig) -> Result<()> { +pub async fn run(svc: Arc, cfg: DashboardConfig) -> Result<()> { let addr = format!("{}:{}", cfg.addr, cfg.port); let listener = TcpListener::bind(&addr).await?; - tracing::info!(%addr, user = %cfg.user, "webServer dashboard listening"); + tracing::info!(%addr, user = %cfg.user, "dashboard listening"); let state = Arc::new(DashState { svc, cfg }); let app = routes::router(state.clone()) @@ -25,5 +25,5 @@ pub async fn run(svc: Arc, cfg: WebServerConfig) -> Result<()> { #[derive(Clone)] pub struct DashState { pub svc: Arc, - pub cfg: WebServerConfig, + pub cfg: DashboardConfig, } diff --git a/server/src/dashboard/model.rs b/server/src/dashboard/model.rs index 510af30..f04b92b 100644 --- a/server/src/dashboard/model.rs +++ b/server/src/dashboard/model.rs @@ -35,26 +35,23 @@ pub struct SystemInfo { #[derive(Serialize)] pub struct SystemConfig { - #[serde(rename = "bindAddr")] - pub bind_addr: String, - #[serde(rename = "bindPort")] - pub bind_port: u16, - #[serde(rename = "quicBindPort")] - pub quic_bind_port: u16, - #[serde(rename = "kcpBindPort")] - pub kcp_bind_port: u16, - #[serde(rename = "vhostHTTPPort")] - pub vhost_http_port: u16, - #[serde(rename = "vhostHTTPSPort")] - pub vhost_https_port: u16, - #[serde(rename = "subDomainHost")] - pub sub_domain_host: String, + pub listen: String, + #[serde(rename = "quicPort")] + pub quic_port: u16, + #[serde(rename = "kcpPort")] + pub kcp_port: u16, + #[serde(rename = "httpGwPort")] + pub http_gw_port: u16, + #[serde(rename = "httpsGwPort")] + pub https_gw_port: u16, + #[serde(rename = "rootDomain")] + pub root_domain: String, #[serde(rename = "tcpMux")] pub tcp_mux: bool, #[serde(rename = "tlsForce")] pub tls_force: bool, - #[serde(rename = "maxPoolCount")] - pub max_pool_count: i64, + #[serde(rename = "maxConnPool")] + pub max_conn_pool: i64, #[serde(rename = "heartbeatTimeout")] pub heartbeat_timeout: i64, } @@ -65,10 +62,10 @@ pub struct SystemStatus { pub client_counts: usize, #[serde(rename = "totalClientCounts")] pub total_client_counts: usize, - #[serde(rename = "proxyTypeCount")] - pub proxy_type_count: std::collections::BTreeMap, - #[serde(rename = "curConns")] - pub cur_conns: usize, + #[serde(rename = "tunnelTypeCount")] + pub tunnel_type_count: std::collections::BTreeMap, + #[serde(rename = "activeConns")] + pub active_conns: usize, #[serde(rename = "totalTrafficIn")] pub total_traffic_in: u64, #[serde(rename = "totalTrafficOut")] @@ -77,8 +74,8 @@ pub struct SystemStatus { #[derive(Serialize)] pub struct ClientInfo { - #[serde(rename = "runId")] - pub run_id: String, + #[serde(rename = "sessionId")] + pub session_id: String, pub user: String, pub hostname: String, pub os: String, @@ -86,39 +83,39 @@ pub struct ClientInfo { #[serde(rename = "clientIP")] pub client_ip: String, pub version: String, - #[serde(rename = "proxyCount")] - pub proxy_count: usize, - #[serde(rename = "curConns")] - pub cur_conns: usize, + #[serde(rename = "tunnelCount")] + pub tunnel_count: usize, + #[serde(rename = "activeConns")] + pub active_conns: usize, #[serde(rename = "connectedSecs")] pub connected_secs: u64, pub status: String, } #[derive(Serialize)] -pub struct ProxyInfo { +pub struct TunnelInfo { pub name: String, #[serde(rename = "type")] - pub proxy_type: String, + pub tunnel_type: String, #[serde(rename = "remoteAddr")] pub remote_addr: String, #[serde(rename = "localAddr")] pub local_addr: String, - #[serde(rename = "clientId")] - pub client_id: String, + #[serde(rename = "sessionId")] + pub session_id: String, pub status: String, #[serde(rename = "todayTrafficIn")] pub today_traffic_in: u64, #[serde(rename = "todayTrafficOut")] pub today_traffic_out: u64, - #[serde(rename = "curConns")] - pub cur_conns: usize, + #[serde(rename = "activeConns")] + pub active_conns: usize, #[serde(rename = "lastStartTime", skip_serializing_if = "Option::is_none")] pub last_start_time: Option, } #[derive(Serialize)] -pub struct ProxyTrafficPoint { +pub struct TunnelTrafficPoint { pub date: String, #[serde(rename = "trafficIn")] pub traffic_in: u64, @@ -127,9 +124,9 @@ pub struct ProxyTrafficPoint { } #[derive(Serialize)] -pub struct ProxyTrafficResp { +pub struct TunnelTrafficResp { pub name: String, pub unit: &'static str, pub granularity: &'static str, - pub history: Vec, + pub history: Vec, } diff --git a/server/src/dashboard/routes.rs b/server/src/dashboard/routes.rs index feff54d..523a22b 100644 --- a/server/src/dashboard/routes.rs +++ b/server/src/dashboard/routes.rs @@ -1,14 +1,14 @@ use super::model::{ - ApiResponse, ClientInfo, Page, ProxyInfo, ProxyTrafficPoint, ProxyTrafficResp, SystemConfig, + ApiResponse, ClientInfo, Page, TunnelInfo, TunnelTrafficPoint, TunnelTrafficResp, SystemConfig, SystemInfo, SystemStatus, }; use super::DashState; -use crate::metrics::{ProxyTrafficHistory, TrafficWindow}; +use crate::metrics::{TunnelTrafficHistory, TrafficWindow}; use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; use axum::middleware::Next; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use base64::Engine; @@ -27,29 +27,17 @@ pub fn router(state: Arc) -> Router { .route("/healthz", get(|| async { "ok" })) .route("/", get(index_html)) .route("/favicon.ico", get(favicon)) - .route("/static", get(|| async { Redirect::permanent("/") })) - .route("/static/", get(|| async { Redirect::permanent("/") })) - .route("/static/{*path}", get(redirect_legacy_static)) .route("/api/v1/system/info", get(system_info)) .route("/api/v1/system/traffic", get(system_traffic)) .route("/api/v1/clients", get(list_clients)) - .route("/api/v1/clients/{run_id}", get(get_client)) - .route("/api/v1/clients/{run_id}/kick", post(kick_client)) - .route("/api/v1/proxies", get(list_proxies)) - .route("/api/v1/proxies/{name}/traffic", get(proxy_traffic)) + .route("/api/v1/clients/{session_id}", get(get_client)) + .route("/api/v1/clients/{session_id}/kick", post(kick_client)) + .route("/api/v1/tunnels", get(list_tunnels)) + .route("/api/v1/tunnels/{name}/traffic", get(tunnel_traffic)) .route("/{*path}", get(static_file)) .with_state(state) } -async fn redirect_legacy_static(Path(path): Path) -> Redirect { - let rel = path.trim_start_matches('/'); - if rel.is_empty() { - Redirect::permanent("/") - } else { - Redirect::permanent(&format!("/{rel}")) - } -} - pub async fn basic_auth( State(state): State>, req: Request, @@ -99,14 +87,14 @@ fn authorized(state: &DashState, headers: &HeaderMap) -> bool { } async fn index_html(State(state): State>) -> Response { - if let Some(bytes) = load_override(&state.cfg.assets_dir, "index.html") { + if let Some(bytes) = load_override(&state.cfg.static_dir, "index.html") { return bytes_response("text/html; charset=utf-8", bytes); } serve_asset("index.html") } async fn favicon(State(state): State>) -> Response { - if let Some(bytes) = load_override(&state.cfg.assets_dir, "favicon.ico") { + if let Some(bytes) = load_override(&state.cfg.static_dir, "favicon.ico") { return bytes_response("image/x-icon", bytes); } if let Some(res) = try_embedded("favicon.ico") { @@ -117,7 +105,7 @@ async fn favicon(State(state): State>) -> Response { async fn static_file(State(state): State>, Path(path): Path) -> Response { let rel = path.trim_start_matches('/'); - if let Some(bytes) = load_override(&state.cfg.assets_dir, rel) { + if let Some(bytes) = load_override(&state.cfg.static_dir, rel) { return bytes_response(content_type(rel), bytes); } @@ -169,15 +157,15 @@ fn traffic_window(q: &TrafficQuery) -> TrafficWindow { TrafficWindow::parse(&q.range) } -fn traffic_resp(hist: ProxyTrafficHistory) -> ProxyTrafficResp { - ProxyTrafficResp { +fn traffic_resp(hist: TunnelTrafficHistory) -> TunnelTrafficResp { + TunnelTrafficResp { name: hist.name, unit: hist.unit, granularity: hist.granularity, history: hist .history .into_iter() - .map(|p| ProxyTrafficPoint { + .map(|p| TunnelTrafficPoint { date: p.date, traffic_in: p.traffic_in, traffic_out: p.traffic_out, @@ -191,16 +179,15 @@ async fn system_info(State(state): State>) -> Json>) -> Json>) -> Json>, Query(q): Query, -) -> Json> { +) -> Json> { let hist = state.svc.metrics().server_traffic(traffic_window(&q)); Json(ApiResponse::ok(traffic_resp(hist))) } @@ -250,11 +237,11 @@ async fn list_clients( async fn get_client( State(state): State>, - Path(run_id): Path, + Path(session_id): Path, ) -> Result>, StatusCode> { - let run_id = urlencoding_decode(&run_id); + let session_id = urlencoding_decode(&session_id); let snap = state.svc.dashboard_snapshot().await; - match snap.clients.into_iter().find(|c| c.run_id == run_id) { + match snap.clients.into_iter().find(|c| c.session_id == session_id) { Some(c) => Ok(Json(ApiResponse::ok(c))), None => Err(StatusCode::NOT_FOUND), } @@ -262,10 +249,10 @@ async fn get_client( async fn kick_client( State(state): State>, - Path(run_id): Path, + Path(session_id): Path, ) -> Json> { - let run_id = urlencoding_decode(&run_id); - match state.svc.kick_client(&run_id).await { + let session_id = urlencoding_decode(&session_id); + match state.svc.kick_client(&session_id).await { Ok(()) => Json(ApiResponse::ok(())), Err(e) => Json(ApiResponse { code: 404, @@ -276,30 +263,30 @@ async fn kick_client( } #[derive(Deserialize)] -struct ProxyListQuery { +struct TunnelListQuery { #[serde(default = "default_page")] page: usize, #[serde(default = "default_page_size", rename = "pageSize")] page_size: usize, - #[serde(default, rename = "clientId")] - client_id: String, + #[serde(default, rename = "sessionId")] + session_id: String, #[serde(default)] q: String, } -async fn list_proxies( +async fn list_tunnels( State(state): State>, - Query(q): Query, -) -> Json>> { + Query(q): Query, +) -> Json>> { let page = q.page.max(1); let page_size = q.page_size.clamp(1, 200); let snap = state.svc.dashboard_snapshot().await; - let client_id = q.client_id.trim(); + let session_id = q.session_id.trim(); let needle = q.q.trim().to_ascii_lowercase(); - let filtered: Vec = snap - .proxies + let filtered: Vec = snap + .tunnels .into_iter() - .filter(|p| client_id.is_empty() || p.client_id == client_id) + .filter(|p| session_id.is_empty() || p.session_id == session_id) .filter(|p| needle.is_empty() || p.name.to_ascii_lowercase().contains(&needle)) .collect(); let total = filtered.len(); @@ -312,13 +299,13 @@ async fn list_proxies( })) } -async fn proxy_traffic( +async fn tunnel_traffic( State(state): State>, Path(name): Path, Query(q): Query, -) -> Result>, StatusCode> { +) -> Result>, StatusCode> { let name = urlencoding_decode(&name); - match state.svc.metrics().proxy_traffic(&name, traffic_window(&q)) { + match state.svc.metrics().tunnel_traffic(&name, traffic_window(&q)) { Some(hist) => Ok(Json(ApiResponse::ok(traffic_resp(hist)))), None => Err(StatusCode::NOT_FOUND), } @@ -362,11 +349,11 @@ fn from_hex(b: u8) -> Option { } } -fn load_override(assets_dir: &str, rel: &str) -> Option> { - if assets_dir.trim().is_empty() { +fn load_override(static_dir: &str, rel: &str) -> Option> { + if static_dir.trim().is_empty() { return None; } - let path = safe_join(FsPath::new(assets_dir), rel)?; + let path = safe_join(FsPath::new(static_dir), rel)?; std::fs::read(path).ok() } diff --git a/server/src/main.rs b/server/src/main.rs index 74514b8..befb72a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -2,7 +2,7 @@ mod access; mod control; mod dashboard; mod metrics; -mod proxy; +mod tunnel; mod service; use anyhow::Result; @@ -14,53 +14,11 @@ use tracing_subscriber::EnvFilter; #[command( name = "orbien-server", about = "orbien server — TCP tunnel", - after_help = "Without -c/--config, orbien-server uses built-in defaults:\n bind 0.0.0.0:9527, QUIC/KCP/vhost/dashboard disabled unless set via flags." + after_help = "Example:\n orbien-server -c conf/orbien-server.toml" )] struct Args { #[arg(short, long, value_name = "FILE")] config: Option, - - #[arg(long = "bind_addr", default_value = "0.0.0.0")] - bind_addr: String, - - #[arg(short = 'p', long = "bind_port", default_value_t = 9527)] - bind_port: u16, - - #[arg(long = "kcp_bind_port", default_value_t = 0)] - kcp_bind_port: u16, - - #[arg(long = "quic_bind_port", default_value_t = 0)] - quic_bind_port: u16, - - #[arg(long = "proxy_bind_addr", default_value = "0.0.0.0")] - proxy_bind_addr: String, - - #[arg(long = "vhost_http_port", default_value_t = 0)] - vhost_http_port: u16, - - #[arg(long = "vhost_https_port", default_value_t = 0)] - vhost_https_port: u16, - - #[arg(long = "dashboard_addr", default_value = "0.0.0.0")] - dashboard_addr: String, - - #[arg(long = "dashboard_port", default_value_t = 0)] - dashboard_port: u16, - - #[arg(long = "dashboard_user", default_value = "admin")] - dashboard_user: String, - - #[arg(long = "dashboard_pwd", default_value = "admin")] - dashboard_pwd: String, - - #[arg(short = 't', long = "token", default_value = "")] - token: String, - - #[arg(long = "subdomain_host", default_value = "")] - subdomain_host: String, - - #[arg(long = "tls_only", default_value_t = false)] - tls_only: bool, } #[tokio::main] @@ -73,11 +31,11 @@ async fn main() -> Result<()> { let cfg = load_server_config(&args)?; tracing::info!( - bind = %format!("{}:{}", cfg.bind_addr, cfg.bind_port), - quic_bind = cfg.quic_bind_port, - kcp_bind = cfg.kcp_bind_port, - vhost_http = cfg.vhost_http_port, - vhost_https = cfg.vhost_https_port, + listen = %cfg.listen, + quic_port = cfg.quic_port, + kcp_port = cfg.kcp_port, + http_gw = cfg.http_gw_port, + https_gw = cfg.https_gw_port, "starting orbien-server" ); @@ -95,22 +53,9 @@ fn load_server_config(args: &Args) -> Result { return ServerConfig::load(path); } - tracing::info!("using CLI flags for config"); + tracing::info!("using built-in defaults"); let mut cfg = ServerConfig::default(); - cfg.bind_addr = args.bind_addr.clone(); - cfg.bind_port = args.bind_port; - cfg.kcp_bind_port = args.kcp_bind_port; - cfg.quic_bind_port = args.quic_bind_port; - cfg.proxy_bind_addr = args.proxy_bind_addr.clone(); - cfg.vhost_http_port = args.vhost_http_port; - cfg.vhost_https_port = args.vhost_https_port; - cfg.sub_domain_host = args.subdomain_host.clone(); - cfg.auth.token = args.token.clone(); - cfg.web_server.addr = args.dashboard_addr.clone(); - cfg.web_server.port = args.dashboard_port; - cfg.web_server.user = args.dashboard_user.clone(); - cfg.web_server.password = args.dashboard_pwd.clone(); - cfg.transport.tls.force = args.tls_only; cfg.complete(); + cfg.validate()?; Ok(cfg) } diff --git a/server/src/metrics/mem.rs b/server/src/metrics/mem.rs index 6fff26e..557e786 100644 --- a/server/src/metrics/mem.rs +++ b/server/src/metrics/mem.rs @@ -27,22 +27,22 @@ impl TrafficWindow { pub struct ServerSnapshot { pub total_traffic_in: u64, pub total_traffic_out: u64, - pub cur_conns: usize, + pub active_conns: usize, pub client_counts: usize, pub total_client_counts: usize, - pub proxy_type_counts: HashMap, + pub tunnel_type_counts: HashMap, } #[derive(Debug, Clone)] #[allow(dead_code)] -pub struct ProxySnapshot { +pub struct TunnelSnapshot { pub name: String, - pub proxy_type: String, + pub tunnel_type: String, pub user: String, - pub client_id: String, + pub session_id: String, pub today_traffic_in: u64, pub today_traffic_out: u64, - pub cur_conns: usize, + pub active_conns: usize, pub last_start_at: Option, pub last_close_at: Option, } @@ -55,37 +55,37 @@ pub struct TrafficPoint { } #[derive(Debug, Clone)] -pub struct ProxyTrafficHistory { +pub struct TunnelTrafficHistory { pub name: String, pub unit: &'static str, pub granularity: &'static str, pub history: Vec, } -struct ProxyStats { - proxy_type: String, +struct TunnelStats { + tunnel_type: String, user: String, - client_id: String, + session_id: String, traffic_in: DateCounter, traffic_out: DateCounter, traffic_in_hourly: HourCounter, traffic_out_hourly: HourCounter, - cur_conns: Counter, + active_conns: Counter, last_start_unix: Option, last_close_unix: Option, } -impl ProxyStats { - fn new(proxy_type: &str, user: &str, client_id: &str) -> Self { +impl TunnelStats { + fn new(tunnel_type: &str, user: &str, session_id: &str) -> Self { Self { - proxy_type: proxy_type.to_string(), + tunnel_type: tunnel_type.to_string(), user: user.to_string(), - client_id: client_id.to_string(), + session_id: session_id.to_string(), traffic_in: DateCounter::new(RESERVE_DAYS), traffic_out: DateCounter::new(RESERVE_DAYS), traffic_in_hourly: HourCounter::new(RESERVE_HOURS), traffic_out_hourly: HourCounter::new(RESERVE_HOURS), - cur_conns: Counter::new(), + active_conns: Counter::new(), last_start_unix: None, last_close_unix: None, } @@ -97,11 +97,11 @@ struct State { total_traffic_out: DateCounter, total_traffic_in_hourly: HourCounter, total_traffic_out_hourly: HourCounter, - cur_conns: Counter, + active_conns: Counter, client_counts: Counter, seen_clients: HashSet, - proxy_type_counts: HashMap, - proxies: HashMap, + tunnel_type_counts: HashMap, + tunnels: HashMap, } pub struct MemMetrics { @@ -116,42 +116,42 @@ impl MemMetrics { total_traffic_out: DateCounter::new(RESERVE_DAYS), total_traffic_in_hourly: HourCounter::new(RESERVE_HOURS), total_traffic_out_hourly: HourCounter::new(RESERVE_HOURS), - cur_conns: Counter::new(), + active_conns: Counter::new(), client_counts: Counter::new(), seen_clients: HashSet::new(), - proxy_type_counts: HashMap::new(), - proxies: HashMap::new(), + tunnel_type_counts: HashMap::new(), + tunnels: HashMap::new(), }), }) } pub fn server_snapshot(&self) -> ServerSnapshot { let g = self.state.lock().expect("metrics lock"); - let mut proxy_type_counts = HashMap::new(); - for (k, v) in &g.proxy_type_counts { + let mut tunnel_type_counts = HashMap::new(); + for (k, v) in &g.tunnel_type_counts { let n = v.count().max(0) as usize; if n > 0 { - proxy_type_counts.insert(k.clone(), n); + tunnel_type_counts.insert(k.clone(), n); } } ServerSnapshot { total_traffic_in: g.total_traffic_in.today_count().max(0) as u64, total_traffic_out: g.total_traffic_out.today_count().max(0) as u64, - cur_conns: g.cur_conns.count().max(0) as usize, + active_conns: g.active_conns.count().max(0) as usize, client_counts: g.client_counts.count().max(0) as usize, total_client_counts: g.seen_clients.len(), - proxy_type_counts, + tunnel_type_counts, } } - pub fn proxy_snapshot(&self, name: &str) -> Option { + pub fn tunnel_snapshot(&self, name: &str) -> Option { let g = self.state.lock().expect("metrics lock"); - g.proxies.get(name).map(|p| to_proxy_snapshot(name, p)) + g.tunnels.get(name).map(|p| to_tunnel_snapshot(name, p)) } - pub fn proxy_traffic(&self, name: &str, window: TrafficWindow) -> Option { + pub fn tunnel_traffic(&self, name: &str, window: TrafficWindow) -> Option { let g = self.state.lock().expect("metrics lock"); - let p = g.proxies.get(name)?; + let p = g.tunnels.get(name)?; Some(match window { TrafficWindow::Days7 => { let inbound = p.traffic_in.last_days(RESERVE_DAYS); @@ -166,7 +166,7 @@ impl MemMetrics { }) } - pub fn server_traffic(&self, window: TrafficWindow) -> ProxyTrafficHistory { + pub fn server_traffic(&self, window: TrafficWindow) -> TunnelTrafficHistory { let g = self.state.lock().expect("metrics lock"); match window { TrafficWindow::Days7 => { @@ -185,15 +185,15 @@ impl MemMetrics { pub fn track_connection( self: &Arc, name: impl Into, - proxy_type: impl Into, + tunnel_type: impl Into, ) -> ConnGuard { let name = name.into(); - let proxy_type = proxy_type.into(); - self.open_connection(&name, &proxy_type); + let tunnel_type = tunnel_type.into(); + self.open_connection(&name, &tunnel_type); ConnGuard { metrics: Arc::clone(self), name, - proxy_type, + tunnel_type, } } } @@ -201,20 +201,20 @@ impl MemMetrics { pub struct ConnGuard { metrics: Arc, name: String, - proxy_type: String, + tunnel_type: String, } impl Drop for ConnGuard { fn drop(&mut self) { - self.metrics.close_connection(&self.name, &self.proxy_type); + self.metrics.close_connection(&self.name, &self.tunnel_type); } } impl ServerMetrics for MemMetrics { - fn new_client(&self, run_id: &str) { + fn new_client(&self, session_id: &str) { let mut g = self.state.lock().expect("metrics lock"); - if !run_id.is_empty() { - g.seen_clients.insert(run_id.to_string()); + if !session_id.is_empty() { + g.seen_clients.insert(session_id.to_string()); const MAX_SEEN: usize = 256; if g.seen_clients.len() > MAX_SEEN { let overflow = g.seen_clients.len() - MAX_SEEN; @@ -236,55 +236,55 @@ impl ServerMetrics for MemMetrics { .dec(1); } - fn new_proxy(&self, name: &str, proxy_type: &str, user: &str, client_id: &str) { + fn new_tunnel(&self, name: &str, tunnel_type: &str, user: &str, session_id: &str) { let mut g = self.state.lock().expect("metrics lock"); - g.proxy_type_counts - .entry(proxy_type.to_string()) + g.tunnel_type_counts + .entry(tunnel_type.to_string()) .or_insert_with(Counter::new) .inc(1); let now_unix = Local::now().timestamp(); let entry = g - .proxies + .tunnels .entry(name.to_string()) - .or_insert_with(|| ProxyStats::new(proxy_type, user, client_id)); + .or_insert_with(|| TunnelStats::new(tunnel_type, user, session_id)); - if entry.proxy_type != proxy_type { - *entry = ProxyStats::new(proxy_type, user, client_id); + if entry.tunnel_type != tunnel_type { + *entry = TunnelStats::new(tunnel_type, user, session_id); } else { entry.user = user.to_string(); - entry.client_id = client_id.to_string(); + entry.session_id = session_id.to_string(); } entry.last_start_unix = Some(now_unix); } - fn close_proxy(&self, name: &str, proxy_type: &str) { + fn close_tunnel(&self, name: &str, tunnel_type: &str) { let mut g = self.state.lock().expect("metrics lock"); - if let Some(counter) = g.proxy_type_counts.get(proxy_type) { + if let Some(counter) = g.tunnel_type_counts.get(tunnel_type) { counter.dec(1); } - if let Some(entry) = g.proxies.get_mut(name) { + if let Some(entry) = g.tunnels.get_mut(name) { entry.last_close_unix = Some(Local::now().timestamp()); } } - fn open_connection(&self, name: &str, _proxy_type: &str) { + fn open_connection(&self, name: &str, _tunnel_type: &str) { let mut g = self.state.lock().expect("metrics lock"); - g.cur_conns.inc(1); - if let Some(p) = g.proxies.get_mut(name) { - p.cur_conns.inc(1); + g.active_conns.inc(1); + if let Some(p) = g.tunnels.get_mut(name) { + p.active_conns.inc(1); } } - fn close_connection(&self, name: &str, _proxy_type: &str) { + fn close_connection(&self, name: &str, _tunnel_type: &str) { let mut g = self.state.lock().expect("metrics lock"); - g.cur_conns.dec(1); - if let Some(p) = g.proxies.get_mut(name) { - p.cur_conns.dec(1); + g.active_conns.dec(1); + if let Some(p) = g.tunnels.get_mut(name) { + p.active_conns.dec(1); } } - fn add_traffic_in(&self, name: &str, _proxy_type: &str, bytes: u64) { + fn add_traffic_in(&self, name: &str, _tunnel_type: &str, bytes: u64) { if bytes == 0 { return; } @@ -292,13 +292,13 @@ impl ServerMetrics for MemMetrics { let mut g = self.state.lock().expect("metrics lock"); g.total_traffic_in.inc(delta); g.total_traffic_in_hourly.inc(delta); - if let Some(p) = g.proxies.get_mut(name) { + if let Some(p) = g.tunnels.get_mut(name) { p.traffic_in.inc(delta); p.traffic_in_hourly.inc(delta); } } - fn add_traffic_out(&self, name: &str, _proxy_type: &str, bytes: u64) { + fn add_traffic_out(&self, name: &str, _tunnel_type: &str, bytes: u64) { if bytes == 0 { return; } @@ -306,28 +306,28 @@ impl ServerMetrics for MemMetrics { let mut g = self.state.lock().expect("metrics lock"); g.total_traffic_out.inc(delta); g.total_traffic_out_hourly.inc(delta); - if let Some(p) = g.proxies.get_mut(name) { + if let Some(p) = g.tunnels.get_mut(name) { p.traffic_out.inc(delta); p.traffic_out_hourly.inc(delta); } } } -fn to_proxy_snapshot(name: &str, p: &ProxyStats) -> ProxySnapshot { - ProxySnapshot { +fn to_tunnel_snapshot(name: &str, p: &TunnelStats) -> TunnelSnapshot { + TunnelSnapshot { name: name.to_string(), - proxy_type: p.proxy_type.clone(), + tunnel_type: p.tunnel_type.clone(), user: p.user.clone(), - client_id: p.client_id.clone(), + session_id: p.session_id.clone(), today_traffic_in: p.traffic_in.today_count().max(0) as u64, today_traffic_out: p.traffic_out.today_count().max(0) as u64, - cur_conns: p.cur_conns.count().max(0) as usize, + active_conns: p.active_conns.count().max(0) as usize, last_start_at: p.last_start_unix, last_close_at: p.last_close_unix, } } -fn build_daily_history(name: &str, inbound: &[i64], outbound: &[i64]) -> ProxyTrafficHistory { +fn build_daily_history(name: &str, inbound: &[i64], outbound: &[i64]) -> TunnelTrafficHistory { let today = Local::now().date_naive(); let n = RESERVE_DAYS.min(inbound.len()).min(outbound.len()); let mut history = Vec::with_capacity(n); @@ -341,7 +341,7 @@ fn build_daily_history(name: &str, inbound: &[i64], outbound: &[i64]) -> ProxyTr traffic_out: outbound[age].max(0) as u64, }); } - ProxyTrafficHistory { + TunnelTrafficHistory { name: name.to_string(), unit: "bytes", granularity: "day", @@ -349,7 +349,7 @@ fn build_daily_history(name: &str, inbound: &[i64], outbound: &[i64]) -> ProxyTr } } -fn build_hourly_history(name: &str, inbound: &[i64], outbound: &[i64]) -> ProxyTrafficHistory { +fn build_hourly_history(name: &str, inbound: &[i64], outbound: &[i64]) -> TunnelTrafficHistory { let now_hour = Local::now() .with_minute(0) .and_then(|t| t.with_second(0)) @@ -365,7 +365,7 @@ fn build_hourly_history(name: &str, inbound: &[i64], outbound: &[i64]) -> ProxyT traffic_out: outbound[age].max(0) as u64, }); } - ProxyTrafficHistory { + TunnelTrafficHistory { name: name.to_string(), unit: "bytes", granularity: "hour", diff --git a/server/src/metrics/mod.rs b/server/src/metrics/mod.rs index c8ebd7c..53fc35f 100644 --- a/server/src/metrics/mod.rs +++ b/server/src/metrics/mod.rs @@ -4,7 +4,7 @@ mod hour_counter; mod mem; mod traits; -pub use mem::{MemMetrics, ProxyTrafficHistory, TrafficWindow}; +pub use mem::{MemMetrics, TunnelTrafficHistory, TrafficWindow}; pub use traits::ServerMetrics; pub const RESERVE_DAYS: usize = 7; @@ -13,21 +13,21 @@ pub const RESERVE_HOURS: usize = 24; pub async fn join_and_record( metrics: &std::sync::Arc, name: &str, - proxy_type: &str, - visitor: A, - work: B, + tunnel_type: &str, + ingress: A, + data: B, ) -> std::io::Result<(u64, u64)> where A: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, B: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - let _guard = metrics.track_connection(name, proxy_type); - let (to_work, from_work, err) = orbien_core::io::join_counted(visitor, work).await; - metrics.add_traffic_in(name, proxy_type, to_work); - metrics.add_traffic_out(name, proxy_type, from_work); + let _guard = metrics.track_connection(name, tunnel_type); + let (to_data, from_data, err) = orbien_core::io::join_counted(ingress, data).await; + metrics.add_traffic_in(name, tunnel_type, to_data); + metrics.add_traffic_out(name, tunnel_type, from_data); match err { - None => Ok((to_work, from_work)), - Some(e) if is_benign_close(&e) => Ok((to_work, from_work)), + None => Ok((to_data, from_data)), + Some(e) if is_benign_close(&e) => Ok((to_data, from_data)), Some(e) => Err(e), } } diff --git a/server/src/metrics/traits.rs b/server/src/metrics/traits.rs index 182c46b..98c102d 100644 --- a/server/src/metrics/traits.rs +++ b/server/src/metrics/traits.rs @@ -1,13 +1,13 @@ pub trait ServerMetrics: Send + Sync { - fn new_client(&self, run_id: &str); + fn new_client(&self, session_id: &str); fn close_client(&self); - fn new_proxy(&self, name: &str, proxy_type: &str, user: &str, client_id: &str); - fn close_proxy(&self, name: &str, proxy_type: &str); + fn new_tunnel(&self, name: &str, tunnel_type: &str, user: &str, session_id: &str); + fn close_tunnel(&self, name: &str, tunnel_type: &str); - fn open_connection(&self, name: &str, proxy_type: &str); - fn close_connection(&self, name: &str, proxy_type: &str); + fn open_connection(&self, name: &str, tunnel_type: &str); + fn close_connection(&self, name: &str, tunnel_type: &str); - fn add_traffic_in(&self, name: &str, proxy_type: &str, bytes: u64); - fn add_traffic_out(&self, name: &str, proxy_type: &str, bytes: u64); + fn add_traffic_in(&self, name: &str, tunnel_type: &str, bytes: u64); + fn add_traffic_out(&self, name: &str, tunnel_type: &str, bytes: u64); } diff --git a/server/src/proxy/http.rs b/server/src/proxy/http.rs deleted file mode 100644 index f6a26cf..0000000 --- a/server/src/proxy/http.rs +++ /dev/null @@ -1,266 +0,0 @@ -use super::vhost::{build_domains, normalize_host, HttpRoute, HttpVhost}; -use crate::access::{prepare_visitor, AccessPolicy}; -use crate::control::Control; -use crate::metrics::ServerMetrics; -use anyhow::{anyhow, bail, Result}; -use httparse::Status; -use orbien_core::limit::{maybe_limit, BandwidthLimiter}; -use orbien_core::msg::NewProxy; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::Notify; - -pub struct HttpProxy { - pub name: String, - pub domains: Vec, - vhost: Arc, - closed: AtomicBool, -} - -impl HttpProxy { - pub async fn register( - np: &NewProxy, - control: Arc, - vhost: Arc, - sub_domain_host: &str, - limiter: Option>, - ) -> Result { - let domains = build_domains(&np.custom_domains, &np.subdomain, sub_domain_host)?; - let name = np.proxy_name.clone(); - let locations = np.locations.clone(); - let rewrite = np.host_header_rewrite.clone(); - - for domain in &domains { - vhost - .register( - domain, - HttpRoute { - proxy_name: name.clone(), - control: Arc::downgrade(&control), - locations: locations.clone(), - host_header_rewrite: rewrite.clone(), - limiter: limiter.clone(), - }, - ) - .await?; - } - - tracing::info!( - proxy = %name, - domains = ?domains, - "http proxy registered" - ); - - Ok(Self { - name, - domains, - vhost, - closed: AtomicBool::new(false), - }) - } - - pub async fn close(&self) { - if self - .closed - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - self.vhost.unregister_proxy(&self.name).await; - } - } -} - -pub async fn run_vhost_http_listener( - bind_addr: String, - port: u16, - vhost: Arc, - access: Arc, - shutdown: Arc, -) -> Result<()> { - let addr = format!("{bind_addr}:{port}"); - let listener = TcpListener::bind(&addr).await?; - tracing::info!(%addr, "http vhost listener ready"); - - loop { - tokio::select! { - _ = shutdown.notified() => break, - accepted = listener.accept() => { - match accepted { - Ok((stream, peer)) => { - let vhost = Arc::clone(&vhost); - let access = Arc::clone(&access); - tokio::spawn(async move { - if let Err(e) = handle_http_visitor(vhost, stream, peer, access).await { - tracing::debug!(%peer, error = %e, "http visitor ended"); - } - }); - } - Err(e) => { - tracing::warn!(error = %e, "http vhost accept failed"); - break; - } - } - } - } - } - Ok(()) -} - -async fn handle_http_visitor( - vhost: Arc, - stream: TcpStream, - peer: std::net::SocketAddr, - access: Arc, -) -> Result<()> { - let mut visitor = prepare_visitor(stream, peer, &access).await?; - let (mut head, host, path) = read_http_request_head(&mut visitor.stream).await?; - let Some(route) = vhost.lookup(&host, &path).await else { - tracing::debug!(peer = %visitor.peer, visitor = %visitor.visitor, %host, %path, "http no route"); - write_not_found(&mut visitor.stream).await; - return Ok(()); - }; - - let Some(control) = route.control.upgrade() else { - write_not_found(&mut visitor.stream).await; - return Err(anyhow!("http proxy client gone: {}", route.proxy_name)); - }; - - if !route.host_header_rewrite.is_empty() { - rewrite_host_header(&mut head, &route.host_header_rewrite)?; - } - - orbien_core::net::apply_x_forwarded_for(&mut head, &visitor.visitor.ip().to_string(), "http")?; - - let work = match control.get_work_conn().await { - Ok(w) => w, - Err(e) => { - write_bad_gateway(&mut visitor.stream).await; - return Err(e); - } - }; - let work = control - .start_work_conn( - work, - &route.proxy_name, - visitor.visitor.ip().to_string(), - visitor.visitor.port(), - visitor - .local - .map(|a| a.ip().to_string()) - .unwrap_or_default(), - visitor.local.map(|a| a.port()).unwrap_or(0), - ) - .await?; - - let mut work = maybe_limit(work, route.limiter.clone()); - let head_len = head.len() as u64; - work.write_all(&head).await?; - tracing::debug!( - proxy = %route.proxy_name, - %host, - %path, - peer = %visitor.peer, - visitor = %visitor.visitor, - "http joining visitor <-> work" - ); - let _guard = control.metrics.track_connection(&route.proxy_name, "http"); - let (to_work, from_work, err) = orbien_core::io::join_counted(visitor.stream, work).await; - control - .metrics - .add_traffic_in(&route.proxy_name, "http", to_work.saturating_add(head_len)); - control - .metrics - .add_traffic_out(&route.proxy_name, "http", from_work); - if let Some(e) = err { - tracing::debug!(proxy = %route.proxy_name, error = %e, "http join ended"); - } - Ok(()) -} - -async fn read_http_request_head( - stream: &mut R, -) -> Result<(Vec, String, String)> { - let mut buf = Vec::with_capacity(4096); - let mut tmp = [0u8; 2048]; - loop { - let n = stream.read(&mut tmp).await?; - if n == 0 { - bail!("client closed before http headers completed"); - } - buf.extend_from_slice(&tmp[..n]); - if buf.len() > 64 * 1024 { - bail!("http headers too large"); - } - - let mut headers = [httparse::EMPTY_HEADER; 64]; - let mut req = httparse::Request::new(&mut headers); - match req.parse(&buf)? { - Status::Complete(_) => { - let host = req - .headers - .iter() - .find(|h| h.name.eq_ignore_ascii_case("host")) - .map(|h| String::from_utf8_lossy(h.value).into_owned()) - .ok_or_else(|| anyhow!("missing Host header"))?; - let path = req.path.unwrap_or("/").to_string(); - return Ok((buf, normalize_host(&host), path)); - } - Status::Partial => continue, - } - } -} - -fn rewrite_host_header(buf: &mut Vec, new_host: &str) -> Result<()> { - let lower = b"host:"; - let text = String::from_utf8_lossy(buf); - let mut out = String::new(); - let mut replaced = false; - for line in text.split_inclusive('\n') { - let trimmed_start = line.trim_start_matches([' ', '\t']); - if !replaced - && trimmed_start.len() >= 5 - && trimmed_start.as_bytes()[..5].eq_ignore_ascii_case(lower) - { - let ending = if line.ends_with("\r\n") { - "\r\n" - } else if line.ends_with('\n') { - "\n" - } else { - "" - }; - out.push_str("Host: "); - out.push_str(new_host); - out.push_str(ending); - replaced = true; - } else { - out.push_str(line); - } - } - if !replaced { - return Err(anyhow!("Host header not found for rewrite")); - } - *buf = out.into_bytes(); - Ok(()) -} - -pub async fn write_not_found(stream: &mut W) { - let body = "Not Found\n"; - let resp = format!( - "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()).await; -} - -async fn write_bad_gateway(stream: &mut W) { - let body = "Bad Gateway\n"; - let resp = format!( - "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()).await; -} diff --git a/server/src/proxy/mod.rs b/server/src/proxy/mod.rs deleted file mode 100644 index 01b446d..0000000 --- a/server/src/proxy/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod http; -mod https; -mod manager; -mod tcp; -mod udp; -mod vhost; - -pub use http::{run_vhost_http_listener, HttpProxy}; -pub use https::{run_vhost_https_listener, HttpsProxy, HttpsVhost}; -pub use manager::{format_local_addr, ProxyManager, ProxySummary, RegisteredProxy}; -pub use tcp::TcpProxy; -pub use udp::UdpProxy; -pub use vhost::HttpVhost; diff --git a/server/src/proxy/vhost.rs b/server/src/proxy/vhost.rs deleted file mode 100644 index 24145bb..0000000 --- a/server/src/proxy/vhost.rs +++ /dev/null @@ -1,131 +0,0 @@ -use crate::control::Control; -use std::collections::HashMap; -use std::sync::Weak; -use tokio::sync::Mutex; - -#[derive(Clone)] -pub struct HttpRoute { - pub proxy_name: String, - pub control: Weak, - - pub locations: Vec, - pub host_header_rewrite: String, - - pub limiter: Option>, -} - -pub struct HttpVhost { - routes: Mutex>>, - pub listen_port: u16, -} - -impl HttpVhost { - pub fn new(listen_port: u16) -> Self { - Self { - routes: Mutex::new(HashMap::new()), - listen_port, - } - } - - pub async fn register(&self, domain: &str, route: HttpRoute) -> anyhow::Result<()> { - let key = normalize_host(domain); - if key.is_empty() { - return Err(anyhow::anyhow!("empty http domain")); - } - let mut map = self.routes.lock().await; - let list = map.entry(key.clone()).or_default(); - - list.retain(|r| r.proxy_name != route.proxy_name); - list.push(route); - tracing::info!(domain = %key, "http route registered"); - Ok(()) - } - - pub async fn unregister_proxy(&self, proxy_name: &str) { - let mut map = self.routes.lock().await; - map.retain(|_, list| { - list.retain(|r| r.proxy_name != proxy_name); - !list.is_empty() - }); - } - - pub async fn lookup(&self, host: &str, path: &str) -> Option { - let key = normalize_host(host); - let map = self.routes.lock().await; - let list = map.get(&key)?; - pick_by_location(list, path).cloned() - } -} - -fn pick_by_location<'a>(list: &'a [HttpRoute], path: &str) -> Option<&'a HttpRoute> { - let mut best: Option<(&HttpRoute, usize)> = None; - for r in list { - let locs = if r.locations.is_empty() { - vec![String::new()] - } else { - r.locations.clone() - }; - for loc in locs { - if loc.is_empty() || path.starts_with(&loc) { - let score = loc.len(); - if best.map(|(_, s)| score >= s).unwrap_or(true) { - best = Some((r, score)); - } - } - } - } - best.map(|(r, _)| r) -} - -pub fn normalize_host(host: &str) -> String { - let host = host.trim(); - let without_port = if let Some(h) = host.strip_prefix('[') { - if let Some(end) = h.find(']') { - &h[..end] - } else { - host - } - } else { - host.split(':').next().unwrap_or(host) - }; - without_port.trim().to_ascii_lowercase() -} - -pub fn build_domains( - custom_domains: &[String], - subdomain: &str, - sub_domain_host: &str, -) -> anyhow::Result> { - let mut out = Vec::new(); - for d in custom_domains { - let d = d.trim(); - if !d.is_empty() { - out.push(normalize_host(d)); - } - } - let sub = subdomain.trim(); - if !sub.is_empty() { - let base = sub_domain_host.trim(); - if base.is_empty() { - if out.is_empty() { - return Err(anyhow::anyhow!( - "subdomain set but server subDomainHost is empty" - )); - } - tracing::warn!( - subdomain = %sub, - "subdomain ignored: server subDomainHost is empty; using customDomains only" - ); - } else if sub.contains('.') || sub.contains('*') { - return Err(anyhow::anyhow!("subdomain must not contain '.' or '*'")); - } else { - out.push(normalize_host(&format!("{sub}.{base}"))); - } - } - if out.is_empty() { - return Err(anyhow::anyhow!( - "http/https proxy requires customDomains and/or subdomain" - )); - } - Ok(out) -} diff --git a/server/src/service.rs b/server/src/service.rs deleted file mode 100644 index 8d93737..0000000 --- a/server/src/service.rs +++ /dev/null @@ -1,604 +0,0 @@ -use crate::access::AccessPolicy; -use crate::control::Control; -use crate::metrics::{MemMetrics, ServerMetrics}; -use crate::proxy::{run_vhost_http_listener, run_vhost_https_listener, HttpVhost, HttpsVhost}; -use anyhow::{anyhow, Result}; -use orbien_core::auth; -use orbien_core::config::ServerConfig; -use orbien_core::msg::{self, Login, LoginResp, Message, NewWorkConn}; -use orbien_core::transport::{self, boxed_stream, DynStream}; -use orbien_core::VERSION; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Instant; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{Mutex, Notify}; -use tokio::task::JoinSet; -use uuid::Uuid; - -struct OfflineClientRecord { - run_id: String, - user: String, - hostname: String, - os: String, - arch: String, - client_ip: String, - version: String, - proxy_count: usize, - disconnected_at: Instant, -} - -pub struct Service { - cfg: ServerConfig, - access: Arc, - controls: Arc>>>, - offline_clients: Arc>>, - http_vhost: Option>, - https_vhost: Option>, - tls_config: Arc, - metrics: Arc, -} - -impl Service { - pub fn new(cfg: ServerConfig) -> Result { - let access = Arc::new(AccessPolicy::from_server_config(&cfg)?); - let http_vhost = if cfg.vhost_http_enabled() { - Some(Arc::new(HttpVhost::new(cfg.vhost_http_port))) - } else { - None - }; - let https_vhost = if cfg.vhost_https_enabled() { - Some(Arc::new(HttpsVhost::new(cfg.vhost_https_port))) - } else { - None - }; - let tls = &cfg.transport.tls; - let tls_config = - transport::new_server_tls_config(&tls.cert_file, &tls.key_file, &tls.trusted_ca_file)?; - if tls.force { - tracing::info!("transport.tls.force=true — non-TLS control connections rejected"); - } - Ok(Self { - cfg, - access, - controls: Arc::new(Mutex::new(HashMap::new())), - offline_clients: Arc::new(Mutex::new(HashMap::new())), - http_vhost, - https_vhost, - tls_config, - metrics: MemMetrics::new(), - }) - } - - pub async fn run(self) -> Result<()> { - let this = Arc::new(self); - - if this.cfg.quic_enabled() - && this.cfg.kcp_enabled() - && this.cfg.quic_bind_port == this.cfg.kcp_bind_port - { - return Err(anyhow!( - "quicBindPort and kcpBindPort both use UDP and must differ (got {})", - this.cfg.quic_bind_port - )); - } - - let tcp_addr = format!("{}:{}", this.cfg.bind_addr, this.cfg.bind_port); - let tcp_listener = TcpListener::bind(&tcp_addr).await?; - tracing::info!( - %tcp_addr, - ws_path = transport::ORBIEN_WEBSOCKET_PATH, - tcp_mux = this.cfg.transport.tcp_mux, - "tcp/websocket control/work listener ready" - ); - - let vhost_shutdown = Arc::new(Notify::new()); - let mut set = JoinSet::new(); - - if let Some(ref vhost) = this.http_vhost { - let bind = this.cfg.proxy_bind_addr.clone(); - let port = this.cfg.vhost_http_port; - let vhost = Arc::clone(vhost); - let access = Arc::clone(&this.access); - let shutdown = Arc::clone(&vhost_shutdown); - set.spawn( - async move { run_vhost_http_listener(bind, port, vhost, access, shutdown).await }, - ); - } - - if let Some(ref vhost) = this.https_vhost { - let bind = this.cfg.proxy_bind_addr.clone(); - let port = this.cfg.vhost_https_port; - let vhost = Arc::clone(vhost); - let access = Arc::clone(&this.access); - let shutdown = Arc::clone(&vhost_shutdown); - set.spawn(async move { - run_vhost_https_listener(bind, port, vhost, access, shutdown).await - }); - } - - if this.cfg.quic_enabled() { - let quic_addr: SocketAddr = - format!("{}:{}", this.cfg.bind_addr, this.cfg.quic_bind_port) - .parse() - .map_err(|e| anyhow!("invalid quic bind addr: {e}"))?; - let endpoint = transport::build_server_endpoint( - quic_addr, - this.cfg.transport.quic.keepalive(), - this.cfg.transport.quic.idle_timeout(), - this.cfg.transport.quic.max_incoming_streams, - &this.cfg.transport.tls.cert_file, - &this.cfg.transport.tls.key_file, - &this.cfg.transport.tls.trusted_ca_file, - )?; - tracing::info!(%quic_addr, "quic control/work listener ready"); - let svc = Arc::clone(&this); - set.spawn(async move { svc.run_quic(endpoint).await }); - } - - if this.cfg.kcp_enabled() { - let kcp_addr: SocketAddr = format!("{}:{}", this.cfg.bind_addr, this.cfg.kcp_bind_port) - .parse() - .map_err(|e| anyhow!("invalid kcp bind addr: {e}"))?; - let listener = transport::bind_kcp_listener(kcp_addr).await?; - tracing::info!( - %kcp_addr, - tcp_mux = this.cfg.transport.tcp_mux, - "kcp control/work listener ready" - ); - let svc = Arc::clone(&this); - set.spawn(async move { svc.run_kcp(listener).await }); - } - - if this.cfg.web_server.enabled() { - let web_cfg = this.cfg.web_server.clone(); - let svc = Arc::clone(&this); - set.spawn(async move { crate::dashboard::run(svc, web_cfg).await }); - } - - let svc = Arc::clone(&this); - set.spawn(async move { svc.run_tcp(tcp_listener).await }); - - let first = set - .join_next() - .await - .ok_or_else(|| anyhow!("no listener tasks"))?; - vhost_shutdown.notify_waiters(); - set.abort_all(); - while set.join_next().await.is_some() {} - - match first { - Ok(Ok(())) => Ok(()), - Ok(Err(e)) => Err(e), - Err(e) if e.is_cancelled() => Ok(()), - Err(e) => Err(anyhow!("listener task join: {e}")), - } - } - - async fn run_tcp(self: Arc, listener: TcpListener) -> Result<()> { - loop { - let (stream, peer) = listener.accept().await?; - let svc = Arc::clone(&self); - tokio::spawn(async move { - if let Err(e) = svc.handle_tcp_or_websocket(stream, peer).await { - tracing::warn!(%peer, error = %e, "tcp/ws connection closed with error"); - } - }); - } - } - - async fn handle_tcp_or_websocket( - self: Arc, - stream: TcpStream, - peer: SocketAddr, - ) -> Result<()> { - let mut peek_buf = [0u8; 16]; - let n = stream.peek(&mut peek_buf).await.unwrap_or(0); - let physical = if transport::is_websocket_http_request(&peek_buf[..n]) { - tracing::debug!(%peer, transport = "websocket", "upgrade"); - transport::accept_websocket(stream).await? - } else { - tracing::debug!(%peer, transport = "tcp", "incoming connection"); - boxed_stream(stream) - }; - let physical = transport::check_and_enable_tls( - physical, - Arc::clone(&self.tls_config), - self.cfg.transport.tls.force, - ) - .await?; - self.handle_physical(physical, peer).await - } - - async fn handle_physical(self: Arc, physical: DynStream, peer: SocketAddr) -> Result<()> { - if self.cfg.transport.tcp_mux { - tracing::debug!(%peer, "yamux server session started"); - let svc = Arc::clone(&self); - transport::serve_yamux_session(physical, move |stream| { - let svc = Arc::clone(&svc); - tokio::spawn(async move { - if let Err(e) = svc.handle_connection(stream, peer).await { - tracing::debug!(error = %e, "yamux stream closed with error"); - } - }); - }) - .await - .map_err(|e| { - let msg = e.to_string(); - - if msg.contains("unknown version: 111") || msg.contains("unknown version: 119") { - anyhow!( - "yamux session {peer}: {e} — transport.tcpMux mismatch: server expects yamux \ - (tcpMux=true) but peer sent a raw control frame (Login 'o'=111 / NewWorkConn 'w'=119). \ - Set the same tcpMux on orbien and orbien-server, then restart both." - ) - } else { - anyhow!("yamux session {peer}: {e}") - } - }) - } else { - self.handle_connection(physical, peer).await - } - } - - async fn run_kcp(self: Arc, mut listener: kcp_tokio::KcpListener) -> Result<()> { - loop { - let (stream, peer) = transport::accept_kcp(&mut listener).await?; - tracing::debug!(%peer, transport = "kcp", "incoming connection"); - let svc = Arc::clone(&self); - tokio::spawn(async move { - let result = async { - let stream = transport::check_and_enable_tls( - stream, - Arc::clone(&svc.tls_config), - svc.cfg.transport.tls.force, - ) - .await?; - svc.handle_physical(stream, peer).await - } - .await; - if let Err(e) = result { - tracing::warn!(%peer, error = %e, "kcp connection closed with error"); - } - }); - } - } - - async fn run_quic(self: Arc, endpoint: quinn::Endpoint) -> Result<()> { - loop { - let incoming = endpoint - .accept() - .await - .ok_or_else(|| anyhow!("quic endpoint closed"))?; - let svc = Arc::clone(&self); - tokio::spawn(async move { - match incoming.await { - Ok(conn) => { - let peer = conn.remote_address(); - tracing::info!(%peer, "quic session accepted"); - if let Err(e) = svc.handle_quic_connection(conn).await { - tracing::debug!(%peer, error = %e, "quic session ended"); - } - } - Err(e) => tracing::warn!(error = %e, "quic accept failed"), - } - }); - } - } - - async fn handle_quic_connection(self: Arc, conn: quinn::Connection) -> Result<()> { - loop { - let (send, recv) = conn.accept_bi().await?; - let stream = transport::quic_bi(send, recv); - let svc = Arc::clone(&self); - let peer = conn.remote_address(); - tokio::spawn(async move { - if let Err(e) = svc.handle_connection(stream, peer).await { - tracing::debug!(%peer, error = %e, "quic stream closed with error"); - } - }); - } - } - - async fn handle_connection( - self: Arc, - mut stream: DynStream, - peer: SocketAddr, - ) -> Result<()> { - let first = msg::read_msg(&mut stream).await?; - match first { - Message::Login(login) => self.register_control(stream, login, peer).await, - Message::NewWorkConn(nw) => self.register_work_conn(stream, nw).await, - other => Err(anyhow!("unexpected first message: {:?}", other.type_byte())), - } - } - - async fn register_control( - self: Arc, - stream: DynStream, - login: Login, - peer: SocketAddr, - ) -> Result<()> { - if !auth::verify_login(&self.cfg.auth.token, &login.privilege_key, login.timestamp) { - let mut stream = stream; - let _ = msg::write_msg( - &mut stream, - &Message::LoginResp(LoginResp { - version: VERSION.into(), - run_id: String::new(), - error: "authorization failed".into(), - }), - ) - .await; - return Err(anyhow!("authorization failed")); - } - - let run_id = if login.run_id.is_empty() { - short_run_id() - } else { - login.run_id.clone() - }; - - let mut stream = stream; - msg::write_msg( - &mut stream, - &Message::LoginResp(LoginResp { - version: VERSION.into(), - run_id: run_id.clone(), - error: String::new(), - }), - ) - .await?; - - tracing::info!(%run_id, %peer, pool = login.pool_count, "client logged in"); - - let max_pool = self.cfg.transport.max_pool_count.max(0) as usize; - let pool_count = (login.pool_count.max(0) as usize).min(max_pool); - - let client_ip = peer.ip().to_string(); - - let control = Control::new( - run_id.clone(), - stream, - self.cfg.clone(), - pool_count, - self.http_vhost.clone(), - self.https_vhost.clone(), - Arc::clone(&self.access), - login.user.clone(), - login.hostname.clone(), - login.os.clone(), - login.arch.clone(), - login.version.clone(), - client_ip, - Arc::clone(&self.metrics), - ); - let control = Arc::new(control); - - { - let mut offline = self.offline_clients.lock().await; - offline.remove(&run_id); - } - - let old = { - let mut map = self.controls.lock().await; - map.insert(run_id.clone(), Arc::clone(&control)) - }; - - if let Some(old) = old { - old.shutdown().await; - } - - self.metrics.new_client(&run_id); - - let controls = Arc::clone(&self.controls); - let offline_clients = Arc::clone(&self.offline_clients); - let metrics = Arc::clone(&self.metrics); - let rid = run_id.clone(); - let result = Arc::clone(&control).run().await; - control.shutdown().await; - metrics.close_client(); - - let proxy_count = control.proxy_count().await; - let mut map = controls.lock().await; - if map - .get(&rid) - .map(|c| Arc::ptr_eq(c, &control)) - .unwrap_or(false) - { - map.remove(&rid); - } - if !map.contains_key(&rid) { - drop(map); - let mut offline = offline_clients.lock().await; - offline.insert( - rid.clone(), - OfflineClientRecord { - run_id: rid, - user: control.user.clone(), - hostname: control.hostname.clone(), - os: control.os.clone(), - arch: control.arch.clone(), - client_ip: control.client_ip.clone(), - version: control.version.clone(), - proxy_count, - disconnected_at: Instant::now(), - }, - ); - } - - result - } - - async fn register_work_conn(self: Arc, stream: DynStream, nw: NewWorkConn) -> Result<()> { - let control = { - let map = self.controls.lock().await; - map.get(&nw.run_id).cloned() - }; - match control { - Some(c) => { - c.push_work_conn(stream).await; - Ok(()) - } - None => Err(anyhow!("unknown run_id for work conn: {}", nw.run_id)), - } - } - - pub fn cfg(&self) -> &ServerConfig { - &self.cfg - } - - pub fn metrics(&self) -> &Arc { - &self.metrics - } - - pub async fn kick_client(&self, run_id: &str) -> Result<()> { - let control = { - let mut map = self.controls.lock().await; - map.remove(run_id) - }; - match control { - Some(c) => { - let proxy_count = c.proxy_count().await; - { - let mut offline = self.offline_clients.lock().await; - offline.insert( - run_id.to_string(), - OfflineClientRecord { - run_id: run_id.to_string(), - user: c.user.clone(), - hostname: c.hostname.clone(), - os: c.os.clone(), - arch: c.arch.clone(), - client_ip: c.client_ip.clone(), - version: c.version.clone(), - proxy_count, - disconnected_at: Instant::now(), - }, - ); - } - c.kick("kicked from dashboard").await; - Ok(()) - } - None => Err(anyhow!("client not online: {run_id}")), - } - } - - pub async fn dashboard_snapshot(&self) -> DashboardSnapshot { - use crate::dashboard::model::{ClientInfo, ProxyInfo}; - use std::collections::BTreeMap; - - let controls = self.controls.lock().await; - let offline = self.offline_clients.lock().await; - let mut clients = Vec::with_capacity(controls.len() + offline.len()); - let mut proxies = Vec::new(); - let mut proxy_type_count: BTreeMap = BTreeMap::new(); - let mut online_ids = std::collections::HashSet::new(); - - for (_, ctrl) in controls.iter() { - let proxy_count = ctrl.proxy_count().await; - online_ids.insert(ctrl.run_id.clone()); - let mut cur_conns = 0usize; - let mut client_proxies = Vec::new(); - for s in ctrl.proxy_summaries().await { - *proxy_type_count.entry(s.proxy_type.clone()).or_default() += 1; - let traffic = self.metrics.proxy_snapshot(&s.name); - let proxy_conns = traffic.as_ref().map(|t| t.cur_conns).unwrap_or(0); - cur_conns += proxy_conns; - client_proxies.push(ProxyInfo { - name: s.name, - proxy_type: s.proxy_type, - remote_addr: s.remote_addr, - local_addr: s.local_addr, - client_id: ctrl.run_id.clone(), - status: s.status, - today_traffic_in: traffic.as_ref().map(|t| t.today_traffic_in).unwrap_or(0), - today_traffic_out: traffic.as_ref().map(|t| t.today_traffic_out).unwrap_or(0), - cur_conns: proxy_conns, - last_start_time: traffic - .as_ref() - .and_then(|t| format_proxy_time(t.last_start_at)), - }); - } - clients.push(ClientInfo { - run_id: ctrl.run_id.clone(), - user: ctrl.user.clone(), - hostname: ctrl.hostname.clone(), - os: ctrl.os.clone(), - arch: ctrl.arch.clone(), - client_ip: ctrl.client_ip.clone(), - version: ctrl.version.clone(), - proxy_count, - cur_conns, - connected_secs: ctrl.connected_at.elapsed().as_secs(), - status: "online".into(), - }); - proxies.extend(client_proxies); - } - - for (id, rec) in offline.iter() { - if online_ids.contains(id) { - continue; - } - clients.push(ClientInfo { - run_id: rec.run_id.clone(), - user: rec.user.clone(), - hostname: rec.hostname.clone(), - os: rec.os.clone(), - arch: rec.arch.clone(), - client_ip: rec.client_ip.clone(), - version: rec.version.clone(), - proxy_count: rec.proxy_count, - cur_conns: 0, - connected_secs: rec.disconnected_at.elapsed().as_secs(), - status: "offline".into(), - }); - } - - clients.sort_by(|a, b| { - let ao = a.status == "online"; - let bo = b.status == "online"; - bo.cmp(&ao).then_with(|| a.run_id.cmp(&b.run_id)) - }); - proxies.sort_by(|a, b| a.name.cmp(&b.name).then(a.client_id.cmp(&b.client_id))); - - let server_stats = self.metrics.server_snapshot(); - let total_clients = clients.len(); - - DashboardSnapshot { - clients, - proxies, - proxy_type_count, - cur_conns: server_stats.cur_conns, - total_client_counts: total_clients, - total_traffic_in: server_stats.total_traffic_in, - total_traffic_out: server_stats.total_traffic_out, - } - } -} - -pub struct DashboardSnapshot { - pub clients: Vec, - pub proxies: Vec, - pub proxy_type_count: std::collections::BTreeMap, - pub cur_conns: usize, - pub total_client_counts: usize, - pub total_traffic_in: u64, - pub total_traffic_out: u64, -} - -fn short_run_id() -> String { - let hex = Uuid::new_v4().simple().to_string(); - hex[..16].to_owned() -} - -fn format_proxy_time(unix: Option) -> Option { - let ts = unix?; - let dt = chrono::DateTime::from_timestamp(ts, 0)?; - Some( - dt.with_timezone(&chrono::Local) - .format("%m-%d %H:%M:%S") - .to_string(), - ) -} diff --git a/server/src/service/dashboard_view.rs b/server/src/service/dashboard_view.rs new file mode 100644 index 0000000..ab0ae3f --- /dev/null +++ b/server/src/service/dashboard_view.rs @@ -0,0 +1,115 @@ +use super::Service; +use std::collections::BTreeMap; + +pub struct DashboardSnapshot { + pub clients: Vec, + pub tunnels: Vec, + pub tunnel_type_count: BTreeMap, + pub active_conns: usize, + pub total_client_counts: usize, + pub total_traffic_in: u64, + pub total_traffic_out: u64, +} + +impl Service { + pub async fn dashboard_snapshot(&self) -> DashboardSnapshot { + use crate::dashboard::model::{ClientInfo, TunnelInfo}; + + let controls = self.controls.lock().await; + let offline = self.offline_clients.lock().await; + let mut clients = Vec::with_capacity(controls.len() + offline.len()); + let mut tunnels = Vec::new(); + let mut tunnel_type_count: BTreeMap = BTreeMap::new(); + let mut online_ids = std::collections::HashSet::new(); + + for (_, ctrl) in controls.iter() { + let tunnel_count = ctrl.tunnel_count().await; + online_ids.insert(ctrl.session_id.clone()); + let mut active_conns = 0usize; + let mut client_tunnels = Vec::new(); + for s in ctrl.tunnel_summaries().await { + *tunnel_type_count.entry(s.tunnel_type.clone()).or_default() += 1; + let traffic = self.metrics.tunnel_snapshot(&s.name); + let tunnel_conns = traffic.as_ref().map(|t| t.active_conns).unwrap_or(0); + active_conns += tunnel_conns; + client_tunnels.push(TunnelInfo { + name: s.name, + tunnel_type: s.tunnel_type, + remote_addr: s.remote_addr, + local_addr: s.local_addr, + session_id: ctrl.session_id.clone(), + status: s.status, + today_traffic_in: traffic.as_ref().map(|t| t.today_traffic_in).unwrap_or(0), + today_traffic_out: traffic.as_ref().map(|t| t.today_traffic_out).unwrap_or(0), + active_conns: tunnel_conns, + last_start_time: traffic + .as_ref() + .and_then(|t| format_tunnel_time(t.last_start_at)), + }); + } + clients.push(ClientInfo { + session_id: ctrl.session_id.clone(), + user: ctrl.user.clone(), + hostname: ctrl.hostname.clone(), + os: ctrl.os.clone(), + arch: ctrl.arch.clone(), + client_ip: ctrl.client_ip.clone(), + version: ctrl.version.clone(), + tunnel_count, + active_conns, + connected_secs: ctrl.connected_at.elapsed().as_secs(), + status: "online".into(), + }); + tunnels.extend(client_tunnels); + } + + for (id, rec) in offline.iter() { + if online_ids.contains(id) { + continue; + } + clients.push(ClientInfo { + session_id: rec.session_id.clone(), + user: rec.user.clone(), + hostname: rec.hostname.clone(), + os: rec.os.clone(), + arch: rec.arch.clone(), + client_ip: rec.client_ip.clone(), + version: rec.version.clone(), + tunnel_count: rec.tunnel_count, + active_conns: 0, + connected_secs: rec.disconnected_at.elapsed().as_secs(), + status: "offline".into(), + }); + } + + clients.sort_by(|a, b| { + let ao = a.status == "online"; + let bo = b.status == "online"; + bo.cmp(&ao).then_with(|| a.session_id.cmp(&b.session_id)) + }); + tunnels.sort_by(|a, b| a.name.cmp(&b.name).then(a.session_id.cmp(&b.session_id))); + + let server_stats = self.metrics.server_snapshot(); + let total_clients = clients.len(); + + DashboardSnapshot { + clients, + tunnels, + tunnel_type_count, + active_conns: server_stats.active_conns, + total_client_counts: total_clients, + total_traffic_in: server_stats.total_traffic_in, + total_traffic_out: server_stats.total_traffic_out, + } + } +} + +fn format_tunnel_time(unix: Option) -> Option { + let ts = unix?; + let dt = chrono::DateTime::from_timestamp(ts, 0)?; + Some( + dt.with_timezone(&chrono::Local) + .format("%m-%d %H:%M:%S") + .to_string(), + ) +} diff --git a/server/src/service/ingress.rs b/server/src/service/ingress.rs new file mode 100644 index 0000000..8d5563c --- /dev/null +++ b/server/src/service/ingress.rs @@ -0,0 +1,147 @@ +use super::Service; +use anyhow::{anyhow, Result}; +use orbien_core::msg::{self, Message}; +use orbien_core::transport::{self, boxed_stream, DynStream}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::{TcpListener, TcpStream}; + +impl Service { + pub(super) async fn run_tcp(self: Arc, listener: TcpListener) -> Result<()> { + loop { + let (stream, peer) = listener.accept().await?; + let svc = Arc::clone(&self); + tokio::spawn(async move { + if let Err(e) = svc.handle_tcp_or_websocket(stream, peer).await { + tracing::warn!(%peer, error = %e, "tcp/ws connection closed with error"); + } + }); + } + } + + async fn handle_tcp_or_websocket( + self: Arc, + stream: TcpStream, + peer: SocketAddr, + ) -> Result<()> { + let mut peek_buf = [0u8; 16]; + let n = stream.peek(&mut peek_buf).await.unwrap_or(0); + let physical = if transport::is_websocket_http_request(&peek_buf[..n]) { + tracing::debug!(%peer, transport = "websocket", "upgrade"); + transport::accept_websocket(stream).await? + } else { + tracing::debug!(%peer, transport = "tcp", "incoming connection"); + boxed_stream(stream) + }; + let physical = transport::check_and_enable_tls( + physical, + Arc::clone(&self.tls_config), + self.cfg.transport.tls.force, + ) + .await?; + self.handle_physical(physical, peer).await + } + + async fn handle_physical(self: Arc, physical: DynStream, peer: SocketAddr) -> Result<()> { + if self.cfg.transport.tcp_mux { + tracing::debug!(%peer, "yamux server session started"); + let svc = Arc::clone(&self); + transport::serve_yamux_session(physical, move |stream| { + let svc = Arc::clone(&svc); + tokio::spawn(async move { + if let Err(e) = svc.handle_connection(stream, peer).await { + tracing::debug!(error = %e, "yamux stream closed with error"); + } + }); + }) + .await + .map_err(|e| { + let msg = e.to_string(); + + if msg.contains("unknown version: 65") || msg.contains("unknown version: 87") { + anyhow!( + "yamux session {peer}: {e} — transport.tcpMux mismatch: server expects yamux \ + (tcpMux=true) but peer sent a raw control frame (Login 'A'=65 / NewDataConn 'W'=87). \ + Set the same tcpMux on orbien and orbien-server, then restart both." + ) + } else { + anyhow!("yamux session {peer}: {e}") + } + }) + } else { + self.handle_connection(physical, peer).await + } + } + + pub(super) async fn run_kcp(self: Arc, mut listener: kcp_tokio::KcpListener) -> Result<()> { + loop { + let (stream, peer) = transport::accept_kcp(&mut listener).await?; + tracing::debug!(%peer, transport = "kcp", "incoming connection"); + let svc = Arc::clone(&self); + tokio::spawn(async move { + let result = async { + let stream = transport::check_and_enable_tls( + stream, + Arc::clone(&svc.tls_config), + svc.cfg.transport.tls.force, + ) + .await?; + svc.handle_physical(stream, peer).await + } + .await; + if let Err(e) = result { + tracing::warn!(%peer, error = %e, "kcp connection closed with error"); + } + }); + } + } + + pub(super) async fn run_quic(self: Arc, endpoint: quinn::Endpoint) -> Result<()> { + loop { + let incoming = endpoint + .accept() + .await + .ok_or_else(|| anyhow!("quic endpoint closed"))?; + let svc = Arc::clone(&self); + tokio::spawn(async move { + match incoming.await { + Ok(conn) => { + let peer = conn.remote_address(); + tracing::info!(%peer, "quic session accepted"); + if let Err(e) = svc.handle_quic_connection(conn).await { + tracing::debug!(%peer, error = %e, "quic session ended"); + } + } + Err(e) => tracing::warn!(error = %e, "quic accept failed"), + } + }); + } + } + + async fn handle_quic_connection(self: Arc, conn: quinn::Connection) -> Result<()> { + loop { + let (send, recv) = conn.accept_bi().await?; + let stream = transport::quic_bi(send, recv); + let svc = Arc::clone(&self); + let peer = conn.remote_address(); + tokio::spawn(async move { + if let Err(e) = svc.handle_connection(stream, peer).await { + tracing::debug!(%peer, error = %e, "quic stream closed with error"); + } + }); + } + } + + async fn handle_connection( + self: Arc, + mut stream: DynStream, + peer: SocketAddr, + ) -> Result<()> { + let first = msg::read_msg(&mut stream).await?; + match first { + Message::Login(login) => self.register_control(stream, login, peer).await, + Message::NewDataConn(nw) => self.register_data_conn(stream, nw).await, + other => Err(anyhow!("unexpected first message: {:?}", other.type_byte())), + } + } +} diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs new file mode 100644 index 0000000..3ce0619 --- /dev/null +++ b/server/src/service/mod.rs @@ -0,0 +1,214 @@ +mod dashboard_view; +mod ingress; +mod session_registry; + +use crate::access::AccessPolicy; +use crate::control::Control; +use crate::metrics::MemMetrics; +use crate::tunnel::{run_http_gw_listener, run_https_gw_listener, HttpGw, HttpsGw}; +use anyhow::{anyhow, Result}; +use orbien_core::config::ServerConfig; +use orbien_core::transport; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; +use tokio::net::TcpListener; +use tokio::sync::{Mutex, Notify}; +use tokio::task::JoinSet; + +#[allow(unused_imports)] // public API re-export +pub use dashboard_view::DashboardSnapshot; + +struct OfflineClientRecord { + session_id: String, + user: String, + hostname: String, + os: String, + arch: String, + client_ip: String, + version: String, + tunnel_count: usize, + disconnected_at: Instant, +} + +pub struct Service { + cfg: ServerConfig, + access: Arc, + controls: Arc>>>, + offline_clients: Arc>>, + http_gw: Option>, + https_gw: Option>, + tls_config: Arc, + metrics: Arc, +} + +impl Service { + pub fn new(cfg: ServerConfig) -> Result { + let access = Arc::new(AccessPolicy::from_server_config(&cfg)?); + let http_gw = if cfg.http_gw_enabled() { + Some(Arc::new(HttpGw::new(cfg.http_gw_port))) + } else { + None + }; + let https_gw = if cfg.https_gw_enabled() { + Some(Arc::new(HttpsGw::new(cfg.https_gw_port))) + } else { + None + }; + let tls = &cfg.transport.tls; + let tls_config = + transport::new_server_tls_config(&tls.cert_file, &tls.key_file, &tls.trusted_ca_file)?; + if tls.force { + tracing::info!("transport.tls.force=true — non-TLS control connections rejected"); + } + Ok(Self { + cfg, + access, + controls: Arc::new(Mutex::new(HashMap::new())), + offline_clients: Arc::new(Mutex::new(HashMap::new())), + http_gw, + https_gw, + tls_config, + metrics: MemMetrics::new(), + }) + } + + pub async fn run(self) -> Result<()> { + let this = Arc::new(self); + + let tcp_addr = this.cfg.listen.clone(); + let tcp_listener = TcpListener::bind(&tcp_addr).await?; + tracing::info!( + %tcp_addr, + ws_path = transport::ORBIEN_WEBSOCKET_PATH, + tcp_mux = this.cfg.transport.tcp_mux, + "tcp/websocket control/data listener ready" + ); + + let gw_shutdown = Arc::new(Notify::new()); + let mut set = JoinSet::new(); + let listen_host = this + .cfg + .listen_host() + .map_err(|e| anyhow!("invalid listen: {e}"))?; + + if let Some(ref gw) = this.http_gw { + let bind = this.cfg.proxy_addr.clone(); + let port = this.cfg.http_gw_port; + let gw = Arc::clone(gw); + let access = Arc::clone(&this.access); + let shutdown = Arc::clone(&gw_shutdown); + set.spawn( + async move { run_http_gw_listener(bind, port, gw, access, shutdown).await }, + ); + } + + if let Some(ref gw) = this.https_gw { + let bind = this.cfg.proxy_addr.clone(); + let port = this.cfg.https_gw_port; + let gw = Arc::clone(gw); + let access = Arc::clone(&this.access); + let shutdown = Arc::clone(&gw_shutdown); + set.spawn(async move { + run_https_gw_listener(bind, port, gw, access, shutdown).await + }); + } + + if this.cfg.quic_enabled() { + let quic_addr: SocketAddr = format!("{}:{}", listen_host, this.cfg.quic_port) + .parse() + .map_err(|e| anyhow!("invalid quic bind addr: {e}"))?; + let endpoint = transport::build_server_endpoint( + quic_addr, + this.cfg.transport.quic.keepalive(), + this.cfg.transport.quic.idle_timeout(), + this.cfg.transport.quic.max_incoming_streams, + &this.cfg.transport.tls.cert_file, + &this.cfg.transport.tls.key_file, + &this.cfg.transport.tls.trusted_ca_file, + )?; + tracing::info!(%quic_addr, "quic control/data listener ready"); + let svc = Arc::clone(&this); + set.spawn(async move { svc.run_quic(endpoint).await }); + } + + if this.cfg.kcp_enabled() { + let kcp_addr: SocketAddr = format!("{}:{}", listen_host, this.cfg.kcp_port) + .parse() + .map_err(|e| anyhow!("invalid kcp bind addr: {e}"))?; + let listener = transport::bind_kcp_listener(kcp_addr).await?; + tracing::info!( + %kcp_addr, + tcp_mux = this.cfg.transport.tcp_mux, + "kcp control/data listener ready" + ); + let svc = Arc::clone(&this); + set.spawn(async move { svc.run_kcp(listener).await }); + } + + if this.cfg.dashboard.enabled() { + let web_cfg = this.cfg.dashboard.clone(); + let svc = Arc::clone(&this); + set.spawn(async move { crate::dashboard::run(svc, web_cfg).await }); + } + + let svc = Arc::clone(&this); + set.spawn(async move { svc.run_tcp(tcp_listener).await }); + + let first = set + .join_next() + .await + .ok_or_else(|| anyhow!("no listener tasks"))?; + gw_shutdown.notify_waiters(); + set.abort_all(); + while set.join_next().await.is_some() {} + + match first { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e), + Err(e) if e.is_cancelled() => Ok(()), + Err(e) => Err(anyhow!("listener task join: {e}")), + } + } + + pub fn cfg(&self) -> &ServerConfig { + &self.cfg + } + + pub fn metrics(&self) -> &Arc { + &self.metrics + } + + pub async fn kick_client(&self, session_id: &str) -> Result<()> { + let control = { + let mut map = self.controls.lock().await; + map.remove(session_id) + }; + match control { + Some(c) => { + let tunnel_count = c.tunnel_count().await; + { + let mut offline = self.offline_clients.lock().await; + offline.insert( + session_id.to_string(), + OfflineClientRecord { + session_id: session_id.to_string(), + user: c.user.clone(), + hostname: c.hostname.clone(), + os: c.os.clone(), + arch: c.arch.clone(), + client_ip: c.client_ip.clone(), + version: c.version.clone(), + tunnel_count, + disconnected_at: Instant::now(), + }, + ); + } + c.kick("kicked from dashboard").await; + Ok(()) + } + None => Err(anyhow!("client not online: {session_id}")), + } + } +} diff --git a/server/src/service/session_registry.rs b/server/src/service/session_registry.rs new file mode 100644 index 0000000..392d8e1 --- /dev/null +++ b/server/src/service/session_registry.rs @@ -0,0 +1,163 @@ +use super::{OfflineClientRecord, Service}; +use crate::control::Control; +use crate::metrics::ServerMetrics; +use anyhow::{anyhow, Result}; +use orbien_core::auth; +use orbien_core::msg::{self, Login, LoginResp, Message, NewDataConn}; +use orbien_core::transport::DynStream; +use orbien_core::VERSION; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; +use uuid::Uuid; + +impl Service { + pub(crate) async fn register_control( + self: Arc, + stream: DynStream, + login: Login, + peer: SocketAddr, + ) -> Result<()> { + if !auth::verify_login(&self.cfg.auth.token, &login.auth_digest, login.timestamp) { + let mut stream = stream; + let _ = msg::write_msg( + &mut stream, + &Message::LoginResp(LoginResp { + version: VERSION.into(), + session_id: String::new(), + error: "authorization failed".into(), + }), + ) + .await; + return Err(anyhow!("authorization failed")); + } + + let session_id = if login.session_id.is_empty() { + short_session_id() + } else { + login.session_id.clone() + }; + + let mut stream = stream; + msg::write_msg( + &mut stream, + &Message::LoginResp(LoginResp { + version: VERSION.into(), + session_id: session_id.clone(), + error: String::new(), + }), + ) + .await?; + + tracing::info!(%session_id, %peer, pool = login.pool_count, "client logged in"); + + let max_pool = self.cfg.transport.max_conn_pool.max(0) as usize; + let pool_count = (login.pool_count.max(0) as usize).min(max_pool); + + let client_ip = peer.ip().to_string(); + + let control = Control::new( + session_id.clone(), + stream, + self.cfg.clone(), + pool_count, + self.http_gw.clone(), + self.https_gw.clone(), + Arc::clone(&self.access), + login.user.clone(), + login.hostname.clone(), + login.os.clone(), + login.arch.clone(), + login.version.clone(), + client_ip, + Arc::clone(&self.metrics), + ); + let control = Arc::new(control); + + { + let mut offline = self.offline_clients.lock().await; + offline.remove(&session_id); + } + + let old = { + let mut map = self.controls.lock().await; + map.insert(session_id.clone(), Arc::clone(&control)) + }; + + if let Some(old) = old { + old.shutdown().await; + } + + self.metrics.new_client(&session_id); + + let controls = Arc::clone(&self.controls); + let offline_clients = Arc::clone(&self.offline_clients); + let metrics = Arc::clone(&self.metrics); + let rid = session_id.clone(); + let result = Arc::clone(&control).run().await; + control.shutdown().await; + metrics.close_client(); + + let tunnel_count = control.tunnel_count().await; + let mut map = controls.lock().await; + if map + .get(&rid) + .map(|c| Arc::ptr_eq(c, &control)) + .unwrap_or(false) + { + map.remove(&rid); + } + if !map.contains_key(&rid) { + drop(map); + let mut offline = offline_clients.lock().await; + offline.insert( + rid.clone(), + OfflineClientRecord { + session_id: rid, + user: control.user.clone(), + hostname: control.hostname.clone(), + os: control.os.clone(), + arch: control.arch.clone(), + client_ip: control.client_ip.clone(), + version: control.version.clone(), + tunnel_count, + disconnected_at: Instant::now(), + }, + ); + } + + result + } + + pub(crate) async fn register_data_conn( + self: Arc, + stream: DynStream, + nw: NewDataConn, + ) -> Result<()> { + if nw.session_id.trim().is_empty() { + return Err(anyhow!("empty session_id for data conn")); + } + if !auth::verify_auth_digest(&self.cfg.auth.token, &nw.auth_digest, nw.timestamp) { + return Err(anyhow!( + "data conn auth failed for session_id={}", + nw.session_id + )); + } + let control = { + let map = self.controls.lock().await; + map.get(&nw.session_id).cloned() + }; + match control { + Some(c) => { + c.push_data_conn(stream).await; + Ok(()) + } + None => Err(anyhow!("unknown session_id for data conn: {}", nw.session_id)), + } + } +} + +fn short_session_id() -> String { + let hex = Uuid::new_v4().simple().to_string(); + hex[..16].to_owned() +} diff --git a/server/src/tunnel/gw.rs b/server/src/tunnel/gw.rs new file mode 100644 index 0000000..f645b6e --- /dev/null +++ b/server/src/tunnel/gw.rs @@ -0,0 +1,265 @@ +use crate::control::Control; +use std::collections::HashMap; +use std::sync::Weak; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub struct HttpRoute { + pub tunnel_name: String, + pub control: Weak, + pub location: String, + pub host_header_rewrite: String, + pub basic_auth_user: String, + pub basic_auth_password: String, + pub route_by_http_user: String, + pub limiter: Option>, +} + +type DomainIndex = HashMap>>; + +pub struct HttpGw { + routes: Mutex, + pub listen_port: u16, +} + +impl HttpGw { + pub fn new(listen_port: u16) -> Self { + Self { + routes: Mutex::new(HashMap::new()), + listen_port, + } + } + + pub async fn register(&self, domain: &str, route: HttpRoute) -> anyhow::Result<()> { + let key = normalize_host(domain); + if key.is_empty() { + return Err(anyhow::anyhow!("empty http domain")); + } + let mut map = self.routes.lock().await; + let by_user = map.entry(key.clone()).or_default(); + let list = by_user.entry(route.route_by_http_user.clone()).or_default(); + + if let Some(existing) = list.iter().find(|r| r.location == route.location) { + if existing.tunnel_name != route.tunnel_name { + return Err(anyhow::anyhow!( + "router config conflict: domain={key} location={} routeByHTTPUser={}", + route.location, + route.route_by_http_user + )); + } + list.retain(|r| r.location != route.location); + } + + list.push(route); + list.sort_by(|a, b| b.location.cmp(&a.location)); + tracing::info!(domain = %key, "http route registered"); + Ok(()) + } + + pub async fn unregister_tunnel(&self, tunnel_name: &str) { + let mut map = self.routes.lock().await; + map.retain(|_, by_user| { + by_user.retain(|_, list| { + list.retain(|r| r.tunnel_name != tunnel_name); + !list.is_empty() + }); + !by_user.is_empty() + }); + } + + pub async fn lookup(&self, host: &str, path: &str, route_user: &str) -> Option { + let key = normalize_host(host); + let map = self.routes.lock().await; + lookup_exact_or_all_users(&map, &key, path, route_user).cloned() + } +} + +fn lookup_exact_or_all_users<'a>( + map: &'a DomainIndex, + host: &str, + path: &str, + route_user: &str, +) -> Option<&'a HttpRoute> { + if let Some(r) = match_location(map, host, path, route_user) { + return Some(r); + } + if !route_user.is_empty() { + return match_location(map, host, path, ""); + } + None +} + +fn match_location<'a>( + map: &'a DomainIndex, + host: &str, + path: &str, + route_user: &str, +) -> Option<&'a HttpRoute> { + let list = map.get(host)?.get(route_user)?; + for route in list { + if path.starts_with(&route.location) { + return Some(route); + } + } + None +} + +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + let without_port = if let Some(h) = host.strip_prefix('[') { + if let Some(end) = h.find(']') { + &h[..end] + } else { + host + } + } else { + host.split(':').next().unwrap_or(host) + }; + without_port + .trim() + .trim_end_matches('.') + .to_ascii_lowercase() +} + +pub fn build_domains( + domains: &[String], + root_domain: &str, +) -> anyhow::Result> { + let root = normalize_host(root_domain); + let entries: Vec = domains + .iter() + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()) + .collect(); + + if entries.is_empty() { + return Err(anyhow::anyhow!( + "http/https requires at least one domain (fullDomain or subdomain prefix)" + )); + } + + let mut out = Vec::new(); + for entry in &entries { + out.push(expand_domain_entry(entry, &root)?); + } + + let mut seen = std::collections::HashSet::new(); + out.retain(|d| seen.insert(d.clone())); + Ok(out) +} + +fn expand_domain_entry(entry: &str, root: &str) -> anyhow::Result { + let e = normalize_host(entry); + if e.is_empty() { + return Err(anyhow::anyhow!("empty domain entry")); + } + if e.starts_with('.') || e.ends_with('.') || e.contains("..") { + return Err(anyhow::anyhow!("invalid domain entry: {entry:?}")); + } + + if !e.contains('.') { + if e.contains('*') { + return Err(anyhow::anyhow!( + "subdomain prefix must not contain '*': {entry:?}" + )); + } + if e.len() > 63 { + return Err(anyhow::anyhow!( + "subdomain prefix too long (max 63): {entry:?}" + )); + } + if root.is_empty() { + return Err(anyhow::anyhow!( + "domain prefix {entry:?} requires server rootDomain" + )); + } + return Ok(normalize_host(&format!("{e}.{root}"))); + } + + if e.contains('*') { + return Err(anyhow::anyhow!( + "wildcard domains are not supported: {entry:?}" + )); + } + + if !root.is_empty() && is_host_under_root(&e, root) { + return Ok(e); + } + + Ok(e) +} + +fn is_host_under_root(host: &str, root: &str) -> bool { + host == root || host.ends_with(&format!(".{root}")) +} + +pub fn expand_locations(locations: &[String]) -> Vec { + if locations.is_empty() { + vec![String::new()] + } else { + locations.to_vec() + } +} + +pub fn parse_basic_auth(header_value: &str) -> Option<(String, String)> { + const PREFIX: &str = "Basic "; + let value = header_value.trim(); + if value.len() < PREFIX.len() || !value[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) { + return None; + } + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(value[PREFIX.len()..].trim()) + .ok()?; + let text = String::from_utf8(decoded).ok()?; + let (user, pass) = text.split_once(':')?; + Some((user.to_string(), pass.to_string())) +} + +pub fn route_user_from_headers( + is_proxy_request: bool, + authorization: Option<&str>, + proxy_authorization: Option<&str>, +) -> String { + if is_proxy_request { + if let Some(proxy_auth) = proxy_authorization { + return parse_basic_auth(proxy_auth) + .map(|(u, _)| u) + .unwrap_or_default(); + } + return authorization + .and_then(parse_basic_auth) + .map(|(u, _)| u) + .unwrap_or_default(); + } + authorization + .and_then(parse_basic_auth) + .map(|(u, _)| u) + .unwrap_or_default() +} + +pub fn route_basic_auth_ok( + route: &HttpRoute, + is_proxy_request: bool, + authorization: Option<&str>, + proxy_authorization: Option<&str>, +) -> bool { + if route.basic_auth_user.is_empty() && route.basic_auth_password.is_empty() { + return true; + } + let creds = if is_proxy_request { + let Some(h) = proxy_authorization else { + return false; + }; + parse_basic_auth(h) + } else { + let Some(h) = authorization else { + return false; + }; + parse_basic_auth(h) + }; + match creds { + Some((u, p)) => u == route.basic_auth_user && p == route.basic_auth_password, + None => false, + } +} diff --git a/server/src/tunnel/http.rs b/server/src/tunnel/http.rs new file mode 100644 index 0000000..b7f532b --- /dev/null +++ b/server/src/tunnel/http.rs @@ -0,0 +1,400 @@ +use super::gw::{ + build_domains, expand_locations, normalize_host, route_basic_auth_ok, route_user_from_headers, + HttpRoute, HttpGw, +}; +use crate::access::{prepare_ingress, AccessPolicy}; +use crate::control::Control; +use crate::metrics::ServerMetrics; +use anyhow::{anyhow, bail, Result}; +use httparse::Status; +use orbien_core::limit::{maybe_limit, BandwidthLimiter}; +use orbien_core::msg::NewTunnel; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Notify; + +pub struct HttpTunnel { + pub name: String, + pub domains: Vec, + gw: Arc, + closed: AtomicBool, +} + +impl HttpTunnel { + pub async fn register( + np: &NewTunnel, + control: Arc, + gw: Arc, + sub_domain_host: &str, + limiter: Option>, + ) -> Result { + let domains = build_domains(&np.domains, sub_domain_host)?; + let name = np.tunnel_name.clone(); + let locations = expand_locations(&np.locations); + let rewrite = np.host_header_rewrite.clone(); + let basic_auth_user = np.basic_auth_user.clone(); + let basic_auth_password = np.basic_auth_password.clone(); + let route_by_http_user = np.route_by_http_user.clone(); + + gw.unregister_tunnel(&name).await; + + for domain in &domains { + for location in &locations { + gw + .register( + domain, + HttpRoute { + tunnel_name: name.clone(), + control: Arc::downgrade(&control), + location: location.clone(), + host_header_rewrite: rewrite.clone(), + basic_auth_user: basic_auth_user.clone(), + basic_auth_password: basic_auth_password.clone(), + route_by_http_user: route_by_http_user.clone(), + limiter: limiter.clone(), + }, + ) + .await?; + } + } + + tracing::info!( + tunnel = %name, + domains = ?domains, + locations = ?locations, + route_by_http_user = %route_by_http_user, + basic_auth = !basic_auth_user.is_empty() || !basic_auth_password.is_empty(), + "http tunnel registered" + ); + + Ok(Self { + name, + domains, + gw, + closed: AtomicBool::new(false), + }) + } + + pub async fn close(&self) { + if self + .closed + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + self.gw.unregister_tunnel(&self.name).await; + } + } +} + +pub async fn run_http_gw_listener( + bind_addr: String, + port: u16, + gw: Arc, + access: Arc, + shutdown: Arc, +) -> Result<()> { + let addr = format!("{bind_addr}:{port}"); + let listener = TcpListener::bind(&addr).await?; + tracing::info!(%addr, "http gateway listener ready"); + + loop { + tokio::select! { + _ = shutdown.notified() => break, + accepted = listener.accept() => { + match accepted { + Ok((stream, peer)) => { + orbien_core::net::enable_nodelay(&stream); + let gw = Arc::clone(&gw); + let access = Arc::clone(&access); + tokio::spawn(async move { + if let Err(e) = handle_http_ingress(gw, stream, peer, access).await { + tracing::debug!(%peer, error = %e, "http ingress ended"); + } + }); + } + Err(e) => { + tracing::warn!(error = %e, "http gateway accept failed"); + break; + } + } + } + } + } + Ok(()) +} + +struct ParsedHttpHead { + raw: Vec, + host: String, + path: String, + is_proxy_request: bool, + authorization: Option, + proxy_authorization: Option, +} + +async fn handle_http_ingress( + gw: Arc, + stream: TcpStream, + peer: std::net::SocketAddr, + access: Arc, +) -> Result<()> { + let mut ingress = prepare_ingress(stream, peer, &access).await?; + let head = read_http_request_head(&mut ingress.stream).await?; + + let route_user = route_user_from_headers( + head.is_proxy_request, + head.authorization.as_deref(), + head.proxy_authorization.as_deref(), + ); + + let Some(route) = gw.lookup(&head.host, &head.path, &route_user).await else { + tracing::debug!( + peer = %ingress.peer, + source = %ingress.source, + host = %head.host, + path = %head.path, + %route_user, + "http no route" + ); + write_not_found(&mut ingress.stream).await; + return Ok(()); + }; + + if !route_basic_auth_ok( + &route, + head.is_proxy_request, + head.authorization.as_deref(), + head.proxy_authorization.as_deref(), + ) { + tracing::debug!( + tunnel = %route.tunnel_name, + peer = %ingress.peer, + proxy_mode = head.is_proxy_request, + "http basic auth failed" + ); + if head.is_proxy_request { + write_proxy_unauthorized(&mut ingress.stream).await; + } else { + write_unauthorized(&mut ingress.stream).await; + } + return Ok(()); + } + + let Some(control) = route.control.upgrade() else { + write_not_found(&mut ingress.stream).await; + return Err(anyhow!("http tunnel client gone: {}", route.tunnel_name)); + }; + + let mut raw = head.raw; + if !route.host_header_rewrite.is_empty() { + rewrite_host_header(&mut raw, &route.host_header_rewrite)?; + } + + orbien_core::net::apply_x_forwarded_for(&mut raw, &ingress.source.ip().to_string(), "http")?; + + let data = match control.get_data_conn().await { + Ok(w) => w, + Err(e) => { + write_bad_gateway(&mut ingress.stream).await; + return Err(e); + } + }; + let data = control + .start_data_conn( + data, + &route.tunnel_name, + ingress.source.ip().to_string(), + ingress.source.port(), + ingress + .local + .map(|a| a.ip().to_string()) + .unwrap_or_default(), + ingress.local.map(|a| a.port()).unwrap_or(0), + ) + .await?; + + let mut data = maybe_limit(data, route.limiter.clone()); + let head_len = raw.len() as u64; + data.write_all(&raw).await?; + tracing::debug!( + tunnel = %route.tunnel_name, + host = %head.host, + path = %head.path, + peer = %ingress.peer, + source = %ingress.source, + "http joining ingress <-> data" + ); + let _guard = control.metrics.track_connection(&route.tunnel_name, "http"); + let (to_data, from_data, err) = orbien_core::io::join_counted(ingress.stream, data).await; + control + .metrics + .add_traffic_in(&route.tunnel_name, "http", to_data.saturating_add(head_len)); + control + .metrics + .add_traffic_out(&route.tunnel_name, "http", from_data); + if let Some(e) = err { + tracing::debug!(tunnel = %route.tunnel_name, error = %e, "http join ended"); + } + Ok(()) +} + +async fn read_http_request_head(stream: &mut R) -> Result { + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 2048]; + loop { + let n = stream.read(&mut tmp).await?; + if n == 0 { + bail!("client closed before http headers completed"); + } + buf.extend_from_slice(&tmp[..n]); + if buf.len() > 64 * 1024 { + bail!("http headers too large"); + } + + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut req = httparse::Request::new(&mut headers); + match req.parse(&buf)? { + Status::Complete(_) => { + let method = req.method.unwrap_or("").to_string(); + let target = req.path.unwrap_or("/").to_string(); + let (is_proxy_request, path) = classify_request_target(&method, &target); + + let host = req + .headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("host")) + .map(|h| String::from_utf8_lossy(h.value).into_owned()) + .or_else(|| host_from_absolute_target(&target)) + .ok_or_else(|| anyhow!("missing Host header"))?; + + let authorization = header_value(&req, "authorization"); + let proxy_authorization = header_value(&req, "proxy-authorization"); + + return Ok(ParsedHttpHead { + raw: buf, + host: normalize_host(&host), + path, + is_proxy_request, + authorization, + proxy_authorization, + }); + } + Status::Partial => continue, + } + } +} + +fn header_value(req: &httparse::Request<'_, '_>, name: &str) -> Option { + req.headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case(name)) + .map(|h| String::from_utf8_lossy(h.value).into_owned()) +} + +fn classify_request_target(method: &str, target: &str) -> (bool, String) { + if method.eq_ignore_ascii_case("CONNECT") { + return (true, "/".into()); + } + if let Some(rest) = target + .strip_prefix("http://") + .or_else(|| target.strip_prefix("https://")) + .or_else(|| target.strip_prefix("HTTP://")) + .or_else(|| target.strip_prefix("HTTPS://")) + { + let path = match rest.find('/') { + Some(i) => rest[i..].to_string(), + None => "/".into(), + }; + return (true, path); + } + (false, target.to_string()) +} + +fn host_from_absolute_target(target: &str) -> Option { + let rest = target + .strip_prefix("http://") + .or_else(|| target.strip_prefix("https://")) + .or_else(|| target.strip_prefix("HTTP://")) + .or_else(|| target.strip_prefix("HTTPS://"))?; + let hostport = rest.split('/').next().unwrap_or(rest); + if hostport.is_empty() { + None + } else { + Some(hostport.to_string()) + } +} + +fn rewrite_host_header(buf: &mut Vec, new_host: &str) -> Result<()> { + let lower = b"host:"; + let text = String::from_utf8_lossy(buf); + let mut out = String::new(); + let mut replaced = false; + for line in text.split_inclusive('\n') { + let trimmed_start = line.trim_start_matches([' ', '\t']); + if !replaced + && trimmed_start.len() >= 5 + && trimmed_start.as_bytes()[..5].eq_ignore_ascii_case(lower) + { + let ending = if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + }; + out.push_str("Host: "); + out.push_str(new_host); + out.push_str(ending); + replaced = true; + } else { + out.push_str(line); + } + } + if !replaced { + return Err(anyhow!("Host header not found for rewrite")); + } + *buf = out.into_bytes(); + Ok(()) +} + +pub async fn write_not_found(stream: &mut W) { + let body = "Not Found\n"; + let resp = format!( + "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; +} + +async fn write_unauthorized(stream: &mut W) { + let body = "Unauthorized\n"; + let resp = format!( + "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"Restricted\"\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; +} + +async fn write_proxy_unauthorized(stream: &mut W) { + let body = "Proxy Authentication Required\n"; + let resp = format!( + "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Restricted\"\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; +} + +async fn write_bad_gateway(stream: &mut W) { + let body = "Bad Gateway\n"; + let resp = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; +} diff --git a/server/src/proxy/https.rs b/server/src/tunnel/https.rs similarity index 54% rename from server/src/proxy/https.rs rename to server/src/tunnel/https.rs index 52a0943..7f9c706 100644 --- a/server/src/proxy/https.rs +++ b/server/src/tunnel/https.rs @@ -1,10 +1,10 @@ -use super::vhost::{build_domains, normalize_host}; -use crate::access::{prepare_visitor, AccessPolicy}; +use super::gw::{build_domains, normalize_host}; +use crate::access::{prepare_ingress, AccessPolicy}; use crate::control::Control; use crate::metrics; use anyhow::{anyhow, Result}; use orbien_core::limit::{maybe_limit, BandwidthLimiter}; -use orbien_core::msg::NewProxy; +use orbien_core::msg::NewTunnel; use orbien_core::tls::{peek_client_hello_sni, PrefixedStream}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -14,17 +14,17 @@ use tokio::sync::{Mutex, Notify}; #[derive(Clone)] pub struct HttpsRoute { - pub proxy_name: String, + pub tunnel_name: String, pub control: Weak, pub limiter: Option>, } -pub struct HttpsVhost { +pub struct HttpsGw { routes: Mutex>, pub listen_port: u16, } -impl HttpsVhost { +impl HttpsGw { pub fn new(listen_port: u16) -> Self { Self { routes: Mutex::new(HashMap::new()), @@ -38,14 +38,19 @@ impl HttpsVhost { return Err(anyhow!("empty https domain")); } let mut map = self.routes.lock().await; + if let Some(existing) = map.get(&key) { + if existing.tunnel_name != route.tunnel_name { + return Err(anyhow!("router config conflict: domain={key} (https)")); + } + } map.insert(key.clone(), route); tracing::info!(domain = %key, "https route registered"); Ok(()) } - pub async fn unregister_proxy(&self, proxy_name: &str) { + pub async fn unregister_tunnel(&self, tunnel_name: &str) { let mut map = self.routes.lock().await; - map.retain(|_, r| r.proxy_name != proxy_name); + map.retain(|_, r| r.tunnel_name != tunnel_name); } pub async fn lookup(&self, sni: &str) -> Option { @@ -55,47 +60,48 @@ impl HttpsVhost { } } -pub struct HttpsProxy { +pub struct HttpsTunnel { pub name: String, pub domains: Vec, - vhost: Arc, + gw: Arc, closed: AtomicBool, } -impl HttpsProxy { +impl HttpsTunnel { pub async fn register( - np: &NewProxy, + np: &NewTunnel, control: Arc, - vhost: Arc, + gw: Arc, sub_domain_host: &str, limiter: Option>, ) -> Result { - let domains = build_domains(&np.custom_domains, &np.subdomain, sub_domain_host)?; - let name = np.proxy_name.clone(); + let domains = build_domains(&np.domains, sub_domain_host)?; + let name = np.tunnel_name.clone(); + + gw.unregister_tunnel(&name).await; for domain in &domains { - vhost - .register( - domain, - HttpsRoute { - proxy_name: name.clone(), - control: Arc::downgrade(&control), - limiter: limiter.clone(), - }, - ) - .await?; + gw.register( + domain, + HttpsRoute { + tunnel_name: name.clone(), + control: Arc::downgrade(&control), + limiter: limiter.clone(), + }, + ) + .await?; } tracing::info!( - proxy = %name, + tunnel = %name, domains = ?domains, - "https proxy registered (SNI passthrough)" + "https tunnel registered (SNI passthrough)" ); Ok(Self { name, domains, - vhost, + gw, closed: AtomicBool::new(false), }) } @@ -106,21 +112,21 @@ impl HttpsProxy { .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { - self.vhost.unregister_proxy(&self.name).await; + self.gw.unregister_tunnel(&self.name).await; } } } -pub async fn run_vhost_https_listener( +pub async fn run_https_gw_listener( bind_addr: String, port: u16, - vhost: Arc, + gw: Arc, access: Arc, shutdown: Arc, ) -> Result<()> { let addr = format!("{bind_addr}:{port}"); let listener = TcpListener::bind(&addr).await?; - tracing::info!(%addr, "https vhost listener ready (SNI mux, no TLS terminate)"); + tracing::info!(%addr, "https gateway listener ready (SNI mux, no TLS terminate)"); loop { tokio::select! { @@ -128,16 +134,17 @@ pub async fn run_vhost_https_listener( accepted = listener.accept() => { match accepted { Ok((stream, peer)) => { - let vhost = Arc::clone(&vhost); + orbien_core::net::enable_nodelay(&stream); + let gw = Arc::clone(&gw); let access = Arc::clone(&access); tokio::spawn(async move { - if let Err(e) = handle_https_visitor(vhost, stream, peer, access).await { - tracing::debug!(%peer, error = %e, "https visitor ended"); + if let Err(e) = handle_https_ingress(gw, stream, peer, access).await { + tracing::debug!(%peer, error = %e, "https ingress ended"); } }); } Err(e) => { - tracing::warn!(error = %e, "https vhost accept failed"); + tracing::warn!(error = %e, "https gateway accept failed"); break; } } @@ -147,18 +154,18 @@ pub async fn run_vhost_https_listener( Ok(()) } -async fn handle_https_visitor( - vhost: Arc, +async fn handle_https_ingress( + gw: Arc, stream: TcpStream, peer: std::net::SocketAddr, access: Arc, ) -> Result<()> { - let mut visitor = prepare_visitor(stream, peer, &access).await?; - let (sni, prefix) = peek_client_hello_sni(&mut visitor.stream).await?; - let Some(route) = vhost.lookup(&sni).await else { + let mut ingress = prepare_ingress(stream, peer, &access).await?; + let (sni, prefix) = peek_client_hello_sni(&mut ingress.stream).await?; + let Some(route) = gw.lookup(&sni).await else { tracing::debug!( - peer = %visitor.peer, - visitor = %visitor.visitor, + peer = %ingress.peer, + source = %ingress.source, %sni, "https no route for SNI" ); @@ -167,35 +174,35 @@ async fn handle_https_visitor( }; let Some(control) = route.control.upgrade() else { - return Err(anyhow!("https proxy client gone: {}", route.proxy_name)); + return Err(anyhow!("https tunnel client gone: {}", route.tunnel_name)); }; - let work = control.get_work_conn().await?; - let work = control - .start_work_conn( - work, - &route.proxy_name, - visitor.visitor.ip().to_string(), - visitor.visitor.port(), - visitor + let data = control.get_data_conn().await?; + let data = control + .start_data_conn( + data, + &route.tunnel_name, + ingress.source.ip().to_string(), + ingress.source.port(), + ingress .local .map(|a| a.ip().to_string()) .unwrap_or_default(), - visitor.local.map(|a| a.port()).unwrap_or(0), + ingress.local.map(|a| a.port()).unwrap_or(0), ) .await?; - let work = maybe_limit(work, route.limiter.clone()); - let user = PrefixedStream::new(prefix, visitor.stream); + let data = maybe_limit(data, route.limiter.clone()); + let user = PrefixedStream::new(prefix, ingress.stream); tracing::debug!( - proxy = %route.proxy_name, + tunnel = %route.tunnel_name, %sni, - peer = %visitor.peer, - visitor = %visitor.visitor, - "https joining visitor <-> work (passthrough)" + peer = %ingress.peer, + source = %ingress.source, + "https joining ingress <-> data (passthrough)" ); let _ = - metrics::join_and_record(&control.metrics, &route.proxy_name, "https", user, work).await; + metrics::join_and_record(&control.metrics, &route.tunnel_name, "https", user, data).await; Ok(()) } diff --git a/server/src/proxy/manager.rs b/server/src/tunnel/manager.rs similarity index 54% rename from server/src/proxy/manager.rs rename to server/src/tunnel/manager.rs index 0ec4acd..dcf8a82 100644 --- a/server/src/proxy/manager.rs +++ b/server/src/tunnel/manager.rs @@ -1,15 +1,15 @@ -use super::{HttpProxy, HttpsProxy, TcpProxy, UdpProxy}; +use super::{HttpTunnel, HttpsTunnel, TcpTunnel, UdpTunnel}; use std::collections::HashMap; -pub enum RegisteredProxy { - Tcp(TcpProxy), - Http(HttpProxy), - Https(HttpsProxy), - Udp(UdpProxy), +pub enum RegisteredTunnel { + Tcp(TcpTunnel), + Http(HttpTunnel), + Https(HttpsTunnel), + Udp(UdpTunnel), } -impl RegisteredProxy { - pub fn proxy_type(&self) -> &'static str { +impl RegisteredTunnel { + pub fn tunnel_type(&self) -> &'static str { match self { Self::Tcp(_) => "tcp", Self::Http(_) => "http", @@ -28,32 +28,32 @@ impl RegisteredProxy { } } -struct ProxyEntry { - proxy: RegisteredProxy, +struct TunnelEntry { + tunnel: RegisteredTunnel, local_addr: String, } -pub struct ProxyManager { - proxies: HashMap, +pub struct TunnelManager { + tunnels: HashMap, } -impl ProxyManager { +impl TunnelManager { pub fn new() -> Self { Self { - proxies: HashMap::new(), + tunnels: HashMap::new(), } } pub async fn insert( &mut self, name: String, - proxy: RegisteredProxy, + tunnel: RegisteredTunnel, local_addr: String, ) -> Option<&'static str> { - let entry = ProxyEntry { proxy, local_addr }; - if let Some(old) = self.proxies.insert(name, entry) { - let ty = old.proxy.proxy_type(); - old.proxy.close().await; + let entry = TunnelEntry { tunnel, local_addr }; + if let Some(old) = self.tunnels.insert(name, entry) { + let ty = old.tunnel.tunnel_type(); + old.tunnel.close().await; Some(ty) } else { None @@ -61,9 +61,9 @@ impl ProxyManager { } pub async fn remove(&mut self, name: &str) -> Option<&'static str> { - if let Some(entry) = self.proxies.remove(name) { - let ty = entry.proxy.proxy_type(); - entry.proxy.close().await; + if let Some(entry) = self.tunnels.remove(name) { + let ty = entry.tunnel.tunnel_type(); + entry.tunnel.close().await; Some(ty) } else { None @@ -71,27 +71,27 @@ impl ProxyManager { } pub async fn close_all(&mut self) -> Vec<(String, &'static str)> { - let mut closed = Vec::with_capacity(self.proxies.len()); - for (name, entry) in self.proxies.drain() { - closed.push((name, entry.proxy.proxy_type())); - entry.proxy.close().await; + let mut closed = Vec::with_capacity(self.tunnels.len()); + for (name, entry) in self.tunnels.drain() { + closed.push((name, entry.tunnel.tunnel_type())); + entry.tunnel.close().await; } closed } - pub fn summaries(&self) -> Vec { - self.proxies + pub fn summaries(&self) -> Vec { + self.tunnels .iter() .map(|(name, entry)| { - let (proxy_type, remote_addr) = match &entry.proxy { - RegisteredProxy::Tcp(t) => ("tcp".into(), format!(":{}", t.remote_port)), - RegisteredProxy::Http(h) => ("http".into(), h.domains.join(",")), - RegisteredProxy::Https(h) => ("https".into(), h.domains.join(",")), - RegisteredProxy::Udp(u) => ("udp".into(), format!(":{}", u.remote_port)), + let (tunnel_type, remote_addr) = match &entry.tunnel { + RegisteredTunnel::Tcp(t) => ("tcp".into(), format!(":{}", t.remote_port)), + RegisteredTunnel::Http(h) => ("http".into(), h.domains.join(",")), + RegisteredTunnel::Https(h) => ("https".into(), h.domains.join(",")), + RegisteredTunnel::Udp(u) => ("udp".into(), format!(":{}", u.remote_port)), }; - ProxySummary { + TunnelSummary { name: name.clone(), - proxy_type, + tunnel_type, remote_addr, local_addr: entry.local_addr.clone(), status: "online".into(), @@ -101,7 +101,7 @@ impl ProxyManager { } pub fn len(&self) -> usize { - self.proxies.len() + self.tunnels.len() } } @@ -124,10 +124,10 @@ pub fn format_local_addr(ip: &str, port: i32) -> String { } #[derive(Debug, Clone, serde::Serialize)] -pub struct ProxySummary { +pub struct TunnelSummary { pub name: String, #[serde(rename = "type")] - pub proxy_type: String, + pub tunnel_type: String, #[serde(rename = "remoteAddr")] pub remote_addr: String, #[serde(rename = "localAddr")] diff --git a/server/src/tunnel/mod.rs b/server/src/tunnel/mod.rs new file mode 100644 index 0000000..8048ae2 --- /dev/null +++ b/server/src/tunnel/mod.rs @@ -0,0 +1,13 @@ +mod http; +mod https; +mod manager; +mod tcp; +mod udp; +mod gw; + +pub use http::{run_http_gw_listener, HttpTunnel}; +pub use https::{run_https_gw_listener, HttpsTunnel, HttpsGw}; +pub use manager::{format_local_addr, TunnelManager, TunnelSummary, RegisteredTunnel}; +pub use tcp::TcpTunnel; +pub use udp::UdpTunnel; +pub use gw::HttpGw; diff --git a/server/src/proxy/tcp.rs b/server/src/tunnel/tcp.rs similarity index 70% rename from server/src/proxy/tcp.rs rename to server/src/tunnel/tcp.rs index e5e6db3..aaf62f9 100644 --- a/server/src/proxy/tcp.rs +++ b/server/src/tunnel/tcp.rs @@ -1,4 +1,4 @@ -use crate::access::{prepare_visitor, AccessPolicy}; +use crate::access::{prepare_ingress, AccessPolicy}; use crate::control::Control; use crate::metrics; use anyhow::Result; @@ -9,7 +9,7 @@ use tokio::net::TcpListener; use tokio::sync::{Mutex, Notify}; use tokio::task::JoinHandle; -pub struct TcpProxy { +pub struct TcpTunnel { pub name: String, pub remote_port: u16, closed: Arc, @@ -17,7 +17,7 @@ pub struct TcpProxy { accept_task: Mutex>>, } -impl TcpProxy { +impl TcpTunnel { pub async fn start( name: String, bind_addr: String, @@ -28,13 +28,13 @@ impl TcpProxy { ) -> Result { let addr = format!("{bind_addr}:{remote_port}"); let listener = TcpListener::bind(&addr).await?; - tracing::info!(%addr, proxy = %name, "tcp proxy listening"); + tracing::info!(%addr, tunnel = %name, "tcp tunnel listening"); let closed = Arc::new(AtomicBool::new(false)); let notify = Arc::new(Notify::new()); let closed_flag = Arc::clone(&closed); let notify_wait = Arc::clone(¬ify); - let proxy_name = name.clone(); + let tunnel_name = name.clone(); let limiter_spawn = limiter.clone(); let control_weak = Arc::downgrade(&control); @@ -45,28 +45,29 @@ impl TcpProxy { _ = notify_wait.notified() => break, accepted = listener.accept() => { match accepted { - Ok((user_conn, peer)) => { + Ok((stream, peer)) => { + orbien_core::net::enable_nodelay(&stream); if closed_flag.load(Ordering::SeqCst) { break; } let Some(ctl) = control_weak.upgrade() else { break; }; - let pname = proxy_name.clone(); + let pname = tunnel_name.clone(); let lim = limiter_spawn.clone(); let access = Arc::clone(&access); tokio::spawn(async move { - if let Err(e) = handle_user_conn( + if let Err(e) = handle_ingress( ctl, &pname, - user_conn, + stream, peer, lim, access, ) .await { - tracing::debug!(proxy = %pname, error = %e, "user conn ended"); + tracing::debug!(tunnel = %pname, error = %e, "ingress ended"); } }); } @@ -90,7 +91,7 @@ impl TcpProxy { } pub async fn close(&self) { - tracing::info!(proxy = %self.name, remote_port = self.remote_port, "tcp proxy closing"); + tracing::info!(tunnel = %self.name, remote_port = self.remote_port, "tcp tunnel closing"); self.closed.store(true, Ordering::SeqCst); self.notify.notify_waiters(); if let Some(h) = self.accept_task.lock().await.take() { @@ -100,7 +101,7 @@ impl TcpProxy { } } -impl Drop for TcpProxy { +impl Drop for TcpTunnel { fn drop(&mut self) { self.closed.store(true, Ordering::SeqCst); self.notify.notify_waiters(); @@ -110,39 +111,39 @@ impl Drop for TcpProxy { } } -async fn handle_user_conn( +async fn handle_ingress( control: Arc, - proxy_name: &str, - user_conn: tokio::net::TcpStream, + tunnel_name: &str, + stream: tokio::net::TcpStream, peer: std::net::SocketAddr, limiter: Option>, access: Arc, ) -> Result<()> { - let visitor = prepare_visitor(user_conn, peer, &access).await?; - let work = control.get_work_conn().await?; - let work = control - .start_work_conn( - work, - proxy_name, - visitor.visitor.ip().to_string(), - visitor.visitor.port(), - visitor + let ingress = prepare_ingress(stream, peer, &access).await?; + let data = control.get_data_conn().await?; + let data = control + .start_data_conn( + data, + tunnel_name, + ingress.source.ip().to_string(), + ingress.source.port(), + ingress .local .map(|a| a.ip().to_string()) .unwrap_or_default(), - visitor.local.map(|a| a.port()).unwrap_or(0), + ingress.local.map(|a| a.port()).unwrap_or(0), ) .await?; - let work = maybe_limit(work, limiter); + let data = maybe_limit(data, limiter); tracing::debug!( - proxy = %proxy_name, - peer = %visitor.peer, - visitor = %visitor.visitor, - "joining visitor <-> work" + tunnel = %tunnel_name, + peer = %ingress.peer, + source = %ingress.source, + "joining ingress <-> data" ); let _ = - metrics::join_and_record(&control.metrics, proxy_name, "tcp", visitor.stream, work).await; + metrics::join_and_record(&control.metrics, tunnel_name, "tcp", ingress.stream, data).await; Ok(()) } diff --git a/server/src/proxy/udp.rs b/server/src/tunnel/udp.rs similarity index 74% rename from server/src/proxy/udp.rs rename to server/src/tunnel/udp.rs index f04ab1b..4102f95 100644 --- a/server/src/proxy/udp.rs +++ b/server/src/tunnel/udp.rs @@ -3,7 +3,7 @@ use crate::metrics::{MemMetrics, ServerMetrics}; use anyhow::Result; use orbien_core::limit::{maybe_limit, BandwidthLimiter}; use orbien_core::msg::{self, Message, UdpPacket}; -use orbien_core::udp::{forward_user_conn, CHANNEL_CAP, SERVER_WORK_READ_DEADLINE}; +use orbien_core::udp::{forward_user_conn, CHANNEL_CAP, SERVER_DATA_READ_DEADLINE}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -13,7 +13,7 @@ use tokio::sync::{mpsc, Mutex, Notify}; use tokio::task::JoinHandle; use tokio::time::{sleep, timeout}; -pub struct UdpProxy { +pub struct UdpTunnel { pub name: String, pub remote_port: u16, closed: Arc, @@ -23,7 +23,7 @@ pub struct UdpProxy { _udp: Arc, } -impl UdpProxy { +impl UdpTunnel { pub async fn start( name: String, bind_addr: String, @@ -34,7 +34,7 @@ impl UdpProxy { ) -> Result { let addr = format!("{bind_addr}:{remote_port}"); let udp = Arc::new(UdpSocket::bind(&addr).await?); - tracing::info!(%addr, proxy = %name, "udp proxy listening"); + tracing::info!(%addr, tunnel = %name, "udp tunnel listening"); let closed = Arc::new(AtomicBool::new(false)); let notify = Arc::new(Notify::new()); @@ -58,12 +58,12 @@ impl UdpProxy { { let closed_flag = Arc::clone(&closed); let notify_wait = Arc::clone(¬ify); - let proxy_name = name.clone(); + let tunnel_name = name.clone(); let control = Arc::downgrade(&control); tasks.push(tokio::spawn(async move { sleep(Duration::from_millis(500)).await; - work_conn_loop( - proxy_name, + data_conn_loop( + tunnel_name, control, limiter, send_rx, @@ -86,7 +86,7 @@ impl UdpProxy { } pub async fn close(&self) { - tracing::info!(proxy = %self.name, remote_port = self.remote_port, "udp proxy closing"); + tracing::info!(tunnel = %self.name, remote_port = self.remote_port, "udp tunnel closing"); self.closed.store(true, Ordering::SeqCst); self.notify.notify_waiters(); let tasks = std::mem::take(&mut *self.tasks.lock().await); @@ -97,7 +97,7 @@ impl UdpProxy { } } -impl Drop for UdpProxy { +impl Drop for UdpTunnel { fn drop(&mut self) { self.closed.store(true, Ordering::SeqCst); self.notify.notify_waiters(); @@ -112,8 +112,8 @@ async fn abort_wait(h: JoinHandle<()>) { let _ = h.await; } -async fn work_conn_loop( - proxy_name: String, +async fn data_conn_loop( + tunnel_name: String, control: std::sync::Weak, limiter: Option>, mut send_rx: mpsc::Receiver, @@ -126,17 +126,17 @@ async fn work_conn_loop( return; }; - let work = { + let data = { tokio::select! { _ = notify.notified() => return, - w = control.get_work_conn() => w, + w = control.get_data_conn() => w, } }; - let work = match work { + let data = match data { Ok(w) => w, Err(e) => { - tracing::warn!(proxy = %proxy_name, error = %e, "udp get work conn failed"); + tracing::warn!(tunnel = %tunnel_name, error = %e, "udp get data conn failed"); tokio::select! { _ = notify.notified() => return, _ = sleep(Duration::from_secs(1)) => continue, @@ -144,34 +144,34 @@ async fn work_conn_loop( } }; - let work = match control - .start_work_conn(work, &proxy_name, String::new(), 0, String::new(), 0) + let data = match control + .start_data_conn(data, &tunnel_name, String::new(), 0, String::new(), 0) .await { Ok(w) => w, Err(e) => { - tracing::warn!(proxy = %proxy_name, error = %e, "udp StartWorkConn failed"); + tracing::warn!(tunnel = %tunnel_name, error = %e, "udp StartDataConn failed"); sleep(Duration::from_secs(1)).await; continue; } }; - let work = maybe_limit(work, limiter.clone()); - let (reader, mut writer) = tokio::io::split(work); + let data = maybe_limit(data, limiter.clone()); + let (reader, mut writer) = tokio::io::split(data); tracing::info!( - proxy = %proxy_name, - "udp work conn established" + tunnel = %tunnel_name, + "udp data conn established" ); let metrics = Arc::clone(&control.metrics); - let _guard = metrics.track_connection(&proxy_name, "udp"); + let _guard = metrics.track_connection(&tunnel_name, "udp"); let (fail_tx, mut fail_rx) = mpsc::channel::<()>(1); let read_tx_r = read_tx.clone(); let fail_r = fail_tx.clone(); - let name_r = proxy_name.clone(); + let name_r = tunnel_name.clone(); let metrics_r = Arc::clone(&metrics); let mut reader_task = Some(tokio::spawn(async move { - work_reader(reader, read_tx_r, fail_r, name_r, metrics_r).await; + data_reader(reader, read_tx_r, fail_r, name_r, metrics_r).await; })); let reconnect = loop { @@ -193,21 +193,21 @@ async fn work_conn_loop( Some(pkt) => { let nbytes = pkt.content.len() as u64; tracing::trace!( - proxy = %proxy_name, + tunnel = %tunnel_name, len = nbytes, - "udp packet to work" + "udp packet to data" ); if msg::write_msg(&mut writer, &Message::UdpPacket(pkt)) .await .is_err() { - tracing::warn!(proxy = %proxy_name, "udp work write error"); + tracing::warn!(tunnel = %tunnel_name, "udp data write error"); if let Some(h) = reader_task.take() { abort_wait(h).await; } break true; } - metrics.add_traffic_in(&proxy_name, "udp", nbytes); + metrics.add_traffic_in(&tunnel_name, "udp", nbytes); } None => { if let Some(h) = reader_task.take() { @@ -221,47 +221,47 @@ async fn work_conn_loop( }; if reconnect { - tracing::info!(proxy = %proxy_name, "udp work conn lost; reconnecting"); + tracing::info!(tunnel = %tunnel_name, "udp data conn lost; reconnecting"); } } } -async fn work_reader( +async fn data_reader( mut reader: R, read_tx: mpsc::Sender, fail_tx: mpsc::Sender<()>, - proxy_name: String, + tunnel_name: String, metrics: Arc, ) { loop { - match timeout(SERVER_WORK_READ_DEADLINE, msg::read_msg(&mut reader)).await { + match timeout(SERVER_DATA_READ_DEADLINE, msg::read_msg(&mut reader)).await { Ok(Ok(Message::Ping(_))) => { - tracing::trace!(proxy = %proxy_name, "udp work ping"); + tracing::trace!(tunnel = %tunnel_name, "udp data ping"); } Ok(Ok(Message::UdpPacket(pkt))) => { let nbytes = pkt.content.len() as u64; tracing::trace!( - proxy = %proxy_name, + tunnel = %tunnel_name, len = nbytes, - "udp packet from work" + "udp packet from data" ); - metrics.add_traffic_out(&proxy_name, "udp", nbytes); + metrics.add_traffic_out(&tunnel_name, "udp", nbytes); let _ = read_tx.try_send(pkt); } Ok(Ok(other)) => { tracing::debug!( - proxy = %proxy_name, + tunnel = %tunnel_name, ty = other.type_byte(), - "udp work unexpected message" + "udp data unexpected message" ); } Ok(Err(e)) => { - tracing::warn!(proxy = %proxy_name, error = %e, "udp work read error"); + tracing::warn!(tunnel = %tunnel_name, error = %e, "udp data read error"); let _ = fail_tx.send(()).await; return; } Err(_) => { - tracing::warn!(proxy = %proxy_name, "udp work read deadline exceeded"); + tracing::warn!(tunnel = %tunnel_name, "udp data read deadline exceeded"); let _ = fail_tx.send(()).await; return; }