Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
64212aa
refactor: extract process_setup_result from process_stream
janeblower May 25, 2026
32dfd94
feat: add NetLocation::from_socket_addr
janeblower May 25, 2026
d9cce9f
feat(config): add ServerProxyConfig::Tproxy variant
janeblower May 25, 2026
5b5d4b1
style(config): use uppercase TPROXY in Display to match HTTP/SOCKS/VL…
janeblower May 25, 2026
625e1f5
feat(config): validate tproxy protocol constraints
janeblower May 25, 2026
84a962b
test(config): gate tproxy validation tests to Linux + cover quic_sett…
janeblower May 25, 2026
9bbad22
feat(tproxy): add IP_TRANSPARENT socket factories
janeblower May 25, 2026
fd96714
refactor(tproxy): fix SO_REUSEPORT comment + drop redundant set_nonbl…
janeblower May 25, 2026
f07802d
feat(tproxy): add recvmsg + cmsg parsing for original destination
janeblower May 25, 2026
3d98dfa
fix(tproxy): match IPV6_ORIGDSTADDR cmsg type + detect MSG_CTRUNC
janeblower May 25, 2026
9ad557d
feat(tproxy): add session-based UDP relay with source-spoofing send c…
janeblower May 26, 2026
4695218
fix(tproxy): tolerate per-packet recv errors + race-safe session insert
janeblower May 26, 2026
88ae214
feat(tproxy): wire start_tproxy_servers into the dispatcher
janeblower May 26, 2026
acb0dce
refactor(tproxy): tighten dispatch cfg branches + restore unreachable!
janeblower May 26, 2026
da36251
docs(tproxy): add example yaml and protocol documentation
janeblower May 26, 2026
d66f862
docs(tproxy): use idiomatic client_chain shorthand in example
janeblower May 26, 2026
c480f38
fix(tproxy): align cmsg control buffer + error on empty bind addresses
janeblower May 26, 2026
1911dd8
fix musl
janeblower May 27, 2026
0b9ad84
reduce capability (-CAP_NET_ADMIN +CAP_NET_RAW)
janeblower May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,27 @@ protocol:

NaiveProxy implements HTTP/2 CONNECT with padding for censorship resistance. Should be used within TLS with `alpn_protocols: ["h2"]`.

### TPROXY (transparent proxy, Linux-only)

Accepts traffic redirected to shoes by `iptables/nftables -j TPROXY` + `ip rule`.
The original destination is recovered from the kernel and forwarded through
the outbound proxy chain.

```yaml
protocol:
type: tproxy
tcp_enabled: true # default true
udp_enabled: true # default true
```

Requires:
- **Linux** with `CAP_NET_ADMIN` (or root) on the shoes process.
- `iptables -t mangle -j TPROXY` (or nftables equivalent) and an `ip rule`
+ `ip route` pair pointing the marked traffic at the local loopback.

See `examples/tproxy.yaml` for a complete worked example, including the
required iptables setup.

## TUN Config

TUN (network TUNnel) devices operate at the IP layer (Layer 3), allowing shoes to act as a transparent VPN.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ shoes is a high-performance multi-protocol proxy server written in Rust.
- **TUIC v5**
- **AnyTLS**
- **NaiveProxy**
- **TPROXY** (Linux transparent proxy, TCP + UDP)
- **H2MUX** (supported with VMess, VLESS, Trojan, Shadowsocks, Snell)

### Transport Protocols
Expand Down
65 changes: 65 additions & 0 deletions examples/tproxy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# TPROXY Inbound Example (Linux-only)
#
# TPROXY is a transparent proxy mode: traffic is redirected to shoes by the
# kernel (via iptables/nftables + ip rule), shoes recovers the original
# destination and forwards it through the outbound chain.
#
# ---------------------------------------------------------------------------
# Privileges
# ---------------------------------------------------------------------------
# Setting IP_TRANSPARENT on a socket is a kernel-side privileged operation.
# The kernel accepts EITHER CAP_NET_RAW or CAP_NET_ADMIN — prefer CAP_NET_RAW
# (narrower scope: no right to reconfigure interfaces, routes, netfilter, etc.).
#
# Quick start (development):
# sudo shoes examples/tproxy.yaml
#
# Single-binary capability (no sudo at runtime):
# sudo setcap cap_net_raw=+ep /usr/local/bin/shoes
# shoes examples/tproxy.yaml
#
# systemd (recommended for production — runs as unprivileged user):
# # /etc/systemd/system/shoes.service
# [Unit]
# Description=shoes proxy
# After=network.target
#
# [Service]
# ExecStart=/usr/local/bin/shoes /etc/shoes/tproxy.yaml
# User=shoes
# Group=shoes
# AmbientCapabilities=CAP_NET_RAW
# CapabilityBoundingSet=CAP_NET_RAW
# NoNewPrivileges=true
# Restart=on-failure
#
# [Install]
# WantedBy=multi-user.target
#
# ---------------------------------------------------------------------------
# Kernel-side setup (run once, as root):
# ---------------------------------------------------------------------------
# # Mark and route packets destined for proxied IPs into a local table.
# ip rule add fwmark 1 lookup 100
# ip route add local 0.0.0.0/0 dev lo table 100
#
# # iptables rules: redirect TCP+UDP via TPROXY to port 7895.
# iptables -t mangle -N SHOES
# iptables -t mangle -A SHOES -d 127.0.0.0/8 -j RETURN
# iptables -t mangle -A SHOES -d 10.0.0.0/8 -j RETURN
# iptables -t mangle -A SHOES -d 192.168.0.0/16 -j RETURN
# iptables -t mangle -A SHOES -d 172.16.0.0/12 -j RETURN
# iptables -t mangle -A SHOES -p tcp -j TPROXY --on-port 7895 --tproxy-mark 1
# iptables -t mangle -A SHOES -p udp -j TPROXY --on-port 7895 --tproxy-mark 1
# iptables -t mangle -A PREROUTING -j SHOES
# ---------------------------------------------------------------------------

- address: "0.0.0.0:7895"
protocol:
type: tproxy
tcp_enabled: true
udp_enabled: true
rules:
- masks: "0.0.0.0/0"
action: allow
client_chain: direct
24 changes: 24 additions & 0 deletions src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ impl NetLocation {
Self { address, port }
}

pub fn from_socket_addr(sa: std::net::SocketAddr) -> Self {
let address = match sa.ip() {
std::net::IpAddr::V4(a) => Address::Ipv4(a),
std::net::IpAddr::V6(a) => Address::Ipv6(a),
};
Self { address, port: sa.port() }
}

pub fn components(&self) -> (&Address, u16) {
(&self.address, self.port)
}
Expand Down Expand Up @@ -685,4 +693,20 @@ mod tests {

assert_eq!(net_location_mask.to_string(), deserialized.to_string());
}

#[test]
fn from_socket_addr_ipv4() {
let sa: std::net::SocketAddr = "203.0.113.5:443".parse().unwrap();
let nl = NetLocation::from_socket_addr(sa);
assert_eq!(nl.port(), 443);
assert_eq!(nl.address(), &Address::Ipv4("203.0.113.5".parse().unwrap()));
}

#[test]
fn from_socket_addr_ipv6() {
let sa: std::net::SocketAddr = "[2001:db8::1]:8080".parse().unwrap();
let nl = NetLocation::from_socket_addr(sa);
assert_eq!(nl.port(), 8080);
assert_eq!(nl.address(), &Address::Ipv6("2001:db8::1".parse().unwrap()));
}
}
52 changes: 52 additions & 0 deletions src/config/types/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,15 @@ pub enum ServerProxyConfig {
#[serde(default = "default_true")]
udp_enabled: bool,
},
/// Linux transparent proxy inbound (TPROXY).
/// Accepts TCP/UDP traffic redirected via iptables `-j TPROXY` + `ip rule`.
/// Original destination is recovered from the kernel.
Tproxy {
#[serde(default = "default_true")]
tcp_enabled: bool,
#[serde(default = "default_true")]
udp_enabled: bool,
},
}

impl std::fmt::Display for ServerProxyConfig {
Expand Down Expand Up @@ -827,6 +836,7 @@ impl std::fmt::Display for ServerProxyConfig {
Self::Mixed { .. } => write!(f, "Mixed (HTTP+SOCKS5)"),
Self::Anytls { .. } => write!(f, "AnyTLS"),
Self::Naiveproxy { .. } => write!(f, "NaiveProxy"),
Self::Tproxy { .. } => write!(f, "TPROXY"),
}
}
}
Expand Down Expand Up @@ -1604,4 +1614,46 @@ client_chain:
let deserialized: ShadowTlsRemoteHandshake = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(deserialized.client_chain.len(), original.client_chain.len());
}

#[test]
fn tproxy_default_yaml_roundtrip() {
let yaml = r#"
address: "0.0.0.0:7895"
protocol:
type: tproxy
"#;
let cfg: ServerConfig = serde_yaml::from_str(yaml).unwrap();
match cfg.protocol {
ServerProxyConfig::Tproxy { tcp_enabled, udp_enabled } => {
assert!(tcp_enabled);
assert!(udp_enabled);
}
_ => panic!("expected Tproxy variant"),
}
}

#[test]
fn tproxy_explicit_flags_yaml_roundtrip() {
let yaml = r#"
address: "0.0.0.0:7895"
protocol:
type: tproxy
tcp_enabled: true
udp_enabled: false
"#;
let cfg: ServerConfig = serde_yaml::from_str(yaml).unwrap();
match cfg.protocol {
ServerProxyConfig::Tproxy { tcp_enabled, udp_enabled } => {
assert!(tcp_enabled);
assert!(!udp_enabled);
}
_ => panic!("expected Tproxy variant"),
}
}

#[test]
fn tproxy_display() {
let p = ServerProxyConfig::Tproxy { tcp_enabled: true, udp_enabled: true };
assert_eq!(format!("{p}"), "TPROXY");
}
}
142 changes: 142 additions & 0 deletions src/config/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,45 @@ fn validate_server_config(
rule_groups: &HashMap<String, Vec<RuleConfig>>,
named_pems: &HashMap<String, String>,
) -> std::io::Result<()> {
// Tproxy-specific validation (fail fast before other checks)
if let ServerProxyConfig::Tproxy { tcp_enabled, udp_enabled } = &server_config.protocol {
#[cfg(not(target_os = "linux"))]
{
let _ = (tcp_enabled, udp_enabled); // silence unused warning on non-linux
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tproxy protocol is only supported on Linux",
));
}
#[cfg(target_os = "linux")]
{
if server_config.transport != Transport::Tcp {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tproxy protocol does not support quic transport",
));
}
if matches!(server_config.bind_location, super::types::BindLocation::Path(_)) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tproxy protocol cannot bind to a unix socket path",
));
}
if server_config.quic_settings.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tproxy protocol does not accept quic_settings",
));
}
if !*tcp_enabled && !*udp_enabled {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tproxy requires at least one of tcp_enabled or udp_enabled",
));
}
}
}

// First handle QUIC settings certificates
if let Some(ref mut quic_settings) = server_config.quic_settings {
embed_pem_from_map(&mut quic_settings.cert, named_pems);
Expand Down Expand Up @@ -2654,4 +2693,107 @@ mod tests {
err
);
}

fn tproxy_err(configs: Vec<Config>) -> String {
match create_server_configs(configs) {
Ok(_) => panic!("expected validation error but got Ok"),
Err(e) => e.to_string(),
}
}

#[cfg(target_os = "linux")]
#[test]
fn tproxy_rejects_quic_transport() {
let yaml = r#"
- address: "0.0.0.0:7895"
transport: quic
protocol:
type: tproxy
"#;
let configs: Vec<Config> = serde_yaml::from_str(yaml).unwrap();
let err = tproxy_err(configs);
assert!(err.to_lowercase().contains("tproxy"), "got: {err}");
assert!(
err.to_lowercase().contains("quic") || err.to_lowercase().contains("transport"),
"got: {err}"
);
}

#[cfg(target_os = "linux")]
#[test]
fn tproxy_rejects_unix_socket_bind() {
let yaml = r#"
- path: "/tmp/foo.sock"
protocol:
type: tproxy
"#;
let configs: Vec<Config> = serde_yaml::from_str(yaml).unwrap();
let err = tproxy_err(configs);
assert!(err.to_lowercase().contains("tproxy"), "got: {err}");
assert!(
err.to_lowercase().contains("unix") || err.to_lowercase().contains("path"),
"got: {err}"
);
}

#[cfg(target_os = "linux")]
#[test]
fn tproxy_rejects_both_disabled() {
let yaml = r#"
- address: "0.0.0.0:7895"
protocol:
type: tproxy
tcp_enabled: false
udp_enabled: false
"#;
let configs: Vec<Config> = serde_yaml::from_str(yaml).unwrap();
let err = tproxy_err(configs);
assert!(
err.to_lowercase().contains("tcp_enabled") || err.to_lowercase().contains("udp_enabled"),
"got: {err}"
);
}

#[cfg(target_os = "linux")]
#[test]
fn tproxy_rejects_quic_settings() {
let yaml = r#"
- address: "0.0.0.0:7895"
quic_settings:
cert: "/tmp/does-not-exist.crt"
key: "/tmp/does-not-exist.key"
protocol:
type: tproxy
"#;
let configs: Vec<Config> = serde_yaml::from_str(yaml).unwrap();
let err = tproxy_err(configs);
assert!(err.to_lowercase().contains("tproxy"), "got: {err}");
assert!(err.to_lowercase().contains("quic_settings"), "got: {err}");
}

#[cfg(not(target_os = "linux"))]
#[test]
fn tproxy_rejects_on_non_linux() {
let yaml = r#"
- address: "0.0.0.0:7895"
protocol:
type: tproxy
"#;
let configs: Vec<Config> = serde_yaml::from_str(yaml).unwrap();
let err = tproxy_err(configs);
assert!(err.to_lowercase().contains("linux"), "got: {err}");
}

#[cfg(target_os = "linux")]
#[test]
fn tproxy_minimal_config_accepted() {
crate::thread_util::set_num_threads(1);
let yaml = r#"
- address: "0.0.0.0:7895"
protocol:
type: tproxy
"#;
let configs: Vec<Config> = serde_yaml::from_str(yaml).unwrap();
let _validated = create_server_configs(configs).expect("should validate");
}
}
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ pub mod logging;
#[cfg(unix)]
pub mod tun;

/// Linux transparent proxy (TPROXY) inbound.
#[cfg(target_os = "linux")]
pub mod tproxy;

/// FFI bindings for mobile platforms.
#[cfg(any(target_os = "android", target_os = "ios", feature = "ffi"))]
pub mod ffi;
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ mod trojan_handler;
mod tuic_server;
#[cfg(unix)]
mod tun;
#[cfg(target_os = "linux")]
mod tproxy;
mod udp_message_stream;
mod uot;
mod util;
Expand Down
Loading