mirror of https://github.com/ospab/ostp.git
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:
parent
4da4f9c1a5
commit
245e79215c
|
|
@ -437,6 +437,7 @@ impl UserConfig {
|
|||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
pub listen: ListenConfig,
|
||||
pub bind_ip: Option<String>,
|
||||
pub access_keys: Vec<UserConfig>,
|
||||
pub debug: Option<bool>,
|
||||
pub outbound: Option<OutboundConfig>,
|
||||
|
|
@ -570,6 +571,10 @@ pub struct OutboundRule {
|
|||
pub ip_cidr: Option<Vec<String>>,
|
||||
pub protocol: 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)]
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ mod tests {
|
|||
config_path: None,
|
||||
dns_server: crate::dns::DnsServer::new(Default::default()),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ pub(crate) struct RemoteState {
|
|||
pub async fn run_server(
|
||||
bind_addrs: Vec<String>,
|
||||
server_public_ip: Option<String>,
|
||||
bind_ip: Option<String>,
|
||||
access_keys: Vec<(String, crate::api::UserMeta)>,
|
||||
outbound: Option<OutboundConfig>,
|
||||
api_config: Option<ApiConfig>,
|
||||
|
|
@ -255,6 +256,7 @@ pub async fn run_server(
|
|||
// Initialize Router
|
||||
let router = std::sync::Arc::new(router::Router::new(
|
||||
outbound.clone(),
|
||||
bind_ip,
|
||||
dns_server.clone(),
|
||||
debug,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ pub struct OutboundRule {
|
|||
#[serde(default)]
|
||||
pub protocol: Option<String>,
|
||||
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)]
|
||||
|
|
@ -41,12 +48,15 @@ pub struct OutboundConfig {
|
|||
pub async fn connect_target(
|
||||
target: &str,
|
||||
outbound: Option<&OutboundConfig>,
|
||||
bind_ip: Option<&str>,
|
||||
debug: bool,
|
||||
) -> Result<TcpStream> {
|
||||
let connect_timeout = Duration::from_secs(10);
|
||||
if let Some(outbound) = outbound {
|
||||
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 {
|
||||
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
|
||||
// as "socks5", and silently treating it as unknown is a trap.
|
||||
return match outbound.protocol.to_ascii_lowercase().as_str() {
|
||||
"socks5" => connect_via_socks5(&proxy_addr, target, &outbound.username, &outbound.password).await,
|
||||
"http" => connect_via_http(&proxy_addr, target).await,
|
||||
"socks5" => connect_via_socks5(&proxy_addr, target, eff_bind, &outbound.username, &outbound.password).await,
|
||||
"http" => connect_via_http(&proxy_addr, target, eff_bind).await,
|
||||
// FAIL CLOSED. This used to fall through to a direct
|
||||
// connection, so any unrecognised protocol string — a typo,
|
||||
// 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`
|
||||
|
|
@ -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
|
||||
/// loads, while IPv4-only destinations work fine - exactly the "traffic
|
||||
/// 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 {
|
||||
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
|
||||
.await
|
||||
|
|
@ -109,7 +139,7 @@ async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpSt
|
|||
|
||||
let mut last_err = None;
|
||||
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(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())),
|
||||
|
|
@ -134,39 +164,35 @@ pub async fn select_outbound_action(
|
|||
protocol: &str,
|
||||
outbound: &OutboundConfig,
|
||||
debug: bool,
|
||||
) -> OutboundAction {
|
||||
) -> (OutboundAction, Option<String>) {
|
||||
let (host, port) = match split_host_port(target) {
|
||||
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 {
|
||||
if let Some(ref rule_proto) = rule.protocol {
|
||||
if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() {
|
||||
// Protocol-only rule match
|
||||
matched = Some(rule.action);
|
||||
break;
|
||||
}
|
||||
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);
|
||||
let hit = (rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty())
|
||||
|| match_domain_rule(&host, &rule.domain_suffix)
|
||||
|| match_ip_rule(&host, port, &rule.ip_cidr).await;
|
||||
if hit {
|
||||
matched = Some((rule.action, rule.send_from.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let action = matched.unwrap_or(outbound.default_action);
|
||||
let (action, send_from) = matched.unwrap_or((outbound.default_action, None));
|
||||
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 {
|
||||
|
|
@ -250,12 +276,18 @@ where
|
|||
async fn connect_via_socks5(
|
||||
proxy_addr: &str,
|
||||
target: &str,
|
||||
bind_ip: Option<&str>,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<TcpStream> {
|
||||
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?;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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};
|
||||
|
||||
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");
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
|
|
@ -426,19 +463,21 @@ impl UdpProxySocket {
|
|||
pub async fn connect_udp_target(
|
||||
target: &str,
|
||||
outbound: Option<&OutboundConfig>,
|
||||
bind_ip: Option<&str>,
|
||||
debug: bool,
|
||||
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
|
||||
) -> Result<UdpProxySocket> {
|
||||
if let Some(outbound) = outbound {
|
||||
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 {
|
||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||
}
|
||||
if action == OutboundAction::Proxy {
|
||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||
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
|
||||
// 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(
|
||||
proxy_addr: &str,
|
||||
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
|
||||
bind_ip: Option<&str>,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<UdpProxySocket> {
|
||||
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?;
|
||||
|
||||
// Send UDP Associate request
|
||||
|
|
@ -688,7 +733,7 @@ mod tests {
|
|||
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());
|
||||
}
|
||||
|
||||
|
|
@ -701,7 +746,7 @@ mod tests {
|
|||
drop(listener);
|
||||
|
||||
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!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,10 +155,16 @@ pub async fn handle_relay_message(
|
|||
if router.debug {
|
||||
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),
|
||||
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 {
|
||||
Ok(s) => std::sync::Arc::new(s),
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ use crate::dns::DnsServer;
|
|||
#[derive(Clone)]
|
||||
pub struct Router {
|
||||
pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>,
|
||||
pub bind_ip: Option<String>,
|
||||
pub dns_server: Arc<DnsServer>,
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
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 {
|
||||
outbound_cfg: Arc::new(RwLock::new(outbound_cfg)),
|
||||
bind_ip,
|
||||
dns_server,
|
||||
debug,
|
||||
}
|
||||
|
|
@ -26,7 +28,7 @@ impl Router {
|
|||
let lock = self.outbound_cfg.read().unwrap();
|
||||
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
|
||||
|
|
@ -35,7 +37,7 @@ impl Router {
|
|||
let lock = self.outbound_cfg.read().unwrap();
|
||||
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
|
||||
|
|
@ -50,7 +52,7 @@ impl Router {
|
|||
if c.enabled {
|
||||
if c.protocol == "socks5" {
|
||||
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)),
|
||||
// Warn unconditionally, not only under `debug`. Every UDP
|
||||
// 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> {
|
||||
if let Some(cfg) = &self.cfg {
|
||||
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 {
|
||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1372,6 +1372,7 @@ async fn run_app() -> Result<()> {
|
|||
ip_cidr: r.ip_cidr.unwrap_or_default(),
|
||||
protocol: r.protocol,
|
||||
action: parse_outbound_action(r.action),
|
||||
send_from: r.send_from,
|
||||
})
|
||||
.collect(),
|
||||
default_action: parse_outbound_action(o.default_action),
|
||||
|
|
@ -1407,8 +1408,9 @@ async fn run_app() -> Result<()> {
|
|||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.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
|
||||
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) => {
|
||||
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
||||
|
|
|
|||
Loading…
Reference in New Issue