Add SOCKS5 CONNECT support

This commit is contained in:
2026-10-29 00:26:15 +00:00
parent f191d66ee4
commit 488f25491d
10 changed files with 1145 additions and 65 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi
ENV HERMES_CONFIG_PATH=/etc/hermes/hermes.yaml ENV HERMES_CONFIG_PATH=/etc/hermes/hermes.yaml
WORKDIR /etc/hermes WORKDIR /etc/hermes
USER 65532:65532 USER 65532:65532
EXPOSE 8080 EXPOSE 8080 1080
ENTRYPOINT ["/usr/local/bin/hermes"] ENTRYPOINT ["/usr/local/bin/hermes"]
+37 -1
View File
@@ -6,6 +6,8 @@ Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streamin
- Absolute-form HTTP forwarding (`GET http://host/path`) - Absolute-form HTTP forwarding (`GET http://host/path`)
- HTTPS and arbitrary TCP tunneling with `CONNECT host:port` - HTTPS and arbitrary TCP tunneling with `CONNECT host:port`
- Optional SOCKS5 CONNECT listener with no-auth and username/password authentication
- SOCKS5 IPv4, IPv6, and domain destinations with shared ACL enforcement
- Streaming request and response bodies - Streaming request and response bodies
- Upstream connection pooling and configurable timeouts - Upstream connection pooling and configurable timeouts
- Ordered allow/deny ACLs using Basic authentication, client IP networks, and destination domains - Ordered allow/deny ACLs using Basic authentication, client IP networks, and destination domains
@@ -67,7 +69,9 @@ The Gitea workflow in `.gitea/workflows/build.yaml` builds and publishes `linux/
```yaml ```yaml
listen_addr: "127.0.0.1:8080" listen_addr: "127.0.0.1:8080"
socks5_listen_addr: "127.0.0.1:1080"
connect_timeout_ms: 10000 connect_timeout_ms: 10000
socks5_handshake_timeout_ms: 10000
upstream_timeout_ms: 30000 upstream_timeout_ms: 30000
telemetry: telemetry:
@@ -110,7 +114,9 @@ acl:
| Field | Default | Description | | Field | Default | Description |
| --- | --- | --- | | --- | --- | --- |
| `listen_addr` | `127.0.0.1:8080` | Downstream listen address | | `listen_addr` | `127.0.0.1:8080` | Downstream listen address |
| `socks5_listen_addr` | `null` | Optional SOCKS5 listen address; disabled when omitted |
| `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector | | `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector |
| `socks5_handshake_timeout_ms` | `10000` | Timeout for SOCKS5 negotiation and CONNECT request parsing |
| `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers | | `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers |
| `telemetry` | see OpenTelemetry below | Logging, resource, and OTLP configuration | | `telemetry` | see OpenTelemetry below | Logging, resource, and OTLP configuration |
| `acl.default_action` | `deny` | Action when no rule matches | | `acl.default_action` | `deny` | Action when no rule matches |
@@ -200,6 +206,35 @@ Password verification is synchronous and inexpensive, and digest comparisons are
**Basic authentication is not encrypted in transit.** A client sending Basic credentials over cleartext HTTP exposes them to the network. Use Basic authentication only on localhost or a trusted private network unless downstream TLS is added. Do not expose Hermes publicly without appropriate network controls. **Basic authentication is not encrypted in transit.** A client sending Basic credentials over cleartext HTTP exposes them to the network. Use Basic authentication only on localhost or a trusted private network unless downstream TLS is added. Do not expose Hermes publicly without appropriate network controls.
## SOCKS5
Set `socks5_listen_addr` to enable a separate SOCKS5 listener alongside the HTTP proxy:
```yaml
listen_addr: "127.0.0.1:8080"
socks5_listen_addr: "127.0.0.1:1080"
socks5_handshake_timeout_ms: 10000
```
The listener supports SOCKS5 CONNECT with IPv4, IPv6, and domain destinations. Domain destinations are resolved by Hermes; use `--socks5-hostname` with curl to request proxy-side DNS resolution:
```sh
# Client-side DNS with an IP destination
curl --socks5 127.0.0.1:1080 http://198.51.100.10/
# Proxy-side DNS with a domain destination
curl --socks5-hostname 127.0.0.1:1080 http://example.com/
# Username/password authentication using the configured ACL user
curl --socks5-hostname 127.0.0.1:1080 \
--proxy-user 'alice:your-password' \
http://example.com/
```
SOCKS5 reuses the ACL's salted password hashes and applies `users`, `networks`, and `domains` before upstream dialing. Username/password negotiation is selected when required by the ACL; otherwise no-auth is used. Authentication is cleartext on the network, so restrict the listener to localhost or trusted networks. ACL denial returns SOCKS reply `0x02` before any upstream connection.
Only CONNECT is supported. SOCKS4, BIND, and UDP ASSOCIATE are not implemented. Docker users can publish the listener with `-p 127.0.0.1:1080:1080` when it is enabled.
## OpenTelemetry ## OpenTelemetry
Hermes exports traces, metrics, and logs independently to an OpenTelemetry Collector using OTLP/HTTP (protobuf). All settings come from `telemetry` in YAML; `OTEL_*` environment variables are not read. Hermes exports traces, metrics, and logs independently to an OpenTelemetry Collector using OTLP/HTTP (protobuf). All settings come from `telemetry` in YAML; `OTEL_*` environment variables are not read.
@@ -247,6 +282,7 @@ In another terminal, run Hermes and send a request through it. The collector wil
- `proxy.upstream`: upstream HTTP client operation - `proxy.upstream`: upstream HTTP client operation
- `proxy.connect`: `CONNECT` handshake and tunnel lifetime - `proxy.connect`: `CONNECT` handshake and tunnel lifetime
- `proxy.upstream_connect`: upstream TCP connection establishment - `proxy.upstream_connect`: upstream TCP connection establishment
- `socks5.connect`: SOCKS5 negotiation, authorization, upstream connection, and tunnel lifetime
For plain HTTP, incoming W3C `traceparent` and `tracestate` headers establish the parent context, and a new context is injected upstream. Trace context cannot be propagated inside an encrypted `CONNECT` tunnel. Request spans include the ACL decision, reason, and matched rule name without credentials. For plain HTTP, incoming W3C `traceparent` and `tracestate` headers establish the parent context, and a new context is injected upstream. Trace context cannot be propagated inside an encrypted `CONNECT` tunnel. Request spans include the ACL decision, reason, and matched rule name without credentials.
@@ -264,7 +300,7 @@ Hermes uses standard HTTP instrument names where applicable and adds proxy-speci
- `hermes.proxy.tunnel.bytes` - `hermes.proxy.tunnel.bytes`
- `hermes.proxy.acl.decisions` - `hermes.proxy.acl.decisions`
Metric attributes are deliberately bounded. Raw URLs, destination hostnames, usernames, rule names, and client addresses are not metric attributes. Metric attributes are deliberately bounded. Tunnel and ACL metrics include `proxy.protocol` to distinguish HTTP, HTTP CONNECT, and SOCKS5. Raw URLs, destination hostnames, usernames, rule names, and client addresses are not metric attributes.
### Logs ### Logs
+3
View File
@@ -1,5 +1,8 @@
listen_addr: "127.0.0.1:8080" listen_addr: "127.0.0.1:8080"
# To enable SOCKS5, use "127.0.0.1:1080" or another non-conflicting address.
socks5_listen_addr: null
connect_timeout_ms: 10000 connect_timeout_ms: 10000
socks5_handshake_timeout_ms: 10000
upstream_timeout_ms: 30000 upstream_timeout_ms: 30000
telemetry: telemetry:
+34 -18
View File
@@ -248,6 +248,38 @@ impl Acl {
}) })
} }
pub fn requires_user_password(&self) -> bool {
self.default_action == Action::Deny && self.has_user_rules
}
pub fn authenticate(&self, username: &str, password: &[u8]) -> bool {
self.users
.get(username)
.is_some_and(|user| user.password_hash.verify(password))
}
pub fn authorize_identity(
&self,
peer: IpAddr,
destination: Option<&DestinationHost>,
authenticated_user: Option<&str>,
) -> AclOutcome {
for rule in &self.rules {
if rule_matches(rule, peer, authenticated_user, destination) {
return AclOutcome::rule(rule);
}
}
if authenticated_user.is_none()
&& self.default_action == Action::Deny
&& self.has_user_rules
{
return AclOutcome::challenge("authentication_required");
}
AclOutcome::default(self.default_action)
}
pub fn authorize( pub fn authorize(
&self, &self,
peer: IpAddr, peer: IpAddr,
@@ -262,10 +294,7 @@ impl Acl {
let authenticated_user = match credentials { let authenticated_user = match credentials {
Some(credentials) => { Some(credentials) => {
let Some(user) = self.users.get(&credentials.username) else { if !self.authenticate(&credentials.username, credentials.password.as_slice()) {
return AclOutcome::challenge("invalid_credentials");
};
if !user.password_hash.verify(credentials.password.as_slice()) {
return AclOutcome::challenge("invalid_credentials"); return AclOutcome::challenge("invalid_credentials");
} }
Some(credentials.username) Some(credentials.username)
@@ -273,20 +302,7 @@ impl Acl {
None => None, None => None,
}; };
for rule in &self.rules { self.authorize_identity(peer, destination, authenticated_user.as_deref())
if rule_matches(rule, peer, authenticated_user.as_deref(), destination) {
return AclOutcome::rule(rule);
}
}
if authenticated_user.is_none()
&& self.default_action == Action::Deny
&& self.has_user_rules
{
return AclOutcome::challenge("authentication_required");
}
AclOutcome::default(self.default_action)
} }
} }
+29
View File
@@ -9,11 +9,14 @@ use crate::acl::{Acl, AclError};
const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000; const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
const DEFAULT_UPSTREAM_TIMEOUT_MS: u64 = 30_000; const DEFAULT_UPSTREAM_TIMEOUT_MS: u64 = 30_000;
const DEFAULT_SOCKS5_HANDSHAKE_TIMEOUT_MS: u64 = 10_000;
#[derive(Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
pub listen_addr: SocketAddr, pub listen_addr: SocketAddr,
pub socks5_listen_addr: Option<SocketAddr>,
pub connect_timeout: Duration, pub connect_timeout: Duration,
pub socks5_handshake_timeout: Duration,
pub upstream_timeout: Duration, pub upstream_timeout: Duration,
pub telemetry: TelemetryConfig, pub telemetry: TelemetryConfig,
pub(crate) acl: Arc<Acl>, pub(crate) acl: Arc<Acl>,
@@ -37,11 +40,19 @@ impl Config {
if file.upstream_timeout_ms == 0 { if file.upstream_timeout_ms == 0 {
return Err(ConfigError::ZeroDuration("upstream_timeout_ms")); return Err(ConfigError::ZeroDuration("upstream_timeout_ms"));
} }
if file.socks5_handshake_timeout_ms == 0 {
return Err(ConfigError::ZeroDuration("socks5_handshake_timeout_ms"));
}
if file.socks5_listen_addr == Some(file.listen_addr) {
return Err(ConfigError::ListenerConflict);
}
file.telemetry.validate()?; file.telemetry.validate()?;
Ok(Self { Ok(Self {
listen_addr: file.listen_addr, listen_addr: file.listen_addr,
socks5_listen_addr: file.socks5_listen_addr,
connect_timeout: Duration::from_millis(file.connect_timeout_ms), connect_timeout: Duration::from_millis(file.connect_timeout_ms),
socks5_handshake_timeout: Duration::from_millis(file.socks5_handshake_timeout_ms),
upstream_timeout: Duration::from_millis(file.upstream_timeout_ms), upstream_timeout: Duration::from_millis(file.upstream_timeout_ms),
telemetry: file.telemetry, telemetry: file.telemetry,
acl: Arc::new(Acl::from_config(file.acl)?), acl: Arc::new(Acl::from_config(file.acl)?),
@@ -54,10 +65,14 @@ impl Config {
struct FileConfig { struct FileConfig {
#[serde(default = "default_listen_addr")] #[serde(default = "default_listen_addr")]
listen_addr: SocketAddr, listen_addr: SocketAddr,
#[serde(default)]
socks5_listen_addr: Option<SocketAddr>,
#[serde(default = "default_connect_timeout_ms")] #[serde(default = "default_connect_timeout_ms")]
connect_timeout_ms: u64, connect_timeout_ms: u64,
#[serde(default = "default_upstream_timeout_ms")] #[serde(default = "default_upstream_timeout_ms")]
upstream_timeout_ms: u64, upstream_timeout_ms: u64,
#[serde(default = "default_socks5_handshake_timeout_ms")]
socks5_handshake_timeout_ms: u64,
#[serde(default)] #[serde(default)]
telemetry: TelemetryConfig, telemetry: TelemetryConfig,
acl: AclConfig, acl: AclConfig,
@@ -75,6 +90,10 @@ const fn default_upstream_timeout_ms() -> u64 {
DEFAULT_UPSTREAM_TIMEOUT_MS DEFAULT_UPSTREAM_TIMEOUT_MS
} }
const fn default_socks5_handshake_timeout_ms() -> u64 {
DEFAULT_SOCKS5_HANDSHAKE_TIMEOUT_MS
}
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct TelemetryConfig { pub struct TelemetryConfig {
@@ -290,6 +309,8 @@ pub enum ConfigError {
InvalidHeader(String), InvalidHeader(String),
#[error("telemetry.service_name must not be empty")] #[error("telemetry.service_name must not be empty")]
EmptyServiceName, EmptyServiceName,
#[error("listen_addr and socks5_listen_addr must differ")]
ListenerConflict,
#[error(transparent)] #[error(transparent)]
Acl(#[from] AclError), Acl(#[from] AclError),
} }
@@ -314,6 +335,8 @@ acl:
assert_eq!(config.listen_addr, default_listen_addr()); assert_eq!(config.listen_addr, default_listen_addr());
assert_eq!(config.connect_timeout, Duration::from_secs(10)); assert_eq!(config.connect_timeout, Duration::from_secs(10));
assert_eq!(config.upstream_timeout, Duration::from_secs(30)); assert_eq!(config.upstream_timeout, Duration::from_secs(30));
assert_eq!(config.socks5_listen_addr, None);
assert_eq!(config.socks5_handshake_timeout, Duration::from_secs(10));
assert_eq!(config.telemetry.service_name, "hermes"); assert_eq!(config.telemetry.service_name, "hermes");
assert_eq!(config.telemetry.log_filter, "info"); assert_eq!(config.telemetry.log_filter, "info");
assert_eq!(config.telemetry.otlp.endpoint, "http://127.0.0.1:4318"); assert_eq!(config.telemetry.otlp.endpoint, "http://127.0.0.1:4318");
@@ -453,5 +476,11 @@ acl:
let zero = Config::from_yaml("connect_timeout_ms: 0\nacl: {}\n"); let zero = Config::from_yaml("connect_timeout_ms: 0\nacl: {}\n");
assert!(matches!(zero, Err(ConfigError::ZeroDuration(_)))); assert!(matches!(zero, Err(ConfigError::ZeroDuration(_))));
let zero_socks = Config::from_yaml("socks5_handshake_timeout_ms: 0\nacl: {}\n");
assert!(matches!(zero_socks, Err(ConfigError::ZeroDuration(_))));
let conflict = Config::from_yaml("socks5_listen_addr: \"127.0.0.1:8080\"\nacl: {}\n");
assert!(matches!(conflict, Err(ConfigError::ListenerConflict)));
} }
} }
+2
View File
@@ -4,6 +4,8 @@ pub mod telemetry;
mod metrics; mod metrics;
mod proxy; mod proxy;
mod socks5;
mod tunnel;
use std::error::Error; use std::error::Error;
+23 -9
View File
@@ -128,42 +128,53 @@ impl Metrics {
); );
} }
pub fn acl_decision(&self, decision: &str, reason: &str) { pub fn acl_decision(&self, protocol: &str, decision: &str, reason: &str) {
self.acl_decisions.add( self.acl_decisions.add(
1, 1,
&[ &[
KeyValue::new("proxy.protocol", protocol.to_owned()),
KeyValue::new("acl.decision", decision.to_owned()), KeyValue::new("acl.decision", decision.to_owned()),
KeyValue::new("acl.reason", reason.to_owned()), KeyValue::new("acl.reason", reason.to_owned()),
], ],
); );
} }
pub fn tunnel_started(&self) -> ActiveTunnel { pub fn tunnel_started(&self, protocol: &'static str) -> ActiveTunnel {
self.active_tunnels.add(1, &[]); self.active_tunnels
.add(1, &[KeyValue::new("proxy.protocol", protocol)]);
ActiveTunnel { ActiveTunnel {
metrics: self.clone(), metrics: self.clone(),
protocol,
} }
} }
pub fn tunnel_finished( pub fn tunnel_finished(
&self, &self,
protocol: &str,
duration: Duration, duration: Duration,
client_to_upstream: u64, client_to_upstream: u64,
upstream_to_client: u64, upstream_to_client: u64,
error_type: Option<&str>, error_type: Option<&str>,
) { ) {
let attributes = error_type let mut attributes = vec![KeyValue::new("proxy.protocol", protocol.to_owned())];
.map(|value| vec![KeyValue::new("error.type", value.to_owned())]) if let Some(error_type) = error_type {
.unwrap_or_default(); attributes.push(KeyValue::new("error.type", error_type.to_owned()));
}
self.tunnel_duration self.tunnel_duration
.record(duration.as_secs_f64(), &attributes); .record(duration.as_secs_f64(), &attributes);
self.tunnel_bytes.add( self.tunnel_bytes.add(
client_to_upstream, client_to_upstream,
&[KeyValue::new("direction", "client_to_upstream")], &[
KeyValue::new("proxy.protocol", protocol.to_owned()),
KeyValue::new("direction", "client_to_upstream"),
],
); );
self.tunnel_bytes.add( self.tunnel_bytes.add(
upstream_to_client, upstream_to_client,
&[KeyValue::new("direction", "upstream_to_client")], &[
KeyValue::new("proxy.protocol", protocol.to_owned()),
KeyValue::new("direction", "upstream_to_client"),
],
); );
} }
} }
@@ -180,10 +191,13 @@ impl Drop for ActiveRequest {
pub(crate) struct ActiveTunnel { pub(crate) struct ActiveTunnel {
metrics: Metrics, metrics: Metrics,
protocol: &'static str,
} }
impl Drop for ActiveTunnel { impl Drop for ActiveTunnel {
fn drop(&mut self) { fn drop(&mut self) {
self.metrics.active_tunnels.add(-1, &[]); self.metrics
.active_tunnels
.add(-1, &[KeyValue::new("proxy.protocol", self.protocol)]);
} }
} }
+46 -36
View File
@@ -20,7 +20,7 @@ use hyper_util::{
use opentelemetry::global; use opentelemetry::global;
use tokio::{ use tokio::{
net::{TcpListener, TcpStream}, net::{TcpListener, TcpStream},
task::JoinSet, task::{JoinHandle, JoinSet},
time::timeout, time::timeout,
}; };
use tracing::{Instrument, Span, debug, error, field, info, info_span, warn}; use tracing::{Instrument, Span, debug, error, field, info, info_span, warn};
@@ -31,7 +31,9 @@ use crate::{
acl::{Acl, Decision, DestinationHost}, acl::{Acl, Decision, DestinationHost},
config::Config, config::Config,
metrics::Metrics, metrics::Metrics,
socks5::{self, SocksState},
telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers}, telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers},
tunnel::{self, TunnelProtocol},
}; };
type ProxyBody = UnsyncBoxBody<Bytes, BoxError>; type ProxyBody = UnsyncBoxBody<Bytes, BoxError>;
@@ -66,6 +68,14 @@ impl ProxyState {
pub async fn run(config: Config) -> Result<(), BoxError> { pub async fn run(config: Config) -> Result<(), BoxError> {
let listener = TcpListener::bind(config.listen_addr).await?; let listener = TcpListener::bind(config.listen_addr).await?;
let state = ProxyState::new(&config); let state = ProxyState::new(&config);
let mut socks_task = match config.socks5_listen_addr {
Some(address) => {
let listener = TcpListener::bind(address).await?;
let state = SocksState::new(&config);
Some(tokio::spawn(socks5::run(listener, state)))
}
None => None,
};
let mut connections = JoinSet::new(); let mut connections = JoinSet::new();
let shutdown = tokio::signal::ctrl_c(); let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown); tokio::pin!(shutdown);
@@ -96,13 +106,33 @@ pub async fn run(config: Config) -> Result<(), BoxError> {
error!(error.message = %error, "connection task failed"); error!(error.message = %error, "connection task failed");
} }
} }
completed = wait_for_socks(&mut socks_task) => {
match completed {
Ok(Ok(())) => error!("SOCKS5 listener stopped unexpectedly"),
Ok(Err(error)) => error!(error.message = %error, "SOCKS5 listener failed"),
Err(error) => error!(error.message = %error, "SOCKS5 listener task failed"),
}
break;
}
} }
} }
if let Some(task) = socks_task {
task.abort();
}
info!("proxy listener stopped"); info!("proxy listener stopped");
Ok(()) Ok(())
} }
async fn wait_for_socks(
task: &mut Option<JoinHandle<Result<(), BoxError>>>,
) -> Result<Result<(), BoxError>, tokio::task::JoinError> {
match task {
Some(task) => task.await,
None => std::future::pending().await,
}
}
async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState) { async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState) {
let peer_ip = peer.ip(); let peer_ip = peer.ip();
let service = service_fn(move |request| { let service = service_fn(move |request| {
@@ -188,9 +218,14 @@ fn authorize_request(
.acl .acl
.authorize(peer, request.headers(), destination.as_ref()); .authorize(peer, request.headers(), destination.as_ref());
let protocol = if request.method() == Method::CONNECT {
"http_connect"
} else {
"http"
};
state state
.metrics .metrics
.acl_decision(outcome.decision.as_str(), outcome.reason); .acl_decision(protocol, outcome.decision.as_str(), outcome.reason);
Span::current().record("acl.decision", outcome.decision.as_str()); Span::current().record("acl.decision", outcome.decision.as_str());
Span::current().record("acl.reason", outcome.reason); Span::current().record("acl.reason", outcome.reason);
if let Some(rule) = &outcome.rule { if let Some(rule) = &outcome.rule {
@@ -424,41 +459,14 @@ async fn run_tunnel(request: Request<Incoming>, mut upstream: TcpStream, metrics
} }
}; };
let started = Instant::now();
let _active = metrics.tunnel_started();
let mut downstream = TokioIo::new(upgraded); let mut downstream = TokioIo::new(upgraded);
tunnel::relay(
match tokio::io::copy_bidirectional(&mut downstream, &mut upstream).await { &mut downstream,
Ok((client_to_upstream, upstream_to_client)) => { &mut upstream,
Span::current().record( &metrics,
"tunnel.bytes.client_to_upstream", TunnelProtocol::HttpConnect,
i64::try_from(client_to_upstream).unwrap_or(i64::MAX), )
); .await;
Span::current().record(
"tunnel.bytes.upstream_to_client",
i64::try_from(upstream_to_client).unwrap_or(i64::MAX),
);
metrics.tunnel_finished(
started.elapsed(),
client_to_upstream,
upstream_to_client,
None,
);
info!(
tunnel.bytes.client_to_upstream = client_to_upstream,
tunnel.bytes.upstream_to_client = upstream_to_client,
tunnel.duration_ms = started.elapsed().as_millis(),
"CONNECT tunnel closed"
);
}
Err(error) => {
Span::current().record("error.type", "tunnel_io");
Span::current().record("otel.status_code", "ERROR");
metrics.error("tunnel", "tunnel_io");
metrics.tunnel_finished(started.elapsed(), 0, 0, Some("tunnel_io"));
error!(error.message = %error, "CONNECT tunnel failed");
}
}
} }
fn finish_request( fn finish_request(
@@ -869,7 +877,9 @@ mod tests {
let address = listener.local_addr().unwrap(); let address = listener.local_addr().unwrap();
let state = ProxyState::new(&Config { let state = ProxyState::new(&Config {
listen_addr: address, listen_addr: address,
socks5_listen_addr: None,
connect_timeout: Duration::from_secs(2), connect_timeout: Duration::from_secs(2),
socks5_handshake_timeout: Duration::from_secs(2),
upstream_timeout: Duration::from_secs(2), upstream_timeout: Duration::from_secs(2),
telemetry: TelemetryConfig::default(), telemetry: TelemetryConfig::default(),
acl: Arc::new(acl), acl: Arc::new(acl),
+892
View File
@@ -0,0 +1,892 @@
use std::{
io,
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::Arc,
time::Duration,
};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
task::JoinSet,
time::timeout,
};
use tracing::{Instrument, Span, debug, error, field, info, info_span, warn};
use zeroize::Zeroizing;
use crate::{
BoxError,
acl::{Acl, Decision, DestinationHost},
config::Config,
metrics::Metrics,
tunnel::{self, TunnelProtocol},
};
const SOCKS_VERSION: u8 = 5;
const AUTH_NONE: u8 = 0;
const AUTH_USER_PASSWORD: u8 = 2;
const AUTH_NO_ACCEPTABLE: u8 = 0xff;
const AUTH_VERSION: u8 = 1;
const COMMAND_CONNECT: u8 = 1;
const ADDRESS_IPV4: u8 = 1;
const ADDRESS_DOMAIN: u8 = 3;
const ADDRESS_IPV6: u8 = 4;
const REPLY_SUCCESS: u8 = 0;
const REPLY_GENERAL_FAILURE: u8 = 1;
const REPLY_NOT_ALLOWED: u8 = 2;
const REPLY_NETWORK_UNREACHABLE: u8 = 3;
const REPLY_HOST_UNREACHABLE: u8 = 4;
const REPLY_CONNECTION_REFUSED: u8 = 5;
const REPLY_TIMEOUT: u8 = 6;
const REPLY_COMMAND_UNSUPPORTED: u8 = 7;
const REPLY_ADDRESS_UNSUPPORTED: u8 = 8;
#[derive(Clone)]
pub(crate) struct SocksState {
acl: Arc<Acl>,
metrics: Metrics,
connect_timeout: Duration,
handshake_timeout: Duration,
}
impl SocksState {
pub fn new(config: &Config) -> Self {
Self {
acl: config.acl.clone(),
metrics: Metrics::new(opentelemetry::global::meter("hermes")),
connect_timeout: config.connect_timeout,
handshake_timeout: config.socks5_handshake_timeout,
}
}
}
pub async fn run(listener: TcpListener, state: SocksState) -> Result<(), BoxError> {
let address = listener.local_addr()?;
info!(listen.address = %address, "SOCKS5 proxy listening");
let mut connections = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
match accepted {
Ok((stream, peer)) => {
let state = state.clone();
let span = connection_span(peer);
connections.spawn(handle_connection(stream, peer, state).instrument(span));
}
Err(error) => {
error!(error.message = %error, "failed to accept SOCKS5 connection");
}
}
}
completed = connections.join_next(), if !connections.is_empty() => {
if let Some(Err(error)) = completed {
error!(error.message = %error, "SOCKS5 connection task failed");
}
}
}
}
}
fn connection_span(peer: SocketAddr) -> tracing::Span {
info_span!(
"socks5.connect",
otel.name = "SOCKS5 CONNECT",
otel.kind = "server",
client.address = %peer.ip(),
client.port = peer.port(),
server.address = field::Empty,
server.port = field::Empty,
socks5.auth_method = field::Empty,
socks5.reply_code = field::Empty,
error.type = field::Empty,
acl.decision = field::Empty,
acl.reason = field::Empty,
acl.rule = field::Empty,
tunnel.bytes.client_to_upstream = field::Empty,
tunnel.bytes.upstream_to_client = field::Empty,
otel.status_code = field::Empty,
)
}
async fn handle_connection(mut stream: TcpStream, peer: SocketAddr, state: SocksState) {
let negotiated = match timeout(state.handshake_timeout, negotiate(&mut stream, &state)).await {
Ok(Ok(negotiated)) => negotiated,
Ok(Err(error)) => {
fail(&mut stream, error, &state.metrics).await;
return;
}
Err(_) => {
fail(
&mut stream,
SocksError::new(None, "handshake_timeout", "SOCKS5 negotiation timed out"),
&state.metrics,
)
.await;
return;
}
};
let request = match timeout(state.handshake_timeout, read_request(&mut stream)).await {
Ok(Ok(request)) => request,
Ok(Err(error)) => {
fail(&mut stream, error, &state.metrics).await;
return;
}
Err(_) => {
fail(
&mut stream,
SocksError::new(
Some(REPLY_TIMEOUT),
"handshake_timeout",
"SOCKS5 request timed out",
),
&state.metrics,
)
.await;
return;
}
};
Span::current().record("server.address", &request.host);
Span::current().record("server.port", request.port);
let outcome = state.acl.authorize_identity(
peer.ip(),
Some(&request.destination),
negotiated.username.as_deref(),
);
state
.metrics
.acl_decision("socks5", outcome.decision.as_str(), outcome.reason);
Span::current().record("acl.decision", outcome.decision.as_str());
Span::current().record("acl.reason", outcome.reason);
if let Some(rule) = &outcome.rule {
Span::current().record("acl.rule", rule);
}
if outcome.decision != Decision::Allow {
Span::current().record("error.type", "acl_denied");
Span::current().record("otel.status_code", "ERROR");
state.metrics.error("acl", "acl_denied");
warn!(
acl.reason = outcome.reason,
acl.rule = outcome.rule.as_deref().unwrap_or_default(),
"ACL denied SOCKS5 connection"
);
if let Err(error) = write_reply(
&mut stream,
REPLY_NOT_ALLOWED,
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)),
)
.await
{
debug!(error.message = %error, "failed to write SOCKS5 ACL denial");
}
return;
}
debug!(
acl.reason = outcome.reason,
acl.rule = outcome.rule.as_deref().unwrap_or_default(),
"ACL allowed SOCKS5 connection"
);
let connect_span = info_span!(
"proxy.upstream_connect",
otel.name = "TCP CONNECT",
otel.kind = "client",
server.address = %request.host,
server.port = request.port,
error.type = field::Empty,
otel.status_code = field::Empty,
);
let connected = async {
match request.target {
ConnectTarget::Socket(address) => {
timeout(state.connect_timeout, TcpStream::connect(address)).await
}
ConnectTarget::Domain(domain) => {
timeout(
state.connect_timeout,
TcpStream::connect((domain.as_str(), request.port)),
)
.await
}
}
}
.instrument(connect_span)
.await;
let mut upstream = match connected {
Ok(Ok(upstream)) => upstream,
Ok(Err(error)) => {
let reply = reply_for_io_error(&error);
fail(
&mut stream,
SocksError::new(
Some(reply),
"upstream_connect",
format!("upstream connection failed: {error}"),
),
&state.metrics,
)
.await;
return;
}
Err(_) => {
fail(
&mut stream,
SocksError::new(
Some(REPLY_TIMEOUT),
"upstream_timeout",
"upstream connection timed out",
),
&state.metrics,
)
.await;
return;
}
};
let bind = upstream
.local_addr()
.unwrap_or_else(|_| SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)));
if let Err(error) = write_reply(&mut stream, REPLY_SUCCESS, bind).await {
debug!(error.message = %error, "failed to write SOCKS5 success reply");
return;
}
tunnel::relay(
&mut stream,
&mut upstream,
&state.metrics,
TunnelProtocol::Socks5,
)
.await;
}
struct Negotiated {
username: Option<String>,
}
async fn negotiate<S>(stream: &mut S, state: &SocksState) -> Result<Negotiated, SocksError>
where
S: AsyncRead + AsyncWrite + Unpin,
{
if read_u8(stream).await? != SOCKS_VERSION {
return Err(SocksError::new(
None,
"invalid_version",
"unsupported SOCKS version",
));
}
let method_count = read_u8(stream).await? as usize;
if method_count == 0 {
return Err(SocksError::new(
None,
"invalid_methods",
"SOCKS5 greeting contains no authentication methods",
));
}
let mut methods = vec![0_u8; method_count];
read_exact(stream, &mut methods).await?;
let required = state.acl.requires_user_password();
let method = if required {
AUTH_USER_PASSWORD
} else {
AUTH_NONE
};
if !methods.contains(&method) {
stream
.write_all(&[SOCKS_VERSION, AUTH_NO_ACCEPTABLE])
.await?;
return Err(SocksError::new(
None,
"no_acceptable_method",
"client offered no acceptable SOCKS5 authentication method",
));
}
stream.write_all(&[SOCKS_VERSION, method]).await?;
Span::current().record(
"socks5.auth_method",
if method == AUTH_USER_PASSWORD {
"username_password"
} else {
"none"
},
);
if method == AUTH_NONE {
return Ok(Negotiated { username: None });
}
if read_u8(stream).await? != AUTH_VERSION {
stream.write_all(&[AUTH_VERSION, 1]).await?;
return Err(SocksError::new(
None,
"invalid_auth_version",
"unsupported username/password authentication version",
));
}
let username_len = read_u8(stream).await? as usize;
if username_len == 0 {
return Err(SocksError::new(
None,
"invalid_credentials",
"SOCKS5 username must not be empty",
));
}
let mut username = vec![0_u8; username_len];
read_exact(stream, &mut username).await?;
let username = String::from_utf8(username).map_err(|_| {
SocksError::new(
None,
"invalid_credentials",
"SOCKS5 username is not valid UTF-8",
)
})?;
let password_len = read_u8(stream).await? as usize;
if password_len == 0 {
return Err(SocksError::new(
None,
"invalid_credentials",
"SOCKS5 password must not be empty",
));
}
let mut password = Zeroizing::new(vec![0_u8; password_len]);
read_exact(stream, password.as_mut_slice()).await?;
if !state.acl.authenticate(&username, password.as_slice()) {
stream.write_all(&[AUTH_VERSION, 1]).await?;
return Err(SocksError::new(
None,
"invalid_credentials",
"SOCKS5 username or password is invalid",
));
}
stream.write_all(&[AUTH_VERSION, 0]).await?;
Ok(Negotiated {
username: Some(username),
})
}
struct SocksRequest {
destination: DestinationHost,
target: ConnectTarget,
host: String,
port: u16,
}
enum ConnectTarget {
Socket(SocketAddr),
Domain(String),
}
async fn read_request<S>(stream: &mut S) -> Result<SocksRequest, SocksError>
where
S: AsyncRead + Unpin,
{
if read_u8(stream).await? != SOCKS_VERSION {
return Err(SocksError::new(
Some(REPLY_GENERAL_FAILURE),
"invalid_version",
"unsupported SOCKS version in CONNECT request",
));
}
if read_u8(stream).await? != COMMAND_CONNECT {
return Err(SocksError::new(
Some(REPLY_COMMAND_UNSUPPORTED),
"unsupported_command",
"only SOCKS5 CONNECT is supported",
));
}
if read_u8(stream).await? != 0 {
return Err(SocksError::new(
Some(REPLY_GENERAL_FAILURE),
"invalid_request",
"SOCKS5 reserved byte must be zero",
));
}
let address_type = read_u8(stream).await?;
let (destination, target, host) = match address_type {
ADDRESS_IPV4 => {
let mut octets = [0_u8; 4];
read_exact(stream, &mut octets).await?;
let ip = IpAddr::from(octets);
let address = SocketAddr::new(ip, 0);
(
DestinationHost::Ip(ip),
ConnectTarget::Socket(address),
ip.to_string(),
)
}
ADDRESS_IPV6 => {
let mut octets = [0_u8; 16];
read_exact(stream, &mut octets).await?;
let ip = IpAddr::from(octets);
let address = SocketAddr::new(ip, 0);
(
DestinationHost::Ip(ip),
ConnectTarget::Socket(address),
ip.to_string(),
)
}
ADDRESS_DOMAIN => {
let length = read_u8(stream).await? as usize;
if length == 0 {
return Err(SocksError::new(
Some(REPLY_GENERAL_FAILURE),
"invalid_destination",
"SOCKS5 domain must not be empty",
));
}
let mut bytes = vec![0_u8; length];
read_exact(stream, &mut bytes).await?;
let domain = String::from_utf8(bytes).map_err(|_| {
SocksError::new(
Some(REPLY_GENERAL_FAILURE),
"invalid_destination",
"SOCKS5 domain is not valid UTF-8",
)
})?;
let destination = DestinationHost::parse(&domain).ok_or_else(|| {
SocksError::new(
Some(REPLY_GENERAL_FAILURE),
"invalid_destination",
"SOCKS5 domain is invalid",
)
})?;
(destination, ConnectTarget::Domain(domain.clone()), domain)
}
_ => {
return Err(SocksError::new(
Some(REPLY_ADDRESS_UNSUPPORTED),
"unsupported_address_type",
"SOCKS5 address type is not supported",
));
}
};
let port = read_u16(stream).await?;
if port == 0 {
return Err(SocksError::new(
Some(REPLY_GENERAL_FAILURE),
"invalid_destination",
"SOCKS5 destination port must not be zero",
));
}
let target = match target {
ConnectTarget::Socket(mut address) => {
address.set_port(port);
ConnectTarget::Socket(address)
}
domain => domain,
};
Ok(SocksRequest {
destination,
target,
host,
port,
})
}
async fn write_reply<S>(stream: &mut S, reply: u8, bind: SocketAddr) -> io::Result<()>
where
S: AsyncWrite + Unpin,
{
Span::current().record("socks5.reply_code", reply);
let mut response = vec![SOCKS_VERSION, reply, 0];
match bind.ip() {
IpAddr::V4(address) => {
response.push(ADDRESS_IPV4);
response.extend_from_slice(&address.octets());
}
IpAddr::V6(address) => {
response.push(ADDRESS_IPV6);
response.extend_from_slice(&address.octets());
}
}
response.extend_from_slice(&bind.port().to_be_bytes());
stream.write_all(&response).await
}
async fn fail<S>(stream: &mut S, error: SocksError, metrics: &Metrics)
where
S: AsyncWrite + Unpin,
{
Span::current().record("error.type", error.error_type);
Span::current().record("otel.status_code", "ERROR");
metrics.error("handshake", error.error_type);
debug!(error.message = %error.message, "SOCKS5 connection failed");
if let Some(reply) = error.reply {
if let Err(error) =
write_reply(stream, reply, SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).await
{
debug!(error.message = %error, "failed to write SOCKS5 error reply");
}
}
}
fn reply_for_io_error(error: &io::Error) -> u8 {
match error.kind() {
io::ErrorKind::ConnectionRefused => REPLY_CONNECTION_REFUSED,
io::ErrorKind::NetworkUnreachable => REPLY_NETWORK_UNREACHABLE,
io::ErrorKind::HostUnreachable
| io::ErrorKind::AddrNotAvailable
| io::ErrorKind::NotFound => REPLY_HOST_UNREACHABLE,
_ => REPLY_GENERAL_FAILURE,
}
}
#[derive(Debug)]
struct SocksError {
reply: Option<u8>,
error_type: &'static str,
message: String,
}
impl SocksError {
fn new(reply: Option<u8>, error_type: &'static str, message: impl Into<String>) -> Self {
Self {
reply,
error_type,
message: message.into(),
}
}
}
impl From<io::Error> for SocksError {
fn from(error: io::Error) -> Self {
Self::new(None, "handshake_io", error.to_string())
}
}
async fn read_u8<S>(stream: &mut S) -> io::Result<u8>
where
S: AsyncRead + Unpin,
{
stream.read_u8().await
}
async fn read_u16<S>(stream: &mut S) -> io::Result<u16>
where
S: AsyncRead + Unpin,
{
stream.read_u16().await
}
async fn read_exact<S>(stream: &mut S, buffer: &mut [u8]) -> io::Result<()>
where
S: AsyncRead + Unpin,
{
stream.read_exact(buffer).await.map(|_| ())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use tokio::task::JoinHandle;
use super::*;
use crate::config::{AclConfig, Action, RuleConfig, UserConfig};
const PASSWORD: &[u8] = b"correct horse battery staple";
fn test_state(acl: Acl) -> SocksState {
SocksState {
acl: Arc::new(acl),
metrics: Metrics::new(opentelemetry::global::meter("hermes-test")),
connect_timeout: Duration::from_secs(2),
handshake_timeout: Duration::from_secs(2),
}
}
fn allow_all_acl() -> Acl {
Acl::from_config(AclConfig {
default_action: Action::Allow,
users: BTreeMap::new(),
rules: vec![],
})
.unwrap()
}
fn basic_acl() -> Acl {
Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::from([(
"alice".to_owned(),
UserConfig {
password_hash: crate::acl::hash_password(PASSWORD).unwrap(),
},
)]),
rules: vec![RuleConfig {
name: "alice".to_owned(),
action: Action::Allow,
users: vec!["alice".to_owned()],
networks: vec![],
domains: vec![],
}],
})
.unwrap()
}
fn localhost_domain_acl() -> Acl {
Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::new(),
rules: vec![RuleConfig {
name: "localhost-domain".to_owned(),
action: Action::Allow,
users: vec![],
networks: vec!["127.0.0.0/8".to_owned()],
domains: vec!["localhost".to_owned()],
}],
})
.unwrap()
}
async fn spawn_socks(acl: Acl) -> (SocketAddr, JoinHandle<Result<(), BoxError>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let task = tokio::spawn(run(listener, test_state(acl)));
(address, task)
}
async fn spawn_echo() -> (SocketAddr, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 4];
stream.read_exact(&mut request).await.unwrap();
assert_eq!(&request, b"ping");
stream.write_all(b"pong").await.unwrap();
stream.shutdown().await.unwrap();
});
(address, task)
}
fn ipv4_request(address: SocketAddr) -> Vec<u8> {
let mut request = vec![SOCKS_VERSION, COMMAND_CONNECT, 0, ADDRESS_IPV4];
let IpAddr::V4(ip) = address.ip() else {
panic!("expected IPv4 address");
};
request.extend_from_slice(&ip.octets());
request.extend_from_slice(&address.port().to_be_bytes());
request
}
fn domain_request(host: &str, port: u16) -> Vec<u8> {
let mut request = vec![
SOCKS_VERSION,
COMMAND_CONNECT,
0,
ADDRESS_DOMAIN,
host.len() as u8,
];
request.extend_from_slice(host.as_bytes());
request.extend_from_slice(&port.to_be_bytes());
request
}
async fn negotiate_without_auth(stream: &mut TcpStream) {
stream
.write_all(&[SOCKS_VERSION, 1, AUTH_NONE])
.await
.unwrap();
let mut response = [0_u8; 2];
stream.read_exact(&mut response).await.unwrap();
assert_eq!(response, [SOCKS_VERSION, AUTH_NONE]);
}
async fn read_reply(stream: &mut TcpStream) -> u8 {
let mut header = [0_u8; 4];
stream.read_exact(&mut header).await.unwrap();
assert_eq!(header[0], SOCKS_VERSION);
assert_eq!(header[2], 0);
match header[3] {
ADDRESS_IPV4 => {
let mut bytes = [0_u8; 4];
stream.read_exact(&mut bytes).await.unwrap();
}
ADDRESS_IPV6 => {
let mut bytes = [0_u8; 16];
stream.read_exact(&mut bytes).await.unwrap();
}
other => panic!("unexpected reply address type {other}"),
}
let mut port = [0_u8; 2];
stream.read_exact(&mut port).await.unwrap();
header[1]
}
async fn assert_ping_pong(stream: &mut TcpStream) {
stream.write_all(b"ping").await.unwrap();
let mut response = [0_u8; 4];
stream.read_exact(&mut response).await.unwrap();
assert_eq!(&response, b"pong");
}
#[tokio::test]
async fn connects_ipv4_without_authentication() {
let (echo_addr, echo_task) = spawn_echo().await;
let (socks_addr, socks_task) = spawn_socks(allow_all_acl()).await;
let mut stream = TcpStream::connect(socks_addr).await.unwrap();
negotiate_without_auth(&mut stream).await;
stream.write_all(&ipv4_request(echo_addr)).await.unwrap();
assert_eq!(read_reply(&mut stream).await, REPLY_SUCCESS);
assert_ping_pong(&mut stream).await;
echo_task.await.unwrap();
socks_task.abort();
}
#[tokio::test]
async fn connects_domain_and_applies_acl() {
let (echo_addr, echo_task) = spawn_echo().await;
let (socks_addr, socks_task) = spawn_socks(localhost_domain_acl()).await;
let mut stream = TcpStream::connect(socks_addr).await.unwrap();
negotiate_without_auth(&mut stream).await;
stream
.write_all(&domain_request("localhost", echo_addr.port()))
.await
.unwrap();
assert_eq!(read_reply(&mut stream).await, REPLY_SUCCESS);
assert_ping_pong(&mut stream).await;
echo_task.await.unwrap();
socks_task.abort();
}
#[tokio::test]
async fn authenticates_username_password() {
let (echo_addr, echo_task) = spawn_echo().await;
let (socks_addr, socks_task) = spawn_socks(basic_acl()).await;
let mut stream = TcpStream::connect(socks_addr).await.unwrap();
stream
.write_all(&[SOCKS_VERSION, 2, AUTH_NONE, AUTH_USER_PASSWORD])
.await
.unwrap();
let mut method = [0_u8; 2];
stream.read_exact(&mut method).await.unwrap();
assert_eq!(method, [SOCKS_VERSION, AUTH_USER_PASSWORD]);
let mut authentication = vec![AUTH_VERSION, 5];
authentication.extend_from_slice(b"alice");
authentication.push(PASSWORD.len() as u8);
authentication.extend_from_slice(PASSWORD);
stream.write_all(&authentication).await.unwrap();
let mut response = [0_u8; 2];
stream.read_exact(&mut response).await.unwrap();
assert_eq!(response, [AUTH_VERSION, 0]);
stream.write_all(&ipv4_request(echo_addr)).await.unwrap();
assert_eq!(read_reply(&mut stream).await, REPLY_SUCCESS);
assert_ping_pong(&mut stream).await;
echo_task.await.unwrap();
socks_task.abort();
}
#[tokio::test]
async fn rejects_invalid_credentials_and_missing_auth_method() {
let (socks_addr, socks_task) = spawn_socks(basic_acl()).await;
let mut no_auth = TcpStream::connect(socks_addr).await.unwrap();
no_auth
.write_all(&[SOCKS_VERSION, 1, AUTH_NONE])
.await
.unwrap();
let mut method = [0_u8; 2];
no_auth.read_exact(&mut method).await.unwrap();
assert_eq!(method, [SOCKS_VERSION, AUTH_NO_ACCEPTABLE]);
let mut wrong_password = TcpStream::connect(socks_addr).await.unwrap();
wrong_password
.write_all(&[SOCKS_VERSION, 1, AUTH_USER_PASSWORD])
.await
.unwrap();
wrong_password.read_exact(&mut method).await.unwrap();
assert_eq!(method, [SOCKS_VERSION, AUTH_USER_PASSWORD]);
let mut authentication = vec![AUTH_VERSION, 5];
authentication.extend_from_slice(b"alice");
authentication.push(5);
authentication.extend_from_slice(b"wrong");
wrong_password.write_all(&authentication).await.unwrap();
let mut response = [0_u8; 2];
wrong_password.read_exact(&mut response).await.unwrap();
assert_eq!(response, [AUTH_VERSION, 1]);
socks_task.abort();
}
#[tokio::test]
async fn denies_acl_with_not_allowed_reply() {
let acl = Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::new(),
rules: vec![],
})
.unwrap();
let (socks_addr, socks_task) = spawn_socks(acl).await;
let mut stream = TcpStream::connect(socks_addr).await.unwrap();
negotiate_without_auth(&mut stream).await;
stream
.write_all(&ipv4_request("192.0.2.1:80".parse().unwrap()))
.await
.unwrap();
assert_eq!(read_reply(&mut stream).await, REPLY_NOT_ALLOWED);
socks_task.abort();
}
#[tokio::test]
async fn reports_unsupported_command_and_address_type() {
let (socks_addr, socks_task) = spawn_socks(allow_all_acl()).await;
let mut bind = TcpStream::connect(socks_addr).await.unwrap();
negotiate_without_auth(&mut bind).await;
bind.write_all(&[SOCKS_VERSION, 2, 0, ADDRESS_IPV4, 127, 0, 0, 1, 0, 80])
.await
.unwrap();
assert_eq!(read_reply(&mut bind).await, REPLY_COMMAND_UNSUPPORTED);
let mut invalid_address = TcpStream::connect(socks_addr).await.unwrap();
negotiate_without_auth(&mut invalid_address).await;
invalid_address
.write_all(&[SOCKS_VERSION, COMMAND_CONNECT, 0, 9])
.await
.unwrap();
assert_eq!(
read_reply(&mut invalid_address).await,
REPLY_ADDRESS_UNSUPPORTED
);
socks_task.abort();
}
#[tokio::test]
async fn parses_fragmented_ipv6_request() {
let (mut client, mut server) = tokio::io::duplex(64);
let writer = tokio::spawn(async move {
let mut request = vec![SOCKS_VERSION, COMMAND_CONNECT, 0, ADDRESS_IPV6];
request.extend_from_slice(&std::net::Ipv6Addr::LOCALHOST.octets());
request.extend_from_slice(&8080_u16.to_be_bytes());
for byte in request {
client.write_all(&[byte]).await.unwrap();
}
});
let request = read_request(&mut server).await.unwrap();
writer.await.unwrap();
assert_eq!(request.host, "::1");
assert_eq!(request.port, 8080);
assert!(matches!(
request.destination,
DestinationHost::Ip(IpAddr::V6(_))
));
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::time::Instant;
use tokio::io::{AsyncRead, AsyncWrite};
use tracing::{Span, error, info};
use crate::metrics::Metrics;
#[derive(Clone, Copy)]
pub(crate) enum TunnelProtocol {
HttpConnect,
Socks5,
}
impl TunnelProtocol {
pub const fn as_str(self) -> &'static str {
match self {
Self::HttpConnect => "http_connect",
Self::Socks5 => "socks5",
}
}
}
pub async fn relay<D, U>(
downstream: &mut D,
upstream: &mut U,
metrics: &Metrics,
protocol: TunnelProtocol,
) where
D: AsyncRead + AsyncWrite + Unpin,
U: AsyncRead + AsyncWrite + Unpin,
{
let started = Instant::now();
let _active = metrics.tunnel_started(protocol.as_str());
match tokio::io::copy_bidirectional(downstream, upstream).await {
Ok((client_to_upstream, upstream_to_client)) => {
Span::current().record(
"tunnel.bytes.client_to_upstream",
i64::try_from(client_to_upstream).unwrap_or(i64::MAX),
);
Span::current().record(
"tunnel.bytes.upstream_to_client",
i64::try_from(upstream_to_client).unwrap_or(i64::MAX),
);
metrics.tunnel_finished(
protocol.as_str(),
started.elapsed(),
client_to_upstream,
upstream_to_client,
None,
);
info!(
proxy.protocol = protocol.as_str(),
tunnel.bytes.client_to_upstream = client_to_upstream,
tunnel.bytes.upstream_to_client = upstream_to_client,
tunnel.duration_ms = started.elapsed().as_millis(),
"TCP tunnel closed"
);
}
Err(error) => {
Span::current().record("error.type", "tunnel_io");
Span::current().record("otel.status_code", "ERROR");
metrics.error("tunnel", "tunnel_io");
metrics.tunnel_finished(
protocol.as_str(),
started.elapsed(),
0,
0,
Some("tunnel_io"),
);
error!(
proxy.protocol = protocol.as_str(),
error.message = %error,
"TCP tunnel failed"
);
}
}
}