feat(server): multi-address egress — global source IP plus per-rule send_from

A server with two IPs (one burned by Google, one fresh) needs to control which
address traffic leaves from, per destination — direct from the clean IP to a
picky site, via the proxy from the other for everything else.

- Receive: already selectable via the listen addresses.
- Send (global): server config `bind_ip` binds every outbound socket to a chosen
  source address (threaded through run_server → Router → the direct/proxy/UDP
  connect paths, which already supported a bind_ip).
- Send (per rule): outbound rules gain `send_from` — a source IP that overrides
  the global bind_ip when that rule matches. select_outbound_action now returns
  the matched rule's source alongside its action, and both the direct and proxy
  connect paths (TCP and the SOCKS5 UDP path) bind to rule.send_from, falling
  back to the global bind_ip. So "direct from 1.2.3.4 to youtube, proxy from
  5.6.7.8 otherwise" is expressible.

New fields default (send_from: None), so existing configs are unaffected and the
migrator backfills them into canonical form. Fixed two pre-existing test call
sites that predated the bind_ip parameter.
This commit is contained in:
ospab 2026-08-25 02:23:24 +03:00
parent 4da4f9c1a5
commit 245e79215c
7 changed files with 103 additions and 41 deletions

View File

@ -437,6 +437,7 @@ impl UserConfig {
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig { pub struct ServerConfig {
pub listen: ListenConfig, pub listen: ListenConfig,
pub bind_ip: Option<String>,
pub access_keys: Vec<UserConfig>, pub access_keys: Vec<UserConfig>,
pub debug: Option<bool>, pub debug: Option<bool>,
pub outbound: Option<OutboundConfig>, pub outbound: Option<OutboundConfig>,
@ -570,6 +571,10 @@ pub struct OutboundRule {
pub ip_cidr: Option<Vec<String>>, pub ip_cidr: Option<Vec<String>>,
pub protocol: Option<String>, pub protocol: Option<String>,
pub action: Option<String>, pub action: Option<String>,
/// Local source IP to egress from when this rule matches (overrides the
/// server's global `bind_ip` for this rule). Lets one destination leave via
/// one address and another via a different one.
pub send_from: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]

View File

@ -878,7 +878,7 @@ mod tests {
config_path: None, config_path: None,
dns_server: crate::dns::DnsServer::new(Default::default()), dns_server: crate::dns::DnsServer::new(Default::default()),
audit_logs: Arc::new(RwLock::new(Vec::new())), audit_logs: Arc::new(RwLock::new(Vec::new())),
router: Arc::new(crate::router::Router::new(None, crate::dns::DnsServer::new(Default::default()), false)), router: Arc::new(crate::router::Router::new(None, None, crate::dns::DnsServer::new(Default::default()), false)),
} }
} }

View File

@ -70,6 +70,7 @@ pub(crate) struct RemoteState {
pub async fn run_server( pub async fn run_server(
bind_addrs: Vec<String>, bind_addrs: Vec<String>,
server_public_ip: Option<String>, server_public_ip: Option<String>,
bind_ip: Option<String>,
access_keys: Vec<(String, crate::api::UserMeta)>, access_keys: Vec<(String, crate::api::UserMeta)>,
outbound: Option<OutboundConfig>, outbound: Option<OutboundConfig>,
api_config: Option<ApiConfig>, api_config: Option<ApiConfig>,
@ -255,6 +256,7 @@ pub async fn run_server(
// Initialize Router // Initialize Router
let router = std::sync::Arc::new(router::Router::new( let router = std::sync::Arc::new(router::Router::new(
outbound.clone(), outbound.clone(),
bind_ip,
dns_server.clone(), dns_server.clone(),
debug, debug,
)); ));

View File

@ -20,6 +20,13 @@ pub struct OutboundRule {
#[serde(default)] #[serde(default)]
pub protocol: Option<String>, pub protocol: Option<String>,
pub action: OutboundAction, pub action: OutboundAction,
/// Local source IP to egress from when this rule matches. Overrides the
/// server's global `bind_ip` for this rule only, so different destinations
/// can leave the machine from different addresses — e.g. one clean IP
/// direct to a picky site, another via the proxy for everything else. None
/// falls back to the global `bind_ip`.
#[serde(default)]
pub send_from: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -41,12 +48,15 @@ pub struct OutboundConfig {
pub async fn connect_target( pub async fn connect_target(
target: &str, target: &str,
outbound: Option<&OutboundConfig>, outbound: Option<&OutboundConfig>,
bind_ip: Option<&str>,
debug: bool, debug: bool,
) -> Result<TcpStream> { ) -> Result<TcpStream> {
let connect_timeout = Duration::from_secs(10); let connect_timeout = Duration::from_secs(10);
if let Some(outbound) = outbound { if let Some(outbound) = outbound {
if outbound.enabled { if outbound.enabled {
let action = select_outbound_action(target, "tcp", outbound, debug).await; let (action, rule_src) = select_outbound_action(target, "tcp", outbound, debug).await;
// Per-rule source wins; otherwise the server's global bind_ip.
let eff_bind = rule_src.as_deref().or(bind_ip);
if action == OutboundAction::Block { if action == OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target)); return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
} }
@ -55,8 +65,8 @@ pub async fn connect_target(
// Case-insensitive: a config saying "SOCKS5" means the same thing // Case-insensitive: a config saying "SOCKS5" means the same thing
// as "socks5", and silently treating it as unknown is a trap. // as "socks5", and silently treating it as unknown is a trap.
return match outbound.protocol.to_ascii_lowercase().as_str() { return match outbound.protocol.to_ascii_lowercase().as_str() {
"socks5" => connect_via_socks5(&proxy_addr, target, &outbound.username, &outbound.password).await, "socks5" => connect_via_socks5(&proxy_addr, target, eff_bind, &outbound.username, &outbound.password).await,
"http" => connect_via_http(&proxy_addr, target).await, "http" => connect_via_http(&proxy_addr, target, eff_bind).await,
// FAIL CLOSED. This used to fall through to a direct // FAIL CLOSED. This used to fall through to a direct
// connection, so any unrecognised protocol string — a typo, // connection, so any unrecognised protocol string — a typo,
// a case difference, an empty value — silently sent ALL TCP // a case difference, an empty value — silently sent ALL TCP
@ -71,10 +81,14 @@ pub async fn connect_target(
)), )),
}; };
} }
// action == Direct: egress directly, but still honour this rule's
// send_from (falling back to the global bind_ip) — that is the whole
// point of "direct from THIS ip to that destination".
return connect_direct(target, connect_timeout, eff_bind).await;
} }
} }
connect_direct(target, connect_timeout).await connect_direct(target, connect_timeout, bind_ip).await
} }
/// Per-candidate-address connect attempt, tried in turn (see `connect_direct` /// Per-candidate-address connect attempt, tried in turn (see `connect_direct`
@ -96,7 +110,23 @@ const PER_ADDR_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// attempted: every dual-stack destination (i.e. most popular sites) never /// attempted: every dual-stack destination (i.e. most popular sites) never
/// loads, while IPv4-only destinations work fine - exactly the "traffic /// loads, while IPv4-only destinations work fine - exactly the "traffic
/// counter moves but sites don't open" symptom this fixes. /// counter moves but sites don't open" symptom this fixes.
async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpStream> { async fn connect_tcp_with_bind(addr: std::net::SocketAddr, bind_ip: Option<&str>) -> Result<TcpStream> {
if let Some(ip_str) = bind_ip {
let ip: std::net::IpAddr = ip_str.parse().map_err(|e| anyhow::anyhow!("invalid bind_ip: {}", e))?;
let socket = match addr {
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
};
if (addr.is_ipv4() && ip.is_ipv4()) || (addr.is_ipv6() && ip.is_ipv6()) {
socket.bind(std::net::SocketAddr::new(ip, 0))?;
}
Ok(socket.connect(addr).await?)
} else {
Ok(TcpStream::connect(addr).await?)
}
}
async fn connect_direct(target: &str, connect_timeout: Duration, bind_ip: Option<&str>) -> Result<TcpStream> {
tokio::time::timeout(connect_timeout, async { tokio::time::timeout(connect_timeout, async {
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target) let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
.await .await
@ -109,7 +139,7 @@ async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpSt
let mut last_err = None; let mut last_err = None;
for addr in addrs { for addr in addrs {
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, TcpStream::connect(addr)).await { match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, connect_tcp_with_bind(addr, bind_ip)).await {
Ok(Ok(stream)) => return Ok(stream), Ok(Ok(stream)) => return Ok(stream),
Ok(Err(e)) => last_err = Some(anyhow::anyhow!("{}: {}", addr, e)), Ok(Err(e)) => last_err = Some(anyhow::anyhow!("{}: {}", addr, e)),
Err(_) => last_err = Some(anyhow::anyhow!("{}: connect timeout ({}s)", addr, PER_ADDR_CONNECT_TIMEOUT.as_secs())), Err(_) => last_err = Some(anyhow::anyhow!("{}: connect timeout ({}s)", addr, PER_ADDR_CONNECT_TIMEOUT.as_secs())),
@ -134,39 +164,35 @@ pub async fn select_outbound_action(
protocol: &str, protocol: &str,
outbound: &OutboundConfig, outbound: &OutboundConfig,
debug: bool, debug: bool,
) -> OutboundAction { ) -> (OutboundAction, Option<String>) {
let (host, port) = match split_host_port(target) { let (host, port) = match split_host_port(target) {
Some(v) => v, Some(v) => v,
None => return outbound.default_action, None => return (outbound.default_action, None),
}; };
let mut matched = None; // Capture the matched rule's action AND its per-rule source IP together, so
// the caller egresses from the address that rule asked for.
let mut matched: Option<(OutboundAction, Option<String>)> = None;
for rule in &outbound.rules { for rule in &outbound.rules {
if let Some(ref rule_proto) = rule.protocol { if let Some(ref rule_proto) = rule.protocol {
if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol { if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol {
continue; continue;
} }
} }
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() { let hit = (rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty())
// Protocol-only rule match || match_domain_rule(&host, &rule.domain_suffix)
matched = Some(rule.action); || match_ip_rule(&host, port, &rule.ip_cidr).await;
break; if hit {
} matched = Some((rule.action, rule.send_from.clone()));
if match_domain_rule(&host, &rule.domain_suffix) {
matched = Some(rule.action);
break;
}
if match_ip_rule(&host, port, &rule.ip_cidr).await {
matched = Some(rule.action);
break; break;
} }
} }
let action = matched.unwrap_or(outbound.default_action); let (action, send_from) = matched.unwrap_or((outbound.default_action, None));
if debug { if debug {
tracing::debug!("Outbound routing: target={target} action={action:?}"); tracing::debug!("Outbound routing: target={target} action={action:?} send_from={send_from:?}");
} }
action (action, send_from)
} }
fn match_domain_rule(host: &str, suffixes: &[String]) -> bool { fn match_domain_rule(host: &str, suffixes: &[String]) -> bool {
@ -250,12 +276,18 @@ where
async fn connect_via_socks5( async fn connect_via_socks5(
proxy_addr: &str, proxy_addr: &str,
target: &str, target: &str,
bind_ip: Option<&str>,
username: &str, username: &str,
password: &str, password: &str,
) -> Result<TcpStream> { ) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = TcpStream::connect(proxy_addr).await?; let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
let mut stream = if let Some(addr) = addrs.into_iter().next() {
connect_tcp_with_bind(addr, bind_ip).await?
} else {
anyhow::bail!("could not resolve proxy address");
};
socks5_negotiate_auth(&mut stream, username, password).await?; socks5_negotiate_auth(&mut stream, username, password).await?;
let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?; let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?;
@ -304,10 +336,15 @@ async fn connect_via_socks5(
Ok(stream) Ok(stream)
} }
async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> { async fn connect_via_http(proxy_addr: &str, target: &str, bind_ip: Option<&str>) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = TcpStream::connect(proxy_addr).await?; let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
let mut stream = if let Some(addr) = addrs.into_iter().next() {
connect_tcp_with_bind(addr, bind_ip).await?
} else {
anyhow::bail!("could not resolve proxy address");
};
let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"); let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
stream.write_all(request.as_bytes()).await?; stream.write_all(request.as_bytes()).await?;
@ -426,19 +463,21 @@ impl UdpProxySocket {
pub async fn connect_udp_target( pub async fn connect_udp_target(
target: &str, target: &str,
outbound: Option<&OutboundConfig>, outbound: Option<&OutboundConfig>,
bind_ip: Option<&str>,
debug: bool, debug: bool,
server_udp: std::sync::Arc<tokio::net::UdpSocket>, server_udp: std::sync::Arc<tokio::net::UdpSocket>,
) -> Result<UdpProxySocket> { ) -> Result<UdpProxySocket> {
if let Some(outbound) = outbound { if let Some(outbound) = outbound {
if outbound.enabled { if outbound.enabled {
let action = select_outbound_action(target, "udp", outbound, debug).await; let (action, rule_src) = select_outbound_action(target, "udp", outbound, debug).await;
let eff_bind = rule_src.as_deref().or(bind_ip);
if action == OutboundAction::Block { if action == OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target)); return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
} }
if action == OutboundAction::Proxy { if action == OutboundAction::Proxy {
let proxy_addr = format!("{}:{}", outbound.address, outbound.port); let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
if outbound.protocol.eq_ignore_ascii_case("socks5") { if outbound.protocol.eq_ignore_ascii_case("socks5") {
return connect_udp_via_socks5(&proxy_addr, server_udp, &outbound.username, &outbound.password).await; return connect_udp_via_socks5(&proxy_addr, server_udp, eff_bind, &outbound.username, &outbound.password).await;
} }
// FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the // FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
// answer to that is not to send the datagrams in the clear. The // answer to that is not to send the datagrams in the clear. The
@ -462,12 +501,18 @@ pub async fn connect_udp_target(
pub async fn connect_udp_via_socks5( pub async fn connect_udp_via_socks5(
proxy_addr: &str, proxy_addr: &str,
server_udp: std::sync::Arc<tokio::net::UdpSocket>, server_udp: std::sync::Arc<tokio::net::UdpSocket>,
bind_ip: Option<&str>,
username: &str, username: &str,
password: &str, password: &str,
) -> Result<UdpProxySocket> { ) -> Result<UdpProxySocket> {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = TcpStream::connect(proxy_addr).await?; let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
let mut stream = if let Some(addr) = addrs.into_iter().next() {
connect_tcp_with_bind(addr, bind_ip).await?
} else {
anyhow::bail!("could not resolve proxy address");
};
socks5_negotiate_auth(&mut stream, username, password).await?; socks5_negotiate_auth(&mut stream, username, password).await?;
// Send UDP Associate request // Send UDP Associate request
@ -688,7 +733,7 @@ mod tests {
let _ = listener.accept().await; let _ = listener.accept().await;
}); });
let result = connect_direct(&addr.to_string(), Duration::from_secs(2)).await; let result = connect_direct(&addr.to_string(), Duration::from_secs(2), None).await;
assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err()); assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err());
} }
@ -701,7 +746,7 @@ mod tests {
drop(listener); drop(listener);
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let result = connect_direct(&addr.to_string(), Duration::from_secs(5)).await; let result = connect_direct(&addr.to_string(), Duration::from_secs(5), None).await;
assert!(result.is_err(), "connecting to a closed port should fail"); assert!(result.is_err(), "connecting to a closed port should fail");
assert!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout"); assert!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout");
} }

View File

@ -155,10 +155,16 @@ pub async fn handle_relay_message(
if router.debug { if router.debug {
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}"))); let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}")));
} }
let udp_bind_result = match UdpSocket::bind("[::]:0").await {
let udp_bind_result = if let Some(ref bind_ip) = router.bind_ip {
tokio::net::UdpSocket::bind(format!("{}:0", bind_ip)).await
} else {
match tokio::net::UdpSocket::bind("[::]:0").await {
Ok(s) => Ok(s), Ok(s) => Ok(s),
Err(_) => UdpSocket::bind("0.0.0.0:0").await, Err(_) => tokio::net::UdpSocket::bind("0.0.0.0:0").await,
}
}; };
let server_udp = match udp_bind_result { let server_udp = match udp_bind_result {
Ok(s) => std::sync::Arc::new(s), Ok(s) => std::sync::Arc::new(s),
Err(e) => { Err(e) => {

View File

@ -7,14 +7,16 @@ use crate::dns::DnsServer;
#[derive(Clone)] #[derive(Clone)]
pub struct Router { pub struct Router {
pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>, pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>,
pub bind_ip: Option<String>,
pub dns_server: Arc<DnsServer>, pub dns_server: Arc<DnsServer>,
pub debug: bool, pub debug: bool,
} }
impl Router { impl Router {
pub fn new(outbound_cfg: Option<OutboundConfig>, dns_server: Arc<DnsServer>, debug: bool) -> Self { pub fn new(outbound_cfg: Option<OutboundConfig>, bind_ip: Option<String>, dns_server: Arc<DnsServer>, debug: bool) -> Self {
Self { Self {
outbound_cfg: Arc::new(RwLock::new(outbound_cfg)), outbound_cfg: Arc::new(RwLock::new(outbound_cfg)),
bind_ip,
dns_server, dns_server,
debug, debug,
} }
@ -26,7 +28,7 @@ impl Router {
let lock = self.outbound_cfg.read().unwrap(); let lock = self.outbound_cfg.read().unwrap();
lock.clone() lock.clone()
}; };
connect_target(target, cfg.as_ref(), self.debug).await connect_target(target, cfg.as_ref(), self.bind_ip.as_deref(), self.debug).await
} }
/// UDP Target Routing /// UDP Target Routing
@ -35,7 +37,7 @@ impl Router {
let lock = self.outbound_cfg.read().unwrap(); let lock = self.outbound_cfg.read().unwrap();
lock.clone() lock.clone()
}; };
crate::outbound::connect_udp_target(target, cfg.as_ref(), self.debug, server_udp).await crate::outbound::connect_udp_target(target, cfg.as_ref(), self.bind_ip.as_deref(), self.debug, server_udp).await
} }
/// Establish a UDP session router that can dynamically route packets /// Establish a UDP session router that can dynamically route packets
@ -50,7 +52,7 @@ impl Router {
if c.enabled { if c.enabled {
if c.protocol == "socks5" { if c.protocol == "socks5" {
let proxy_addr = format!("{}:{}", c.address, c.port); let proxy_addr = format!("{}:{}", c.address, c.port);
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone(), &c.username, &c.password).await { match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone(), self.bind_ip.as_deref(), &c.username, &c.password).await {
Ok(p) => proxy = Some(Arc::new(p)), Ok(p) => proxy = Some(Arc::new(p)),
// Warn unconditionally, not only under `debug`. Every UDP // Warn unconditionally, not only under `debug`. Every UDP
// flow the rules want proxied is now dropped instead of // flow the rules want proxied is now dropped instead of
@ -99,7 +101,7 @@ impl UdpSessionRouter {
pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> { pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> {
if let Some(cfg) = &self.cfg { if let Some(cfg) = &self.cfg {
if cfg.enabled { if cfg.enabled {
let action = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await; let (action, _rule_src) = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await;
if action == crate::outbound::OutboundAction::Block { if action == crate::outbound::OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target)); return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
} }

View File

@ -1372,6 +1372,7 @@ async fn run_app() -> Result<()> {
ip_cidr: r.ip_cidr.unwrap_or_default(), ip_cidr: r.ip_cidr.unwrap_or_default(),
protocol: r.protocol, protocol: r.protocol,
action: parse_outbound_action(r.action), action: parse_outbound_action(r.action),
send_from: r.send_from,
}) })
.collect(), .collect(),
default_action: parse_outbound_action(o.default_action), default_action: parse_outbound_action(o.default_action),
@ -1407,8 +1408,9 @@ async fn run_app() -> Result<()> {
.map(serde_json::from_value) .map(serde_json::from_value)
.transpose() .transpose()
.map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?; .map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?;
let bind_ip = server_cfg.bind_ip;
// Pass all listen addresses for multi-listener support // Pass all listen addresses for multi-listener support
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?; ostp_server::run_server(listen_addrs, Some(host), bind_ip, access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
} }
AppMode::Client(client_cfg) => { AppMode::Client(client_cfg) => {
println!("{}", include_str!("../../docs/banner.txt").blue().bold()); println!("{}", include_str!("../../docs/banner.txt").blue().bold());