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:
ospab 2026-08-24 15:32:20 +03:00
parent d08738eff9
commit c0124a19be
4 changed files with 80 additions and 16 deletions

View File

@ -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>,

View File

@ -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()?;

View File

@ -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

View File

@ -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()