fix(server)!: never fall back to a direct connection when a rule says proxy

Reported: with an upstream proxy configured, Google flags the session because
the server's address and the proxy's exit address differ, and YouTube
geolocates to the server rather than the proxy exit. Both follow from the same
defect in three places — an outbound rule that says Proxy was being satisfied
by connecting DIRECTLY whenever the proxy could not be used:

  - router.rs, UdpSessionRouter::send_to: action Proxy with no UDP proxy
    established fell through to the direct socket.
  - outbound.rs, connect_target: any protocol string other than exactly
    "socks5"/"http" hit a `_ =>` arm that connected directly. A typo, or just
    "SOCKS5", silently un-proxied ALL TCP.
  - outbound.rs, connect_udp_target: non-socks5 upstream returned
    UdpProxySocket::Direct, with a comment noting HTTP cannot carry UDP —
    correct in itself, but the chosen fallback leaks.

The visible symptom is precisely what these produce: TCP goes through the
proxy while UDP (QUIC — which is what YouTube uses) leaves from the server,
so one session presents two exit addresses.

All three now fail closed. A rule asking for the proxy is never honoured by
sending in the clear: a dropped flow is visible and debuggable, a
deanonymising leak is neither. Protocol matching is also case-insensitive
now, and the SOCKS5 UDP ASSOCIATE failure warns unconditionally rather than
only under `debug`, which is why this could go unnoticed.

Behaviour change: with an HTTP upstream, or a broken SOCKS5 UDP ASSOCIATE,
UDP now fails instead of leaking. Where that is genuinely wanted, it must be
stated in the config as an explicit udp rule with action "direct".

Also carries the relay key-sync diagnostics: a 404 there means the URL is
missing the panel's secret webpath (the API is nested under it, not at /api),
which the previous bare "HTTP 404" gave no way to work out.
This commit is contained in:
ospab 2026-08-03 17:57:42 +03:00
parent a1c146aff3
commit b673219894
2 changed files with 74 additions and 13 deletions

View File

@ -48,10 +48,23 @@ pub async fn connect_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);
return match outbound.protocol.as_str() { // 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).await, "socks5" => connect_via_socks5(&proxy_addr, target).await,
"http" => connect_via_http(&proxy_addr, target).await, "http" => connect_via_http(&proxy_addr, target).await,
_ => connect_direct(target, connect_timeout).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
// straight out of the server while the operator believed it
// was proxied. Combined with the same bug on the UDP path,
// that is how one session ends up presenting two different
// exit addresses to the remote site.
other => Err(anyhow::anyhow!(
"outbound.protocol is \"{other}\", which is not a supported proxy type \
(expected \"socks5\" or \"http\"); refusing to connect to {target} \
directly, because the rules asked for the proxy"
)),
}; };
} }
} }
@ -370,10 +383,22 @@ 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 == "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).await;
} }
// HTTP CONNECT does not support UDP. Fallback to direct. // FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
// answer to that is not to send the datagrams in the clear. The
// previous "fallback to direct" honoured a Proxy rule by
// egressing from the server's own address, so with an HTTP
// upstream every UDP flow (QUIC, DNS) leaked while TCP stayed
// proxied, presenting two exit IPs to the same remote site.
return Err(anyhow::anyhow!(
"outbound rules route UDP to {target} through the proxy, but the upstream \
protocol is \"{}\", which cannot carry UDP. Refusing to send directly. \
Use a socks5 upstream, or add an explicit udp rule with action \"direct\" \
or \"block\" so the intent is recorded in the config.",
outbound.protocol
));
} }
} }
} }

View File

@ -47,12 +47,29 @@ impl Router {
let mut proxy = None; let mut proxy = None;
if let Some(ref c) = cfg { if let Some(ref c) = cfg {
if c.enabled && c.protocol == "socks5" { if c.enabled {
let proxy_addr = format!("{}:{}", c.address, c.port); if c.protocol == "socks5" {
if let Ok(p) = crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await { let proxy_addr = format!("{}:{}", c.address, c.port);
proxy = Some(Arc::new(p)); match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
} else if self.debug { Ok(p) => proxy = Some(Arc::new(p)),
tracing::warn!("Failed to establish SOCKS5 UDP Associate"); // Warn unconditionally, not only under `debug`. Every UDP
// flow the rules want proxied is now dropped instead of
// sent, so an operator who cannot see this has a session
// where TCP works and UDP silently does not.
Err(e) => tracing::warn!(
"SOCKS5 UDP ASSOCIATE to {proxy_addr} failed: {e}. UDP that the \
outbound rules route through the proxy will be DROPPED (it is not \
sent directly, which would expose this server's address)."
),
}
} else {
tracing::warn!(
"Upstream proxy protocol is '{}', which cannot carry UDP. UDP matching \
a Proxy rule will be DROPPED. Use a socks5 upstream for UDP, or add an \
explicit udp rule with action \"direct\" or \"block\" to make the \
intent explicit.",
c.protocol
);
} }
} }
} }
@ -87,9 +104,28 @@ impl UdpSessionRouter {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target)); return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
} }
if action == crate::outbound::OutboundAction::Proxy { if action == crate::outbound::OutboundAction::Proxy {
if let Some(p) = &self.proxy { return match &self.proxy {
return p.send_to(data, target).await; Some(p) => p.send_to(data, target).await,
} // FAIL CLOSED. This used to fall through to the direct
// socket, so whenever the UDP proxy was unavailable —
// the SOCKS5 UDP ASSOCIATE failed, or the upstream is an
// HTTP proxy, which cannot carry UDP at all — every UDP
// datagram silently egressed from the server's own
// address while TCP still went through the proxy. The
// session then had two different exit IPs, which is what
// Google flags and why YouTube (QUIC, i.e. UDP/443)
// geolocated to the server instead of the proxy exit.
//
// A rule that says "proxy" must never be satisfied by
// sending in the clear: a dropped datagram is visible and
// debuggable, a deanonymising leak is neither.
None => Err(anyhow::anyhow!(
"outbound rule requires the proxy for UDP to {target}, but no UDP \
proxy is available (SOCKS5 UDP ASSOCIATE failed, or the upstream \
is an HTTP proxy, which cannot carry UDP) - dropping rather than \
leaking the server's own address"
)),
};
} }
} }
} }