diff --git a/docs/connection_setting.md b/docs/connection_setting.md new file mode 100644 index 00000000..91100c4f --- /dev/null +++ b/docs/connection_setting.md @@ -0,0 +1,21 @@ +# Connection Setting + +Multiple VPN connections are supported via the `servers` field (`Vec`). At the same time, Config is designed to work equally well with a single server connection in a straightforward and energy-efficient way — as is the case with the current android client implementation. +It is also common to share the same auth across multiple connections within a group of servers. The top-level fields in Config outside of servers are sufficient to set up a single connection, but this introduces complexity when the code needs to handle both single and multiple connection cases. +To address this, `Config` provides three helper methods that abstract over the `servers` field: + +- `.len()` and `.is_empty()` — simple helpers to inspect how many servers are configured. +- `.take_servers()` — extracts the servers from the config, normalizing by promoting the top-level connection config into a single entry in servers if needed, and filling in any missing auth or certificate information in the process. + +This makes it possible to specify a single auth or certificate at the top level and have it applied across all connections automatically. +The network initialization flow remains clean and consistent across clients, as illustrated in the following pseudo code. Note that the actual implementation differs due to asynchronous execution. +```rust +#[cfg(platform)] +fn client(mut config: Config) { + let servers = config.take_servers(); + + for server in servers.into_iter() { + connect(server); + } +} +``` diff --git a/docs/design_overview.md b/docs/design_overview.md index c84f3175..7e32eed0 100644 --- a/docs/design_overview.md +++ b/docs/design_overview.md @@ -18,6 +18,8 @@ desktop or an iPhone. lightway-client is a Linux implementation for a fully working Lightway client with both TCP and UDP support. +All clients possible work with mulitple server connections with different auth settings, and it still possible to set one auth for all connection, please refer [Connection Setting.](/docs/connection_setting.md) + ## lightway-server lightway-server is a Linux implementation for a fully working Lightway server with both TCP and UDP support. diff --git a/lightway-client/src/config.rs b/lightway-client/src/config.rs index 87b7109f..b1f5a64f 100644 --- a/lightway-client/src/config.rs +++ b/lightway-client/src/config.rs @@ -9,7 +9,7 @@ use lightway_app_utils::args::KeyShare; use lightway_app_utils::args::{ Cipher, ConfigFormat, ConnectionType, Duration, LogLevel, NonZeroDuration, }; -use lightway_core::{AuthMethod, MAX_OUTSIDE_MTU, RootCertificate}; +use lightway_core::{AuthMethod, MAX_OUTSIDE_MTU}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration as StdDuration; @@ -46,7 +46,7 @@ pub struct Config { #[serde(default)] #[serde(skip_serializing)] #[schemars(skip)] - pub servers: Vec, + servers: Vec, #[patch(attribute(clap(short, long)))] #[patch(attribute(doc = r#"Server to connect to in `:` format @@ -314,30 +314,69 @@ pub struct Config { } impl Config { - /// Try build auth from config - pub fn take_auth(&mut self) -> Result { - take_auth(self.token.take(), self.user.take(), self.password.take()) + /// The number of servers + pub fn len(&self) -> usize { + if self.server.is_empty() { + self.servers.len() + } else { + self.servers.len() + 1 + } } - /// Try build CA from ca_crt - pub fn load_ca(&self) -> Result, Error> { - load_ca(&self.ca_cert) + /// Check if there is any server setting + pub fn is_empty(&self) -> bool { + self.servers.is_empty() && self.server.is_empty() } - /// Try build CA from ca_crt file - /// If input ca_path is none, will take ca_cert field as path and pass to SSL - pub fn load_ca_file<'a>( - &self, - ca_path: &'a mut Option, - ) -> lightway_core::tls::RootCertificate<'a> { - if ca_path.is_none() { - *ca_path = Some(PathBuf::from(&self.ca_cert)); + /// Take out configures for servers + /// and normalize the auth and ca of ConnectionConfig of servers + pub fn take_servers(&mut self) -> Result, Error> { + if self.servers.is_empty() { + self.servers = vec![ConnectionConfig { + server: self.server.clone(), + mode: self.mode, + server_dn: self.server_dn.take(), + cipher: self.cipher, + outside_mtu: self.outside_mtu, + ..Default::default() + }]; + } + let ca_content = if check_cert_header(&self.ca_cert) { + Some(self.ca_cert.clone()) + } else { + // NOTE: we support a path input on desktop, we keep None if global cert is not set, and + // it is okay to use certs for each server, thees will be checked in following loop. + if cfg!(desktop) { + std::fs::read_to_string(&self.ca_cert).ok() + } else { + None + } + }; + for server in self.servers.iter_mut() { + if server.user.is_none() && server.password.is_none() && server.token.is_none() { + server.user = self.user.clone(); + server.password = self.password.clone(); + server.token = self.token.clone(); + } + + if let Some(ref mut ca_cert) = server.ca_cert { + if !check_cert_header(&ca_cert) { + // NOTE: we support a path input on desktop, but raise error if not found + if cfg!(desktop) { + *ca_cert = std::fs::read_to_string(&mut *ca_cert) + .map_err(|_| Error::CaFileNotFound)?; + } else { + return Err(Error::InvalidCertificate); + } + } + } else { + if ca_content.is_none() { + return Err(Error::CaFileNotFound); + } + server.ca_cert = ca_content.clone(); + } } - RootCertificate::PemFileOrDirectory( - ca_path - .as_ref() - .expect("the path already initialized if it is none"), - ) + Ok(std::mem::take::>(&mut self.servers)) } } @@ -450,7 +489,6 @@ pub struct ConnectionConfig { impl ConnectionConfig { /// Try build auth from config - #[cfg(feature = "mobile")] pub fn take_auth(&mut self) -> Result { take_auth(self.token.take(), self.user.take(), self.password.take()) } @@ -460,7 +498,15 @@ impl ConnectionConfig { pub fn load_ca(&self) -> Result, Error> { self.ca_cert .as_ref() - .map(load_ca) + .map(|ca| { + if check_cert_header(ca) { + Ok(lightway_core::tls::RootCertificate::PemBuffer( + ca.as_bytes(), + )) + } else { + Err(Error::InvalidCertificate) + } + }) .ok_or(Error::InvalidCertificate)? } @@ -496,6 +542,10 @@ pub enum Error { /// Lack information to Authentication #[error("Insufficient information for Authentication")] InsufficientAuth, + + /// No such Ca file from user's input + #[error("Ca file is absent")] + CaFileNotFound, } fn take_auth( @@ -510,12 +560,8 @@ fn take_auth( } } -fn load_ca(ca: &String) -> Result, Error> { - if ca.starts_with("-----BEGIN CERTIFICATE-----") { - Ok(RootCertificate::PemBuffer(ca.as_bytes())) - } else { - Err(Error::InvalidCertificate) - } +fn check_cert_header>(content: T) -> bool { + content.as_ref().starts_with("-----BEGIN CERTIFICATE-----") } fn byte_size_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { diff --git a/lightway-client/src/lib.rs b/lightway-client/src/lib.rs index ac3403dd..cfc3e172 100644 --- a/lightway-client/src/lib.rs +++ b/lightway-client/src/lib.rs @@ -144,15 +144,7 @@ pub struct BestConnectionInfo { #[derive(educe::Educe)] #[educe(Debug)] -pub struct ClientConfig<'cert, ExtAppState: Send + Sync> { - /// Auth parameters to use for connection - #[educe(Debug(ignore))] - pub auth: AuthMethod, - - /// CA certificate - #[educe(Debug(ignore))] - pub root_ca_cert: RootCertificate<'cert>, - +pub struct ClientConfig { /// Outside (wire) MTU pub outside_mtu: usize, @@ -290,6 +282,12 @@ pub struct ClientConnectionConfig /// Server IP address and port pub server: SocketAddr, + /// Auth parameters to use for connection + pub auth: AuthMethod, + + /// Content of CA certificate + pub cert_content: String, + /// Inside plugins to use #[educe(Debug(method(debug_fmt_plugin_list)))] pub inside_plugins: PluginFactoryList, @@ -804,17 +802,29 @@ pub async fn connect< EventHandler: 'static + Send + EventCallback, ExtAppState: 'static + Default + Send + Sync, >( - config: &ClientConfig<'_, ExtAppState>, - mut server_config: ClientConnectionConfig, + config: &ClientConfig, + server_config: ClientConnectionConfig, inside_io: Arc>, ) -> Result> { let mut join_set = JoinSet::new(); + let ClientConnectionConfig { + mode, + cipher, + server, + server_dn, + auth, + cert_content, + inside_pkt_codec, + inside_plugins, + outside_plugins, + event_handler, + } = server_config; let (connection_type, outside_io): (ConnectionType, Arc) = - match server_config.mode { + match mode { ClientConnectionMode::Datagram(maybe_sock) => { #[cfg_attr(not(batch_receive), allow(unused_mut))] - let mut sock = io::outside::Udp::new(server_config.server, maybe_sock) + let mut sock = io::outside::Udp::new(server, maybe_sock) .await .inspect_err(|e| tracing::error!("Failed to create outside IO UDP socket: {e}")) .context("Outside IO UDP")?; @@ -827,7 +837,7 @@ pub async fn connect< (ConnectionType::Datagram, Arc::new(sock)) } ClientConnectionMode::Stream(maybe_sock) => { - let sock = io::outside::Tcp::new(server_config.server, maybe_sock) + let sock = io::outside::Tcp::new(server, maybe_sock) .await .inspect_err(|e| tracing::error!("Failed to create outside IO TCP socket: {e}")) .context("Outside IO TCP")?; @@ -853,29 +863,28 @@ pub async fn connect< set_logging_callback(|m: &str| tracing::debug!(target: "ssl_debug", m)); } - let (inside_io_codec, encoded_pkt_receiver, decoded_pkt_receiver) = - match &server_config.inside_pkt_codec { - Some(codec_factory) => { - let codec = codec_factory.build(); - ( - Some((codec.encoder, codec.decoder)), - Some(codec.encoded_pkt_receiver), - Some(codec.decoded_pkt_receiver), - ) - } - None => (None, None, None), - }; + let (inside_io_codec, encoded_pkt_receiver, decoded_pkt_receiver) = match &inside_pkt_codec { + Some(codec_factory) => { + let codec = codec_factory.build(); + ( + Some((codec.encoder, codec.decoder)), + Some(codec.encoded_pkt_receiver), + Some(codec.decoded_pkt_receiver), + ) + } + None => (None, None, None), + }; let conn_builder = ClientContextBuilder::new( connection_type, - config.root_ca_cert, + RootCertificate::PemBuffer(cert_content.as_bytes()), None, Arc::new(ClientIpConfigCb), connection_ticker_cb, )? - .with_cipher(server_config.cipher.into())? - .with_inside_plugins(server_config.inside_plugins) - .with_outside_plugins(server_config.outside_plugins) + .with_cipher(cipher.into())? + .with_inside_plugins(inside_plugins) + .with_outside_plugins(outside_plugins) .when(config.enable_expresslane, |b| { b.with_expresslane(config.expresslane_keys_rotation_interval) }) @@ -890,11 +899,11 @@ pub async fn connect< outside_io.clone().into_io_send_callback(), config.outside_mtu, )? - .with_auth(config.auth.clone()) + .with_auth(auth) .with_event_cb(Box::new(event_cb)) .with_inside_pkt_codec(inside_io_codec) .when_some(config.pmtud_base_mtu, |b, mtu| b.with_pmtud_base_mtu(mtu)) - .when_some(server_config.server_dn, |b, sdn| { + .when_some(server_dn, |b, sdn| { b.with_server_domain_name_validation(&sdn) }) .when(connection_type.is_datagram() && config.enable_pmtud, |b| { @@ -923,7 +932,6 @@ pub async fn connect< let (connected_tx, connected_rx) = oneshot::channel(); let (disconnected_tx, disconnected_rx) = oneshot::channel(); - let event_handler = server_config.event_handler.take(); join_set.spawn(handle_events( event_stream, keepalive.clone(), @@ -994,8 +1002,8 @@ pub async fn connect< Some(_) = keepalive_task => Err(anyhow!("Keepalive timeout")), io = &mut outside_io_loop => Err(anyhow!("Outside IO loop exited: {io:?}")), io = &mut inside_io_loop => Err(anyhow!("Inside IO loop exited: {io:?}")), - io = &mut encoded_pkt_send_task, if server_config.inside_pkt_codec.is_some() => Err(anyhow!("Inside IO (Encoded packet send task) exited: {io:?}")), - io = &mut decoded_pkt_send_task, if server_config.inside_pkt_codec.is_some() => Err(anyhow!("Inside IO (Decoded packet send task) exited: {io:?}")), + io = &mut encoded_pkt_send_task, if inside_pkt_codec.is_some() => Err(anyhow!("Inside IO (Encoded packet send task) exited: {io:?}")), + io = &mut decoded_pkt_send_task, if inside_pkt_codec.is_some() => Err(anyhow!("Inside IO (Decoded packet send task) exited: {io:?}")), _ = &mut ticker_task => Err(anyhow!("Ticker task stopped")), result = &mut network_change_task => { match result { @@ -1172,7 +1180,7 @@ fn validate_client_config< EventHandler: 'static + Send + EventCallback, ExtAppState: Send + Sync, >( - config: &ClientConfig<'_, ExtAppState>, + config: &ClientConfig, servers: &[ClientConnectionConfig], ) -> Result<()> { if config.network_change_signal.is_some() && config.keepalive_interval.is_zero() { @@ -1208,7 +1216,7 @@ pub async fn client< EventHandler: 'static + Send + EventCallback, ExtAppState: 'static + Default + Send + Sync, >( - mut config: ClientConfig<'_, ExtAppState>, + mut config: ClientConfig, mut stop_signal: oneshot::Receiver<()>, conn_confs: Vec>, ) -> Result { diff --git a/lightway-client/src/main.rs b/lightway-client/src/main.rs index 84630a63..04566ab4 100644 --- a/lightway-client/src/main.rs +++ b/lightway-client/src/main.rs @@ -35,8 +35,9 @@ impl EventCallback for EventHandler { } async fn make_client_connection_config( - config: ConnectionConfig, + mut config: ConnectionConfig, ) -> Result> { + let auth = config.take_auth()?; tracing::info!("Resolving server address: {}", &config.server); let server_addr: SocketAddr = tokio::net::lookup_host(config.server) @@ -54,6 +55,10 @@ async fn make_client_connection_config( cipher: config.cipher, server_dn: config.server_dn, server: server_addr, + auth, + cert_content: config + .ca_cert + .expect("Should exist, because it is normalized after take_servers()"), inside_plugins: Default::default(), outside_plugins: Default::default(), inside_pkt_codec: None, @@ -209,22 +214,8 @@ async fn main() -> Result<()> { spawn_reload_event_handler(&config, config_file.clone(), env_patch, cli_patch); let inside_io: Option>> = None; - - if config.servers.is_empty() { - config.servers = vec![ConnectionConfig { - server: config.server.clone(), - mode: config.mode, - server_dn: config.server_dn.clone(), - cipher: config.cipher, - ..Default::default() - }]; - } - - let conn_confs = join_all( - std::mem::take::>(&mut config.servers) - .into_iter() - .map(make_client_connection_config), - ); + let servers = config.take_servers()?; + let conn_confs = join_all(servers.into_iter().map(make_client_connection_config)); let conn_confs = tokio::select! { results = conn_confs => { results.into_iter() @@ -241,10 +232,6 @@ async fn main() -> Result<()> { }; let config = ClientConfig { - auth: config.take_auth()?, - root_ca_cert: config - .load_ca() - .unwrap_or(config.load_ca_file(&mut _root_ca_cert_path)), outside_mtu: config.outside_mtu, inside_io, tun_config, diff --git a/lightway-client/src/mobile.rs b/lightway-client/src/mobile.rs index 33c78e97..fb89c391 100644 --- a/lightway-client/src/mobile.rs +++ b/lightway-client/src/mobile.rs @@ -153,20 +153,9 @@ impl RustVpnConnection { let mut config = crate::config::Config::default(); config.apply(serde_saphyr::from_str(&config_content)?); - config.servers.push(crate::config::ConnectionConfig { - server: config.server.clone(), - mode: config.mode, - server_dn: config.server_dn.take(), - cipher: config.cipher, - outside_mtu: config.outside_mtu, - user: config.user.take(), - password: config.password.take(), - token: config.token.take(), - ca_cert: Some(config.ca_cert.clone()), - }); - - info!("Received {} endpoints", config.servers.len()); - if config.servers.is_empty() { + + info!("Received {} endpoints", config.len()); + if config.is_empty() { return Err(LightwayError::EmptyEndpointsError); } diff --git a/lightway-client/src/mobile/lightway.rs b/lightway-client/src/mobile/lightway.rs index 3caf987c..d494b512 100644 --- a/lightway-client/src/mobile/lightway.rs +++ b/lightway-client/src/mobile/lightway.rs @@ -175,18 +175,18 @@ async fn setup_tunnel_interface( pub(crate) async fn async_lightway_start( tun_fd: RawFd, external_event_handler: Arc, - config: Config, + mut config: Config, connected_index: Arc>, ) -> uniffi::Result { - let mut outside_sockets = config - .servers + let servers = config.take_servers()?; + let mut outside_sockets = servers .iter() .map(|s| OutsideSocket::new(s.mode.is_tcp(), Some(external_event_handler.clone())).ok()) .collect::>>(); // Strore meta data before the server consumed - let server_len = config.servers.len(); - let tcp_connections_only = config.servers.iter().all(|s| s.mode.is_tcp()); + let server_len = servers.len(); + let tcp_connections_only = servers.iter().all(|s| s.mode.is_tcp()); let inside_io = setup_tunnel_interface(tun_fd, config.tun_local_ip, config.tun_dns_ip).await?; @@ -204,8 +204,7 @@ pub(crate) async fn async_lightway_start( let (mut in_progress_connection_abort_handles, mut in_progress_connections): ( Vec<_>, FuturesUnordered<_>, - ) = config - .servers + ) = servers .into_iter() .enumerate() .map(|(instance_id, connect_conf)| {