mirror of https://github.com/ospab/ostp.git
feat(outbound): SOCKS5 username/password auth for the upstream proxy
Adds optional username/password to the server's outbound proxy config so an upstream SOCKS5 that requires authentication — a residential-proxy service, for instance — can be used. Both the TCP-connect and UDP-associate paths now run a shared RFC 1929 negotiation: when credentials are set the client offers method 0x02 (and 0x00), and on a 0x02 selection performs the username/password sub-negotiation; a proxy that rejects all methods reports a clear "set outbound.username/password" error instead of a bare failure. No credentials = the previous no-auth behaviour, unchanged. The two on-disk config templates (`ostp init` / `ostp setup`) now carry empty "username"/"password" in the outbound block so the fields are discoverable and ready to fill. Both fields default on deserialize, so existing configs are unaffected.
This commit is contained in:
parent
d08738eff9
commit
c0124a19be
|
|
@ -552,6 +552,13 @@ pub struct OutboundConfig {
|
||||||
pub protocol: String,
|
pub protocol: String,
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
/// SOCKS5 username, for an upstream proxy that requires authentication
|
||||||
|
/// (e.g. a residential-proxy service). Empty/absent = no-auth SOCKS5.
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
/// SOCKS5 password (paired with `username`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rules: Vec<OutboundRule>,
|
pub rules: Vec<OutboundRule>,
|
||||||
pub default_action: Option<String>,
|
pub default_action: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ pub struct OutboundConfig {
|
||||||
pub protocol: String,
|
pub protocol: String,
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
/// SOCKS5 credentials for an upstream proxy that requires auth (e.g. a
|
||||||
|
/// residential-proxy service). Empty = no-auth SOCKS5.
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
pub rules: Vec<OutboundRule>,
|
pub rules: Vec<OutboundRule>,
|
||||||
pub default_action: OutboundAction,
|
pub default_action: OutboundAction,
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +55,7 @@ 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).await,
|
"socks5" => connect_via_socks5(&proxy_addr, target, &outbound.username, &outbound.password).await,
|
||||||
"http" => connect_via_http(&proxy_addr, target).await,
|
"http" => connect_via_http(&proxy_addr, target).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,
|
||||||
|
|
@ -193,16 +197,66 @@ async fn match_ip_rule(host: &str, _port: u16, cidrs: &[String]) -> bool {
|
||||||
|
|
||||||
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
|
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
|
||||||
|
|
||||||
async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
/// SOCKS5 method negotiation plus RFC 1929 username/password auth when
|
||||||
|
/// credentials are supplied. Shared by the TCP-connect and UDP-associate paths
|
||||||
|
/// so both authenticate identically to an upstream that requires it.
|
||||||
|
async fn socks5_negotiate_auth<S>(stream: &mut S, username: &str, password: &str) -> Result<()>
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
let use_auth = !username.is_empty();
|
||||||
|
if use_auth && (username.len() > 255 || password.len() > 255) {
|
||||||
|
anyhow::bail!("SOCKS5 username/password must each be at most 255 bytes");
|
||||||
|
}
|
||||||
|
// Offer username/password (0x02) alongside no-auth (0x00) when we have
|
||||||
|
// credentials, so a residential-proxy service that demands auth is satisfied
|
||||||
|
// while a plain proxy still works.
|
||||||
|
if use_auth {
|
||||||
|
stream.write_all(&[0x05, 0x02, 0x00, 0x02]).await?;
|
||||||
|
} else {
|
||||||
|
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
||||||
|
}
|
||||||
|
let mut reply = [0u8; 2];
|
||||||
|
stream.read_exact(&mut reply).await?;
|
||||||
|
if reply[0] != 0x05 {
|
||||||
|
anyhow::bail!("SOCKS5: unexpected version 0x{:02x} in method reply", reply[0]);
|
||||||
|
}
|
||||||
|
match reply[1] {
|
||||||
|
0x00 => {} // no authentication required
|
||||||
|
0x02 => {
|
||||||
|
let mut auth = vec![0x01u8];
|
||||||
|
auth.push(username.len() as u8);
|
||||||
|
auth.extend_from_slice(username.as_bytes());
|
||||||
|
auth.push(password.len() as u8);
|
||||||
|
auth.extend_from_slice(password.as_bytes());
|
||||||
|
stream.write_all(&auth).await?;
|
||||||
|
let mut ar = [0u8; 2];
|
||||||
|
stream.read_exact(&mut ar).await?;
|
||||||
|
if ar[1] != 0x00 {
|
||||||
|
anyhow::bail!("SOCKS5 username/password auth rejected (status 0x{:02x})", ar[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0xFF => anyhow::bail!(
|
||||||
|
"SOCKS5 proxy rejected all offered auth methods — it likely requires \
|
||||||
|
credentials; set outbound.username / outbound.password"
|
||||||
|
),
|
||||||
|
other => anyhow::bail!("SOCKS5 proxy chose unsupported auth method 0x{:02x}", other),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_via_socks5(
|
||||||
|
proxy_addr: &str,
|
||||||
|
target: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<TcpStream> {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(proxy_addr).await?;
|
let mut stream = TcpStream::connect(proxy_addr).await?;
|
||||||
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
socks5_negotiate_auth(&mut stream, username, password).await?;
|
||||||
let mut reply = [0u8; 2];
|
|
||||||
stream.read_exact(&mut reply).await?;
|
|
||||||
if reply != [0x05, 0x00] {
|
|
||||||
anyhow::bail!("SOCKS5 auth not accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
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"))?;
|
||||||
let mut req = Vec::new();
|
let mut req = Vec::new();
|
||||||
|
|
@ -384,7 +438,7 @@ pub async fn connect_udp_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).await;
|
return connect_udp_via_socks5(&proxy_addr, server_udp, &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
|
||||||
|
|
@ -408,16 +462,13 @@ 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>,
|
||||||
|
username: &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 mut stream = TcpStream::connect(proxy_addr).await?;
|
||||||
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
socks5_negotiate_auth(&mut stream, username, password).await?;
|
||||||
let mut reply = [0u8; 2];
|
|
||||||
stream.read_exact(&mut reply).await?;
|
|
||||||
if reply != [0x05, 0x00] {
|
|
||||||
anyhow::bail!("SOCKS5 auth not accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send UDP Associate request
|
// Send UDP Associate request
|
||||||
let local_addr = server_udp.local_addr()?;
|
let local_addr = server_udp.local_addr()?;
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,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()).await {
|
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone(), &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
|
||||||
|
|
|
||||||
|
|
@ -649,6 +649,8 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
"protocol": "socks5",
|
"protocol": "socks5",
|
||||||
"address": "127.0.0.1",
|
"address": "127.0.0.1",
|
||||||
"port": 9050,
|
"port": 9050,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
"default_action": "proxy",
|
"default_action": "proxy",
|
||||||
"rules": []
|
"rules": []
|
||||||
},
|
},
|
||||||
|
|
@ -751,6 +753,8 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
"protocol": "socks5",
|
"protocol": "socks5",
|
||||||
"address": "127.0.0.1",
|
"address": "127.0.0.1",
|
||||||
"port": 9050,
|
"port": 9050,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
"default_action": "proxy",
|
"default_action": "proxy",
|
||||||
"rules": []
|
"rules": []
|
||||||
},
|
},
|
||||||
|
|
@ -1358,6 +1362,8 @@ async fn run_app() -> Result<()> {
|
||||||
protocol: o.protocol,
|
protocol: o.protocol,
|
||||||
address: o.address,
|
address: o.address,
|
||||||
port: o.port,
|
port: o.port,
|
||||||
|
username: o.username,
|
||||||
|
password: o.password,
|
||||||
rules: o
|
rules: o
|
||||||
.rules
|
.rules
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue