WIP project, early stage of development. This project just started from the owner's personal interest and research activity. Not recommended for production use yet.
rpxy-l4 is an L4 reverse proxy supporting both TCP and UDP protocols, which is designed on the same philosophy as rpxy (HTTP reverse proxy). It is written in Rust and aims to provide a high-performance and easy-to-use reverse proxy for layer-4 protocols.
- Basic L4 reverse proxy feature:
rpxy-l4can forward TCP and UDP packets to the backend server. - Protocol multiplexing:
rpxy-l4can multiplex multiple protocols over TCP/UDP on the same port, which meansrpxy-l4routes specific protocols to their corresponding backend servers. Currently, it supports the following protocols:- TCP: HTTP (cleartext), TLS, SSH, Socks5
- UDP: QUIC (IETF QUIC1), WireGuard
- Load balancing:
rpxy-l4can distribute incoming connections to multiple backend servers based on the several simple load balancing algorithms. - Protocol sanitization:
rpxy-l4can sanitize the incoming packets to prevent protocol over TCP/UDP mismatching between the client and the backend server by leveraging the protocol multiplexer feature. (Simply drops packets that do not match the expected protocol by disallowing the default route.) - TLS/QUIC forwarder:
rpxy-l4can forward TLS/IETF QUIC streams to appropriate backend servers based on the ServerName Indication (SNI) and Application Layer Protocol Negotiation (ALPN) values. - HAProxy PROXY protocol support:
rpxy-l4supports outbound PROXY protocol (prepend header to backend connections, global/per-protocol) and inbound PROXY protocol (parse header from trusted upstream proxies) for TCP. (Requires theproxy-protocolCargo feature, enabled by default.) - [Experimental] TLS Encrypted Client Hello (ECH) proxy:
rpxy-l4works as a proxy2 to serve TLS/QUIC streams with Encrypted Client Hello (RFC9849). In other words,rpxy-l4hosts ECH private keys and decrypts the ECH-encrypted Client Hello to route the stream to the appropriate backend server.
You can build an executable binary yourself by checking out this Git repository.
# Cloning the git repository
% git clone https://github.com/junkurihara/rust-rpxy-l4
% cd rust-rpxy-l4
# Build
% cargo build --releaseThen you have an executable binary rust-rpxy-l4/target/release/rpxy-l4.
To build without the PROXY protocol feature:
% cargo build --release --no-default-featuresYou can find the Jenkins CI/CD build scripts for rpxy-l4 in the ./.build directory.
Prebuilt packages
[!WARNING] Prebuilt packages for Linux RPM and DEB are available at https://rpxy.gamerboy59.dev, provided by @Gamerboy59. However, these packages are built and distributed by a third party, and they may lag behind the latest release of
rpxy-l4, sometimes by several versions. Since new releases often include security-related fixes and improvements, please check the packaged version before installation. To always run the latest release, use the prebuilt binaries on the GitHub Releases page or the docker image (see below).
rpxy-l4 always refers to a configuration file in TOML format, e.g., config.toml. You can find an example of the configuration file, config.example.toml, in this repository.
You can run rpxy-l4 with a configuration file like
% ./target/release/rpxy-l4 --config config.tomlrpxy-l4 always tracks the change of config.toml in the real-time manner and apply the change immediately without restarting the process.
Full command line options are as follows:
% ./target/release/rpxy-l4 --help
Usage: rpxy-l4 [OPTIONS] --config <FILE>
Options:
-c, --config <FILE> Configuration file path like ./config.toml
-l, --log-dir <LOG_DIR> Directory for log files. If not specified, logs are printed to stdout.
-h, --help Print help
-V, --version Print versionIf you set --log-dir=<log_dir>, the log files are created in the specified directory. Otherwise, the log is printed to stdout.
${log_dir}/access.logfor access log${log_dir}/rpxy-l4.logfor system and error log
Note
A configuration example can be found at ./config.example.toml. Another toml file, ./config.spec.toml, is a specification of the configuration file including unimplemented features.
The following is an example of the basic configuration for the TCP/UDP reverse proxy scenario.
# Listen port, must be set
listen_port = 8448
# Default targets for TCP connections. [default: empty]
# Format: ["<ip|domain_name>:<port>", "<ip|domain_name>:<port>", ...]
tcp_target = ["192.168.0.2:8000"]
# Default targets for UDP connections. [default: empty]
# Format: ["<ip|domain_name>:<port>", "<ip|domain_name>:<port>", ...]
udp_target = ["192.168.0.3:4000"]The above configuration works as the following manner.
- Forwards TCP packets received on port
8448to the backend server192.168.0.2:8000; - Forwards UDP packets received on port
8448to the backend server192.168.0.3:4000.
Important
For the UDP reverse proxy, rpxy-l4 manages the pseudo connection for each client based on its socket address (IP address + port number) to save the memory usage and preserve the connection state for protocol multiplexing. The pseudo connection is automatically removed after the idle lifetime (default: 30 seconds) since the last packet received from the client. We recommend setting the udp_idle_lifetime value in the configuration file to adjust the idle lifetime according to your use case.
Setting udp_idle_lifetime = 0 disables idle expiry. An unlimited pseudo-connection retains its socket, tasks, and global UDP admission slot, and remains in the existing per-datagram pool scan until service completion or error, replacement, cancellation, configuration reload, or shutdown ends it.
# UDP connection idle lifetime in seconds; 0 disables idle expiry [default: 30]
udp_idle_lifetime = 30rpxy-l4 allows you to distribute incoming TCP/UDP packets to multiple backend servers based on the several simple load balancing algorithms. For the multiple TCP/UDP targets, you can set the load balancing algorithm as follows.
# Listen port, must be set
listen_port = 8448
# Default targets for TCP connections. [default: empty]
# Format: ["<ip|domain_name>:<port>", "<ip|domain_name>:<port>", ...]
tcp_target = ["192.168.0.2:8000", "192.168.0.3:8000"]
# Load balancing method for default targets [default: none]
tcp_load_balance = "source_ip" # source_ip, source_socket, random, or none
# Default targets for UDP connections. [default: empty]
# Format: ["<ip|domain_name>:<port>", "<ip|domain_name>:<port>", ...]
udp_target = ["192.168.0.2:4000", "192.168.0.3:4000"]
# (Optional) Load balancing method for default targets [default: none]
udp_load_balance = "source_socket"Currently, rpxy-l4 supports the following load balancing algorithms:
source_ip: based on source IP hashsource_socket: based on source IP and port hashrandom: random selectionnone: always use the first target [default]
Here are examples/use-cases of the protocol multiplexing scenario over TCP/UDP. For protocol multiplexing, you need to set a [protocols.<service_name>] field in the configuration file as follows.
listen_port = 8448
...
# Set for each multiplexed service
[protocols."http_service"]
...Currently, rpxy-l4 supports the following protocols for multiplexing:
- TCP: HTTP (cleartext), TLS, SSH, Socks5
- UDP: QUIC (IETF RFC9000), WireGuard
rpxy-l4 can detect and multiplex TLS/QUIC streams by probing the TLS ClientHello message and IETF QUIC Initial packet (containing ClientHello). The following example demonstrates the scenario that any TLS/QUIC is forwarded to the appropriate backend that are different from the default targets.
listen_port = 8448
tcp_target = ["192.168.0.2:8000"]
udp_target = ["192.168.0.3:4000"]
# TLS
[protocols."tls_service"]
# Name of protocol tls|ssh|socks5|http|wireguard|quic
protocol = "tls"
# Target for connections detected as TLS.
target = ["192.168.0.5:443"]
# (Optional) Load balancing method specific to this connections [default: none]
load_balance = "source_ip"
#####################
# IETF QUIC
[protocols."quic_service"]
# Name of protocol tls|ssh|socks5|http|wireguard|quic
protocol = "quic"
# Target for connections detected as QUIC.
target = ["192.168.0.6:443"]
# Load balancing method for QUIC connections [default: none]
load_balance = "source_socket"
# Idle lifetime for QUIC connections in seconds; 0 disables idle expiry [default: 30]
idle_lifetime = 30Note
Since IETF-QUIC is a UDP-based protocol, the idle_lifetime field is available for protocol="quic" to adjust the idle lifetime of the pseudo connection only valid for QUIC streams. Setting it to 0 disables idle expiry with the same resource-retention behavior described for udp_idle_lifetime.
Additionally, you can set the tls_alpn and tls_sni fields for the case where protocol="tls" or protocol="quic". These are additional filters for the TLS/QUIC multiplexer to route the stream to the appropriate backend server based on the Application Layer Protocol Negotiation (ALPN) and Server Name Indication (SNI) values. This means that only streams with the specified ALPN and SNI values are forwarded to the target.
[protocols."tls_service"]
protocol = "tls"
target = ["192.168.0.5:443"]
load_balance = "source_ip"
# (Optional) SNI-based routing for TLS/QUIC connections.
# If specified, only TLS/QUIC connections matched to the given SNI(s) are forwarded to the target.
# Format: ["<server_name>", "<server_name>", ...]
server_names = ["example.com", "example.org"]
# (Optional) ALPN-based routing for TLS/QUIC connections.
# If specified, only TLS/QUIC connections matched to the given ALPN(s) are forwarded to the target.
# Format: ["<alpn>", "<alpn>", ...]
alpns = ["h2", "http/1.1"]
Note
If both server_names and alpns are specified, the proxy forwards connections that match simultaneously both of them.
rpxy-l4 can detect and multiplex WireGuard packets by probing the initial handshake packet. The following example demonstrates the scenario that any WireGuard packets are forwarded to the appropriate backend that are different from the default targets as well.
[protocols."wireguard_service"]
protocol = "wireguard"
target = ["192.168.0.10:51820"]
load_balance = "none"
# longer than the keepalive interval of the wireguard tunnel
idle_lifetime = 30Note
As well as QUIC, WireGuard is a UDP-based protocol. The idle_lifetime field is available for protocol="wireguard". Setting it to 0 disables idle expiry with the same resource-retention behavior described for udp_idle_lifetime. Otherwise, you should set it longer than the keep-alive interval.
This is somewhat a security feature to prevent protocol over TCP/UDP mismatching between the client and the backend server. By ignoring the default routes, i.e., removing tcp_target and udp_target on the top level, and set only specific protocol multiplexers, rpxy-l4 simply handles packets matching the expected protocols and drops the others.
rpxy-l4 supports HAProxy PROXY protocol (v1 and v2) for TCP connections. This enables client IP preservation when rpxy-l4 sits in a proxy chain.
Note
This feature requires the proxy-protocol Cargo feature, which is enabled by default. To build without it, use cargo build --release --no-default-features.
Important
The PROXY protocol is only supported for TCP connections, and UDP connections are not supported due to the stateless nature of UDP, meaning that the concept of a "connection" does not exist in the same way as TCP. Typically, for UDP-based protocols, the concept of stream is used the overlaid protocol, e.g., QUIC stream or WireGuard session, and it does not fit well with the connection-oriented design of the PROXY protocol.
Warning
Enabling inbound PROXY protocol may cause service disruption and security risks if not configured properly. Make sure to set tcp_trusted_proxies to only accept PROXY headers from trusted sources, and be aware that all incoming TCP connections are expected to start with a valid PROXY header when tcp_recv_proxy_protocol is enabled.
Prepend a PROXY protocol header to connections toward backend servers, so backends can see the original client IP.
Global setting — applies to all TCP backend connections:
listen_port = 8448
tcp_target = ["192.168.0.2:8000"]
# Prepend PROXY protocol v2 header to all TCP backend connections
tcp_send_proxy_protocol = "v2" # "v1", "v2", or omit/"none" to disablePer-protocol override — each protocol entry can override the global setting:
tcp_send_proxy_protocol = "v2" # global default
[protocols."tls_1"]
protocol = "tls"
target = ["192.168.0.5:443"]
send_proxy_protocol = "v1" # override: use v1 for this protocol
[protocols."http_1"]
protocol = "http"
target = ["192.168.0.6:80"]
send_proxy_protocol = "none" # override: disable for this protocolWhen rpxy-l4 sits behind a load balancer or proxy (e.g., AWS NLB, HAProxy) that sends PROXY protocol, enable inbound parsing to extract the original client IP from the header.
listen_port = 8448
tcp_target = ["192.168.0.2:8000"]
# Expect inbound PROXY header on every TCP connection
tcp_recv_proxy_protocol = true
# Trusted sources allowed to send PROXY headers (required when recv is enabled)
tcp_trusted_proxies = ["10.0.0.0/8", "192.168.0.0/16"]tcp_recv_proxy_protocol = truerequires all incoming TCP connections to start with a PROXY header. Connections without a valid header are rejected.tcp_trusted_proxiesis mandatory when recv is enabled. Connections from untrusted source IPs are rejected. If this field is missing or empty, startup validation fails.- Both v1 and v2 headers are auto-detected — no version configuration is needed.
- LOCAL (v2) / UNKNOWN (v1) commands (e.g., health checks) are accepted without modifying the source address.
When both inbound and outbound are enabled, rpxy-l4 achieves end-to-end client IP preservation through a proxy chain:
Client → [LB with PROXY protocol] → rpxy-l4 (inbound parse → outbound send) → Backend
The inbound parser extracts the original client IP, and the outbound encoder forwards it to the backend. No special configuration is needed beyond enabling both.
See ./examples/README.md for the ECH proxy configuration and client and backend server examples.
The container, docker, image is available at Docker Hub and Github Container Registry.
- Docker Hub: jqtype/rpxy-l4
- Github Container Registry: ghcr.io/junkurihara/rust-rpxy-l4
The detailed configuration of the container can be found at ./docker directory.
rpxy-l4 enables TCP keepalive (using the operating system's default keepalive timing) on both the client-facing and the backend TCP connections. This lets the kernel probe an idle connection and close it if the peer has vanished without a proper FIN/RST (a crash, a network partition, a NAT/firewall silently dropping state), so a dead or half-open connection does not hold its resources indefinitely.
This is a hygiene measure that reclaims only unresponsive peers. It does not defend against a live client that deliberately holds a connection open (such a client answers the keepalive probes and keeps the connection alive); enforce that kind of policy at your network/L4 edge (firewall, load balancer). The detection timing follows the host keepalive policy (e.g. a common Linux default is ~2 hours of idle before the first probe), which you can tune via the operating system if faster reclamation is required.
As mentioned earlier, rpxy-l4 manages pseudo connections for UDP packets from each clients based on the socket address. Also, rpxy-l4 identifies specific protocols by probing their initial/handshake packets. These means that if the idle lifetime of pseudo connections is too short and the client sends packets in a long interval, the pseudo connection would be removed even during the communication. Then, the subsequent packets from the client, i.e., NOT the initial/handshake packets, are routed not to the protocol-specific target but to the default target (or dropped if there is no default target). To avoid this, you should set the idle_lifetime value of UDP-based protocol multiplexer to be longer than the interval of the client's packet sending.
Currently we do not fully implement the function of client facing server described in RFC9849. It works as the following simplified and reduced manner, which is different from the RFC:
- If no matching configuration with the given ECH is found, it just forwards the client hello to the backend server as it is.
rpxy-l4does not support the retry mechanisms of client facing server, i.e., it currently has no state about ECH request, and doesn't handle, forward or emit theHelloRetryRequestmessage to the client.
ECH proxy function is limited only to the TLS protocol, and ECH over QUIC is not supported yet.
TBD!
rpxy-l4 cannot be built without the following projects and inspirations:
sslh:rpxy-l4is strongly inspired bysslhfor its protocol multiplexer feature.tokio: Great async runtime for Rust.RustCrypto: Pure Rust implementations of various cryptographic algorithms, used inrpxy-l4for TLS/QUIC cryptographic operations.
rpxy-l4 is free, open-source software licensed under MIT License.
If you discover a security vulnerability, do not open a public Issue. Please use GitHub's Private vulnerability reporting to notify the maintainers.
Contributions are welcome (issues, feature requests, bug reports, pull requests).
Please note that this project is maintained primarily based on the code owner’s personal interests, and not backed by any commercial agreement. Contributions are handled on a best-effort basis. Sponsorship is also welcome to help sustain the project.
For more details on contribution guidelines and project scope, please see CONTRIBUTING.md.