├── .build ├── .gitignore ├── DEB │ ├── prerm │ ├── control │ ├── postrm │ └── postinst ├── rpxy-l4.service ├── RPM │ └── rpxy-l4.spec ├── rpxy-l4-start.sh ├── config.toml └── Jenkinsfile ├── .github ├── FUNDING.yml ├── workflows │ ├── ci.yml │ ├── shift_left.yml │ ├── release.yml │ └── docker.yml └── dependabot.yml ├── .dockerignore ├── proxy-l4-lib ├── src │ ├── trace.rs │ ├── time_util.rs │ ├── access_log.rs │ ├── socket.rs │ ├── proto.rs │ ├── constants.rs │ ├── count.rs │ ├── lib.rs │ ├── error.rs │ ├── target.rs │ ├── probe.rs │ └── udp_conn.rs └── Cargo.toml ├── .rustfmt.toml ├── proxy-l4 ├── src │ ├── config │ │ ├── mod.rs │ │ ├── service.rs │ │ ├── parse.rs │ │ └── toml.rs │ ├── log.rs │ └── main.rs └── Cargo.toml ├── examples ├── server.key ├── server.crt ├── Cargo.toml ├── README.md └── src │ └── bin │ └── ech-client.rs ├── docker ├── run.sh ├── docker-compose.yml ├── Dockerfile └── entrypoint.sh ├── Cargo.toml ├── .gitignore ├── quic-tls ├── Cargo.toml └── src │ ├── lib.rs │ ├── error.rs │ ├── serialize.rs │ ├── ech_extension.rs │ └── tls.rs ├── LICENSE ├── config.example.toml ├── config.spec.toml └── README.md /.build/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: junkurihara 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .vscode/ 3 | .private/ 4 | .github/ 5 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/trace.rs: -------------------------------------------------------------------------------- 1 | pub use tracing::{debug, error, info, trace, warn}; 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | newline_style = "Unix" 3 | tab_spaces = 2 4 | max_width = 130 5 | -------------------------------------------------------------------------------- /proxy-l4/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod parse; 2 | mod service; 3 | mod toml; 4 | 5 | pub(crate) use self::{parse::parse_opts, service::ConfigTomlReloader, toml::ConfigToml}; 6 | -------------------------------------------------------------------------------- /.build/DEB/prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ -d /run/systemd/system ] && [ "$1" = remove ]; then 5 | deb-systemd-invoke stop rpxy-l4.service >/dev/null || true 6 | fi 7 | 8 | exit 0 9 | -------------------------------------------------------------------------------- /examples/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTvrAY0ugVIH0iGcc 3 | mfrPuUqfjScbNu1Fker4s9fqP5ahRANCAATTNOf2pG57vfjcieRWF77iIWVjijO0 4 | c6Hl5eS6V/zH9XZzf4zXiyY3EFMfmdlLO3z58zNGXZ5cSYjz8hT8VxIR 5 | -----END PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/time_util.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | /// Get the current time since the epoch in seconds. 4 | #[inline] 5 | pub(crate) fn get_since_the_epoch() -> u64 { 6 | SystemTime::now() 7 | .duration_since(UNIX_EPOCH) 8 | .expect("Time went backwards!!! Check system time.") 9 | .as_secs() 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [synchronize, opened] 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | test: 13 | permissions: 14 | contents: read 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v6 20 | with: 21 | submodules: recursive 22 | - name: Run unit tests 23 | run: | 24 | cargo test --verbose 25 | -------------------------------------------------------------------------------- /.build/DEB/control: -------------------------------------------------------------------------------- 1 | Package: rpxy-l4 2 | Version: @BUILD_VERSION@-1 3 | Maintainer: Jun Kurihara 4 | Homepage: https://github.com/junkurihara/rust-rpxy-l4 5 | Architecture: amd64 6 | Depends: systemd 7 | Recommends: rpxy-webui 8 | Priority: optional 9 | Section: base 10 | Description: A reverse proxy for layer-4 (TCP+UDP) with protocol multiplexer, written in Rust 11 | rpxy-l4 is a Layer 4 reverse proxy supporting TCP and UDP protocols 12 | with protocol multiplexing for high-performance traffic forwarding. 13 | -------------------------------------------------------------------------------- /.github/workflows/shift_left.yml: -------------------------------------------------------------------------------- 1 | name: ShiftLeft Scan 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [synchronize, opened] 7 | 8 | jobs: 9 | Scan-Build: 10 | permissions: 11 | contents: read 12 | 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | 17 | - name: Perform ShiftLeft Scan 18 | uses: ShiftLeftSecurity/scan-action@master 19 | env: 20 | WORKSPACE: "" 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | output: reports 24 | -------------------------------------------------------------------------------- /examples/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBUjCB+aADAgECAgkAo5xh3mJm1a0wCgYIKoZIzj0EAwIwITEfMB0GA1UEAwwW 3 | cmNnZW4gc2VsZiBzaWduZWQgY2VydDAgFw03NTAxMDEwMDAwMDBaGA80MDk2MDEw 4 | MTAwMDAwMFowITEfMB0GA1UEAwwWcmNnZW4gc2VsZiBzaWduZWQgY2VydDBZMBMG 5 | ByqGSM49AgEGCCqGSM49AwEHA0IABNM05/akbnu9+NyJ5FYXvuIhZWOKM7RzoeXl 6 | 5LpX/Mf1dnN/jNeLJjcQUx+Z2Us7fPnzM0ZdnlxJiPPyFPxXEhGjGDAWMBQGA1Ud 7 | EQQNMAuCCWxvY2FsaG9zdDAKBggqhkjOPQQDAgNIADBFAiA/J4cy7Y3wUt2Z9wf7 8 | JhrkXva8qtZzOl3cD593zYTrcwIhALF8lvfJ0s4LUeogW40QEn5ldr4VyDNlIutf 9 | qcavbbRO 10 | -----END CERTIFICATE----- 11 | -------------------------------------------------------------------------------- /docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | CONFIG_FILE=/etc/rpxy-l4.toml 3 | LOG_DIR=/rpxy-l4/log 4 | LOGGING=${LOG_TO_FILE:-false} 5 | 6 | # debug level logging 7 | if [ -z $LOG_LEVEL ]; then 8 | LOG_LEVEL=info 9 | fi 10 | echo "rpxy-l4: Logging with level ${LOG_LEVEL}" 11 | 12 | if "${LOGGING}"; then 13 | echo "rpxy-l4: Start with writing log files" 14 | RUST_LOG=${LOG_LEVEL} /rpxy-l4/bin/rpxy-l4 --config ${CONFIG_FILE} --log-dir ${LOG_DIR} 15 | else 16 | echo "rpxy-4: Start without writing log files" 17 | RUST_LOG=${LOG_LEVEL} /rpxy-l4/bin/rpxy-l4 --config ${CONFIG_FILE} 18 | fi 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for cargo 7 | - package-ecosystem: "cargo" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | 12 | # Enable version updates for Docker 13 | - package-ecosystem: "docker" 14 | directory: "/docker" 15 | schedule: 16 | interval: "daily" 17 | 18 | # Enable version updates for GitHub Actions 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: "daily" 23 | -------------------------------------------------------------------------------- /.build/rpxy-l4.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=rpxy-l4 system service 3 | Documentation=https://github.com/junkurihara/rust-rpxy-l4 4 | After=network.target 5 | Wants=network-online.target 6 | 7 | [Service] 8 | Type=simple 9 | ExecStart=/usr/bin/rpxy-l4-start.sh 10 | Restart=on-failure 11 | RestartSec=5 12 | User=rpxy-l4 13 | Group=rpxy-l4 14 | AmbientCapabilities=CAP_NET_BIND_SERVICE 15 | NoNewPrivileges=true 16 | PrivateTmp=true 17 | ProtectSystem=full 18 | ProtectHome=true 19 | RuntimeDirectory=rpxy-l4 20 | RuntimeDirectoryMode=0750 21 | LogsDirectory=rpxy-l4 22 | LogsDirectoryMode=0750 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace.package] 2 | version = "0.2.1" 3 | authors = ["Jun Kurihara"] 4 | homepage = "https://github.com/junkurihara/rust-rpxy-l4" 5 | repository = "https://github.com/junkurihara/rust-rpxy-l4" 6 | license = "MIT" 7 | readme = "./README.md" 8 | edition = "2024" 9 | categories = ["asynchronous", "network-programming", "command-line-utilities"] 10 | publish = false 11 | 12 | [workspace] 13 | members = ["proxy-l4-lib", "proxy-l4", "quic-tls", "examples"] 14 | # exclude = ["submodules"] 15 | resolver = "2" 16 | 17 | [profile.release] 18 | codegen-units = 1 19 | incremental = false 20 | lto = "fat" 21 | opt-level = 3 22 | panic = "abort" 23 | strip = true 24 | -------------------------------------------------------------------------------- /.build/DEB/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "$1" = "purge" ]; then 5 | # Remove the rpxy-l4 user 6 | if getent passwd rpxy-l4 >/dev/null; then 7 | deluser --quiet --system rpxy-l4 >/dev/null || true 8 | fi 9 | 10 | # Remove config directory 11 | rm -rf /etc/rpxy-l4 12 | 13 | # Note: Log directory is intentionally NOT removed to preserve logs 14 | # Administrators can manually remove /var/log/rpxy-l4 if desired 15 | 16 | # Remove systemd service state 17 | deb-systemd-helper purge rpxy-l4.service >/dev/null || true 18 | deb-systemd-helper unmask rpxy-l4.service >/dev/null || true 19 | fi 20 | 21 | if [ -d /run/systemd/system ]; then 22 | systemctl --system daemon-reload >/dev/null || true 23 | fi 24 | 25 | exit 0 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # RustRover 13 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 14 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 15 | # and can be added to the global gitignore or merged into this file. For a more nuclear 16 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 17 | .idea/ 18 | 19 | # Added by cargo 20 | /target 21 | 22 | .vscode 23 | .private 24 | .log 25 | log 26 | .claude 27 | .tmp 28 | -------------------------------------------------------------------------------- /proxy-l4/src/config/service.rs: -------------------------------------------------------------------------------- 1 | use super::toml::ConfigToml; 2 | use async_trait::async_trait; 3 | use hot_reload::{Reload, ReloaderError}; 4 | 5 | #[derive(Clone)] 6 | pub struct ConfigTomlReloader { 7 | pub config_path: String, 8 | } 9 | 10 | #[async_trait] 11 | impl Reload for ConfigTomlReloader { 12 | type Source = String; 13 | async fn new(source: &Self::Source) -> Result> { 14 | Ok(Self { 15 | config_path: source.clone(), 16 | }) 17 | } 18 | 19 | async fn reload(&self) -> Result, ReloaderError> { 20 | let conf = ConfigToml::new(&self.config_path).map_err(|e| ReloaderError::::Reload(e.to_string()))?; 21 | Ok(Some(conf)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /quic-tls/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rpxy-l4-quic-tls" 3 | description = "Library to probe TLS and QUIC initial packets" 4 | version.workspace = true 5 | edition.workspace = true 6 | readme.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | categories.workspace = true 10 | publish.workspace = true 11 | 12 | [dependencies] 13 | # traces and logs 14 | tracing = { version = "0.1.44" } 15 | 16 | # error handling 17 | anyhow = { version = "1.0.100" } 18 | thiserror = { version = "2.0.17" } 19 | 20 | # quic parser 21 | hkdf = { version = "0.12.4" } 22 | sha2 = { version = "0.10.9" } 23 | aes = { version = "0.8.4" } 24 | aes-gcm = { version = "0.10.3" } 25 | hex-literal = { version = "1.1.0" } 26 | 27 | # ech 28 | bytes = { version = "1.11.0" } 29 | rand = { version = "0.9.2" } 30 | hpke = { version = "0.13.0" } 31 | base64 = { version = "0.22.1" } 32 | 33 | [dev-dependencies] 34 | rustls = { version = "0.23.35" } 35 | -------------------------------------------------------------------------------- /examples/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rpxy-l4-examples" 3 | description = "examples and utils for rpxy-l4" 4 | version.workspace = true 5 | edition.workspace = true 6 | readme.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | categories.workspace = true 10 | publish = false 11 | 12 | [dependencies] 13 | # rustls = { version = "0.23.25", features = ["logging"] } 14 | # rustls = { path = "../../rustls/rustls/", features = ["logging"] } 15 | rustls = { git = "https://github.com/junkurihara/rustls.git", package = "rustls", branch = "ech-split-backend", features = [ 16 | "logging", 17 | ] } 18 | clap = { version = "4", features = ["derive"] } 19 | # hickory-resolver = { version = "0.25.1", features = [ 20 | # "webpki-roots", 21 | # "https-aws-lc-rs", 22 | # ] } 23 | log = { version = "0.4.29" } 24 | env_logger = { version = "0.11" } 25 | webpki-roots = { version = "1.0" } 26 | base64 = { version = "0.22.1" } 27 | tokio = { version = "1.48.0" } 28 | mio = { version = "1", features = ["net", "os-poll"] } 29 | -------------------------------------------------------------------------------- /quic-tls/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod client_hello; 2 | mod ech; 3 | mod ech_config; 4 | mod ech_extension; 5 | mod error; 6 | mod quic; 7 | mod serialize; 8 | mod tls; 9 | 10 | #[allow(unused)] 11 | pub(crate) mod trace { 12 | pub(crate) use tracing::{debug, error, info, trace, warn}; 13 | } 14 | /// TLS 1.0, TLS 1.1 and TLS 1.2 for ClientHello.legacy_version and TLSPlaintext.legacy_record_version. 15 | /// Note that TLS 1.3 (0x304) is indicated in `supported_versions` extension, 16 | /// then 0x303 is given to ClientHello.legacy_version and TLSPlaintext.legacy_record_version 17 | pub(crate) const SUPPORTED_TLS_VERSIONS: [u16; 3] = [0x0301, 0x0302, 0x303]; 18 | 19 | pub use client_hello::TlsClientHello; 20 | pub mod extension { 21 | pub use crate::client_hello::{ApplicationLayerProtocolNegotiation, OtherTlsClientHelloExtension, ServerNameIndication}; 22 | } 23 | pub use ech_config::{EchConfigError, EchConfigList, EchPrivateKey}; 24 | pub use error::{TlsClientHelloError, TlsProbeFailure}; 25 | pub use quic::probe_quic_initial_packets; 26 | pub use tls::{TlsAlertBuffer, TlsAlertDescription, TlsAlertLevel, TlsClientHelloBuffer, probe_tls_handshake}; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jun Kurihara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rpxy-l4: 3 | image: jqtype/rpxy-l4:latest 4 | container_name: rpxy-l4 5 | init: true 6 | restart: unless-stopped 7 | ports: 8 | - 127.0.0.1:8448:8448 9 | - 127.0.0.1:8448:8448/udp 10 | build: # Uncomment if you build yourself 11 | context: ../ 12 | additional_contexts: 13 | - messense/rust-musl-cross:amd64-musl=docker-image://messense/rust-musl-cross:x86_64-musl 14 | - messense/rust-musl-cross:arm64-musl=docker-image://messense/rust-musl-cross:aarch64-musl 15 | dockerfile: ./docker/Dockerfile 16 | platforms: # Choose your platforms 17 | - "linux/amd64" 18 | # - "linux/arm64" 19 | environment: 20 | - LOG_LEVEL=debug 21 | - LOG_TO_FILE=true 22 | - HOST_USER=jun 23 | - HOST_UID=501 24 | - HOST_GID=501 25 | tty: false 26 | volumes: 27 | - ./log:/rpxy-l4/log:rw 28 | - ../config.example.toml:/etc/rpxy-l4.toml:ro 29 | # NOTE: To correctly enable "watch" in docker, 30 | # ** you should mount not a file but a dir mapped to /rpxy-l4/config including "config.toml" due to the limitation of docker ** 31 | # e.g, - ./rpxy-config:/rpxy-l4/config 32 | -------------------------------------------------------------------------------- /proxy-l4-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rpxy-l4-lib" 3 | description = "Core library of L4 reverse proxy written in Rust" 4 | version.workspace = true 5 | edition.workspace = true 6 | readme.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | categories.workspace = true 10 | publish.workspace = true 11 | 12 | [dependencies] 13 | derive_builder = { version = "0.20.2" } 14 | bytes = { version = "1.11.0" } 15 | 16 | # backend selector 17 | rand = { version = "0.9.2" } 18 | 19 | # Connection pool 20 | dashmap = { version = "6.1.0" } 21 | ahash = { version = "0.8.12" } 22 | 23 | # error handling 24 | thiserror = { version = "2.0.17" } 25 | 26 | # traces and logs 27 | tracing = { version = "0.1.44" } 28 | 29 | # async runtime 30 | tokio = { version = "1.48.0", default-features = false, features = [ 31 | "net", 32 | "rt-multi-thread", 33 | "time", 34 | "sync", 35 | "macros", 36 | "io-util", 37 | ] } 38 | tokio-util = { version = "0.7.17", default-features = false } 39 | 40 | # network 41 | socket2 = { version = "0.6.1", features = ["all"] } 42 | hickory-resolver = { version = "0.25.2" } 43 | 44 | # quic and tls 45 | quic-tls = { package = "rpxy-l4-quic-tls", path = "../quic-tls/" } 46 | 47 | [dev-dependencies] 48 | tracing-subscriber = { version = "0.3.22" } 49 | # quic and tls 50 | -------------------------------------------------------------------------------- /proxy-l4/src/config/parse.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use clap::Arg; 3 | 4 | /// Parsed options 5 | pub struct Opts { 6 | /// Configuration file path 7 | pub config_file_path: String, 8 | pub log_dir_path: Option, 9 | } 10 | 11 | /// Parse arg values passed from cli 12 | pub fn parse_opts() -> Result { 13 | let _ = include_str!("../../Cargo.toml"); 14 | let options = clap::command!() 15 | .arg( 16 | Arg::new("config_file") 17 | .long("config") 18 | .short('c') 19 | .value_name("FILE") 20 | .required(true) 21 | .help("Configuration file path like ./config.toml"), 22 | ) 23 | .arg( 24 | Arg::new("log_dir") 25 | .long("log-dir") 26 | .short('l') 27 | .value_name("LOG_DIR") 28 | .help("Directory for log files. If not specified, logs are printed to stdout."), 29 | ); 30 | let matches = options.get_matches(); 31 | 32 | /////////////////////////////////// 33 | let config_file_path = matches 34 | .get_one::("config_file") 35 | .ok_or_else(|| anyhow!("config_file is required"))? 36 | .to_owned(); 37 | let log_dir_path = matches.get_one::("log_dir").map(|v| v.to_owned()); 38 | 39 | Ok(Opts { 40 | config_file_path, 41 | log_dir_path, 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /proxy-l4/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rpxy-l4" 3 | description = "L4 reverse proxy written in Rust" 4 | version.workspace = true 5 | edition.workspace = true 6 | readme.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | categories.workspace = true 10 | publish.workspace = true 11 | 12 | [dependencies] 13 | rpxy-l4-lib = { path = "../proxy-l4-lib" } 14 | 15 | # error handling 16 | anyhow = { version = "1.0.100" } 17 | 18 | # logging 19 | tracing = { version = "0.1.44" } 20 | tracing-subscriber = { version = "0.3.22" } 21 | 22 | # config 23 | clap = { version = "4.5.53", features = ["std", "cargo", "wrap_help"] } 24 | toml = { version = "0.9.10", default-features = false, features = [ 25 | "parse", 26 | "serde", 27 | ] } 28 | hot_reload = { version = "0.3.5" } 29 | serde = { version = "1.0.228", default-features = false, features = [ 30 | "derive", 31 | "std", 32 | ] } 33 | serde_ignored = { version = "0.1.14" } 34 | async-trait = { version = "0.1.89" } 35 | 36 | # memory allocator 37 | mimalloc = { version = "0.1.48", default-features = false } 38 | 39 | # async runtime 40 | tokio = { version = "1.48.0", default-features = false, features = [ 41 | "net", 42 | "rt-multi-thread", 43 | "time", 44 | "sync", 45 | "macros", 46 | "io-util", 47 | ] } 48 | tokio-util = { version = "0.7.17", default-features = false } 49 | futures = { version = "0.3.31", default-features = false, features = ["alloc"] } 50 | -------------------------------------------------------------------------------- /.build/DEB/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Source debconf library 5 | . /usr/share/debconf/confmodule 6 | 7 | # Create rpxy-l4 user if it doesn't exist 8 | if ! getent passwd rpxy-l4 > /dev/null; then 9 | adduser --system --group --no-create-home --shell /usr/sbin/nologin rpxy-l4 10 | fi 11 | 12 | # Set correct ownership for config directory 13 | if [ -d /etc/rpxy-l4 ]; then 14 | chown -R rpxy-l4:rpxy-l4 /etc/rpxy-l4 15 | fi 16 | 17 | # Create log directory with correct ownership 18 | mkdir -p /var/log/rpxy-l4 19 | chown rpxy-l4:rpxy-l4 /var/log/rpxy-l4 20 | chmod 750 /var/log/rpxy-l4 21 | 22 | # Reload systemd, enable and start the service 23 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 24 | deb-systemd-helper unmask rpxy-l4.service >/dev/null || true 25 | if deb-systemd-helper --quiet was-enabled rpxy-l4.service; then 26 | deb-systemd-helper enable rpxy-l4.service >/dev/null || true 27 | else 28 | deb-systemd-helper update-state rpxy-l4.service >/dev/null || true 29 | fi 30 | if [ -d /run/systemd/system ]; then 31 | systemctl --system daemon-reload >/dev/null || true 32 | if [ -n "$2" ]; then 33 | deb-systemd-invoke try-restart rpxy-l4.service >/dev/null || true 34 | else 35 | deb-systemd-invoke start rpxy-l4.service >/dev/null || true 36 | fi 37 | fi 38 | fi 39 | 40 | exit 0 41 | -------------------------------------------------------------------------------- /quic-tls/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::{ech_config::EchConfigError, serialize::SerDeserError}; 2 | 3 | /// Probe result 4 | pub enum TlsProbeFailure { 5 | /// Not enough buffer to probe 6 | PollNext, 7 | /// Failed to probe 8 | Failure, 9 | } 10 | 11 | /// Error for serializing and deserializing TLS ClientHello 12 | #[derive(Debug, thiserror::Error)] 13 | pub enum TlsClientHelloError { 14 | #[error("Invalid TLS ClientHello")] 15 | InvalidTlsClientHello, 16 | 17 | #[error("Invalid Extension length")] 18 | InvalidExtensionLength, 19 | #[error("Invalid SNI Extension")] 20 | InvalidSniExtension, 21 | #[error("Invalid ALPN Extension")] 22 | InvalidAlpnExtension, 23 | #[error("Invalid ECH Extension")] 24 | InvalidEchExtension, 25 | #[error("Invalid OuterExtensions Extension")] 26 | InvalidOuterExtensionsExtension, 27 | #[error("Unsupported Hpke Kdf, or Aead")] 28 | UnsupportedHpkeKdfAead, 29 | #[error("Hpke error")] 30 | HpkeError(hpke::HpkeError), 31 | #[error("ECH config public_name mismatched with SNI in client hello outer")] 32 | PublicNameMismatch, 33 | #[error("Invalid Ech ClientHello Inner recomposition attempt")] 34 | InvalidClientHelloRecomposition, 35 | #[error("No SNI in decrypted ClientHello")] 36 | NoSniInDecryptedClientHello, 37 | 38 | #[error("Error in serialization/deserialization")] 39 | SerDeserError(#[from] SerDeserError), 40 | #[error("Error in EchConfig")] 41 | EchConfigError(#[from] EchConfigError), 42 | } 43 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/access_log.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | proto::{TcpProtocolType, UdpProtocolType}, 3 | trace::info, 4 | }; 5 | use std::net::SocketAddr; 6 | 7 | /// Protocol type for access log 8 | pub(crate) enum AccessLogProtocolType { 9 | /// TCP 10 | Tcp(TcpProtocolType), 11 | /// UDP 12 | Udp(UdpProtocolType), 13 | } 14 | impl std::fmt::Display for AccessLogProtocolType { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | match self { 17 | AccessLogProtocolType::Tcp(t) => match t { 18 | TcpProtocolType::Any => write!(f, "tcp"), 19 | other => write!(f, "tcp:{}", other), 20 | }, 21 | AccessLogProtocolType::Udp(u) => match u { 22 | UdpProtocolType::Any => write!(f, "udp"), 23 | other => write!(f, "udp:{}", other), 24 | }, 25 | } 26 | } 27 | } 28 | 29 | /// Handle log for probed protocol, source and destination sockets, when establishing a connection 30 | pub(crate) fn access_log_start(proto: &AccessLogProtocolType, src_addr: &SocketAddr, dst_addr: &SocketAddr) { 31 | info!(name: crate::constants::log_event_names::ACCESS_LOG_START, "[start] {}: {:?} -> {:?}", proto, src_addr, dst_addr); 32 | } 33 | /// Handle log for probed protocol, source and destination sockets, when closing a connection 34 | pub(crate) fn access_log_finish(proto: &AccessLogProtocolType, src_addr: &SocketAddr, dst_addr: &SocketAddr) { 35 | info!(name: crate::constants::log_event_names::ACCESS_LOG_FINISH, "[finish] {}: {:?} -> {:?}", proto, src_addr, dst_addr); 36 | } 37 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/socket.rs: -------------------------------------------------------------------------------- 1 | use socket2::{Domain, Protocol, Socket, Type}; 2 | use std::net::{SocketAddr, UdpSocket}; 3 | use tokio::net::TcpSocket; 4 | 5 | /// Bind TCP socket to the given `SocketAddr`, and returns the TCP socket with `SO_REUSEADDR` and `SO_REUSEPORT` options. 6 | /// This option is required to re-bind the socket address when the proxy instance is reconstructed. 7 | pub(super) fn bind_tcp_socket(listening_on: &SocketAddr) -> Result { 8 | let tcp_socket = if listening_on.is_ipv6() { 9 | TcpSocket::new_v6() 10 | } else { 11 | TcpSocket::new_v4() 12 | }?; 13 | tcp_socket.set_reuseaddr(true)?; 14 | 15 | #[cfg(not(target_os = "windows"))] 16 | tcp_socket.set_reuseport(true)?; 17 | 18 | tcp_socket.bind(*listening_on)?; 19 | Ok(tcp_socket) 20 | } 21 | 22 | /// Bind UDP socket to the given `SocketAddr`, and returns the UDP socket with `SO_REUSEADDR` and `SO_REUSEPORT` options. 23 | /// This option is required to re-bind the socket address when the proxy instance is reconstructed. 24 | pub(super) fn bind_udp_socket(listening_on: &SocketAddr) -> Result { 25 | let socket = if listening_on.is_ipv6() { 26 | Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP)) 27 | } else { 28 | Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) 29 | }?; 30 | socket.set_reuse_address(true)?; 31 | 32 | #[cfg(not(target_os = "windows"))] 33 | socket.set_reuse_port(true)?; 34 | 35 | socket.set_nonblocking(true)?; // This is important to use `recv_from` in the UDP listener 36 | 37 | socket.bind(&(*listening_on).into())?; 38 | Ok(socket.into()) 39 | } 40 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ######################################## 2 | FROM --platform=$BUILDPLATFORM messense/rust-musl-cross:${TARGETARCH}-musl AS builder 3 | 4 | LABEL maintainer="Jun Kurihara" 5 | 6 | ARG TARGETARCH 7 | 8 | RUN if [ $TARGETARCH = "amd64" ]; then \ 9 | echo "x86_64" > /arch; \ 10 | elif [ $TARGETARCH = "arm64" ]; then \ 11 | echo "aarch64" > /arch; \ 12 | else \ 13 | echo "Unsupported platform: $TARGETARCH"; \ 14 | exit 1; \ 15 | fi 16 | 17 | ENV CFLAGS=-Ofast 18 | 19 | WORKDIR /tmp 20 | 21 | COPY . /tmp/ 22 | 23 | ENV RUSTFLAGS="-C link-arg=-s" 24 | 25 | RUN echo "Building rpxy-l4 from source" && \ 26 | cargo update && \ 27 | cargo build --release --package rpxy-l4 --target $(cat /arch)-unknown-linux-musl && \ 28 | musl-strip --strip-all /tmp/target/$(cat /arch)-unknown-linux-musl/release/rpxy-l4 && \ 29 | cp /tmp/target/$(cat /arch)-unknown-linux-musl/release/rpxy-l4 /tmp/target/release/rpxy-l4 30 | 31 | ######################################## 32 | FROM alpine:latest AS runner 33 | LABEL maintainer="Jun Kurihara" 34 | 35 | ENV RUNTIME_DEPS="logrotate ca-certificates su-exec" 36 | 37 | RUN apk add --no-cache ${RUNTIME_DEPS} && \ 38 | update-ca-certificates && \ 39 | find / -type d -path /proc -prune -o -type f -perm /u+s -exec chmod u-s {} \; && \ 40 | find / -type d -path /proc -prune -o -type f -perm /g+s -exec chmod g-s {} \; && \ 41 | mkdir -p /rpxy-l4/bin &&\ 42 | mkdir -p /rpxy-l4/log &&\ 43 | mkdir -p /rpxy-l4/config 44 | 45 | COPY --from=builder /tmp/target/release/rpxy-l4 /rpxy-l4/bin/rpxy-l4 46 | COPY ./docker/run.sh /rpxy-l4 47 | COPY ./docker/entrypoint.sh /rpxy-l4 48 | 49 | RUN chmod +x /rpxy-l4/run.sh && \ 50 | chmod +x /rpxy-l4/entrypoint.sh 51 | 52 | ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt 53 | ENV SSL_CERT_DIR=/etc/ssl/certs 54 | 55 | EXPOSE 80 443 56 | 57 | CMD ["/rpxy-l4/entrypoint.sh"] 58 | 59 | ENTRYPOINT ["/rpxy-l4/entrypoint.sh"] 60 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/proto.rs: -------------------------------------------------------------------------------- 1 | use crate::error::ProxyBuildError; 2 | 3 | /// L5--L7 Protocol specific types 4 | #[derive(Debug, Clone, PartialEq, Eq)] 5 | pub enum ProtocolType { 6 | /// TCP: cleartext HTTP 7 | Http, 8 | /// TCP: TLS 9 | Tls, 10 | /// TCP: SSH 11 | Ssh, 12 | /// Udp: WireGuard 13 | Wireguard, 14 | /// Udp: QUIC 15 | Quic, 16 | } 17 | 18 | impl TryFrom<&str> for ProtocolType { 19 | type Error = ProxyBuildError; 20 | fn try_from(value: &str) -> Result { 21 | match value { 22 | "http" => Ok(ProtocolType::Http), 23 | "tls" => Ok(ProtocolType::Tls), 24 | "ssh" => Ok(ProtocolType::Ssh), 25 | "wireguard" => Ok(ProtocolType::Wireguard), 26 | "quic" => Ok(ProtocolType::Quic), 27 | _ => Err(ProxyBuildError::UnsupportedProtocol(value.to_string())), 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 33 | /// TCP protocol types 34 | pub(crate) enum TcpProtocolType { 35 | /// TCP: cleartext HTTP 36 | Http, 37 | /// TCP: TLS 38 | Tls, 39 | /// TCP: SSH 40 | Ssh, 41 | /// TCP: Any 42 | Any, 43 | } 44 | impl std::fmt::Display for TcpProtocolType { 45 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 46 | match self { 47 | TcpProtocolType::Http => write!(f, "http"), 48 | TcpProtocolType::Tls => write!(f, "tls"), 49 | TcpProtocolType::Ssh => write!(f, "ssh"), 50 | TcpProtocolType::Any => write!(f, "any"), 51 | } 52 | } 53 | } 54 | 55 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 56 | /// TCP protocol types 57 | pub(crate) enum UdpProtocolType { 58 | /// UDP: WireGuard 59 | Wireguard, 60 | /// UDP: QUIC 61 | Quic, 62 | /// UDP: Any 63 | Any, 64 | } 65 | impl std::fmt::Display for UdpProtocolType { 66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 | match self { 68 | UdpProtocolType::Wireguard => write!(f, "wireguard"), 69 | UdpProtocolType::Quic => write!(f, "quic"), 70 | UdpProtocolType::Any => write!(f, "any"), 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples for the backend server and client of Encrypted Client Hello 2 | 3 | ## Start backend server 4 | 5 | ```bash 6 | cargo run --package rpxy-l4-examples --bin tlsserver-mio -- --certs ./examples/server.crt --key ./examples/server.key --verbose http 7 | ``` 8 | 9 | This simply hosts a TLS server working as the backend server in the context of [ECH Split Mode](https://www.ietf.org/archive/id/draft-ietf-tls-esni-24.html#section-3). 10 | 11 | > [!NOTE] 12 | > The above server certificate and key are self-signed for the common name `localhost`. This means that you will get an error as `untrusted certificate` or `unknown CA` when you try to connect to it without `--cafile server.crt` option in the client. 13 | 14 | ## Start client 15 | 16 | ```bash 17 | cargo run --package rpxy-l4-examples --bin ech-client -- --host localhost --cafile ./examples/server.crt localhost localhost 18 | ``` 19 | 20 | This will connect to the backend server by cloaking the target hostname `localhost:443` (the backend server) with the public hostname `localhost:8448` (the client facing server, i.e., `rpxy-l4`), and tries to send HTTP request with host header `localhost`. 21 | 22 | ## Start rxpy-l4 with ECH configuration 23 | 24 | Before you run the above command, make sure that you have started the backend server and `rxpy-l4` with the following ECH configuration: 25 | 26 | ```toml 27 | listen_port = 8448 28 | 29 | [protocols."tls_ech"] 30 | protocol = "tls" 31 | target = ["1.1.1.1:53"] # default target to which the packet is sent when ECH decryption fails (no matching ECH config) 32 | load_balance = "source_ip" 33 | server_names = ["localhost"] 34 | 35 | # Static ECH configuration embedded in the client source code 36 | [protocols."tls_ech".ech] 37 | ech_config_list = "ADz+DQA4ugAgACA9U8FCH7vKOFXVCCcAdpUUSfu3rzlooRNflhOXyV0uTwAEAAEAAQAJbG9jYWxob3N0AAA" 38 | private_keys = ["KwyvZOuPlflYlcmJbwhA24HMWUvxXXyan/oh9cJ6lNw"] 39 | # Acceptable private server names obtained from decrypted ECH Client Hello Inner, where port is optional. 40 | # When port is not specified, it is the same as the listen port (i.e., forward as it is). 41 | private_server_names = ["localhost:443"] 42 | ``` 43 | -------------------------------------------------------------------------------- /quic-tls/src/serialize.rs: -------------------------------------------------------------------------------- 1 | use bytes::{Buf, BufMut, Bytes, BytesMut}; 2 | 3 | // Define distinct deserialize/serialize error for objects 4 | #[derive(Debug, thiserror::Error)] 5 | pub enum SerDeserError { 6 | #[error("Short input")] 7 | ShortInput, 8 | #[error("Invalid input length")] 9 | InvalidInputLength, 10 | #[error("Invalid input")] 11 | InvalidInput, 12 | } 13 | 14 | /* ------------------------------------------- */ 15 | // Imported from odoh-rs crate 16 | 17 | /// Serialize to IETF wireformat that is similar to [XDR](https://tools.ietf.org/html/rfc1014) 18 | pub(crate) trait Serialize { 19 | type Error; 20 | /// Serialize the provided struct into the buf. 21 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error>; 22 | } 23 | 24 | /// Deserialize from IETF wireformat that is similar to [XDR](https://tools.ietf.org/html/rfc1014) 25 | pub(crate) trait Deserialize { 26 | type Error; 27 | /// Deserialize a struct from the buf. 28 | fn deserialize(buf: &mut B) -> Result 29 | where 30 | Self: Sized; 31 | } 32 | 33 | /// Convenient function to deserialize a structure from Bytes. 34 | pub(super) fn parse(buf: &mut B) -> Result { 35 | D::deserialize(buf) 36 | } 37 | 38 | #[allow(unused)] 39 | /// Convenient function to serialize a structure into a new BytesMut. 40 | pub(super) fn compose(s: S) -> Result { 41 | let mut buf = BytesMut::new(); 42 | s.serialize(&mut buf)?; 43 | Ok(buf) 44 | } 45 | 46 | /// Reads a length-prefixed value from the buffer, where the length is defined as `len_prefix` bytes 47 | pub(super) fn read_lengthed(b: &mut B, len_prefix: usize) -> Result { 48 | if b.remaining() < len_prefix { 49 | return Err(SerDeserError::ShortInput); 50 | } 51 | // byte length of usize::MAX 52 | let max_len_prefix = std::mem::size_of::(); 53 | if len_prefix > max_len_prefix { 54 | return Err(SerDeserError::InvalidInputLength); 55 | } 56 | 57 | let mut len = 0; 58 | for _ in 0..len_prefix { 59 | len <<= 8; 60 | len += b.get_u8() as usize; 61 | } 62 | 63 | if len > b.remaining() { 64 | return Err(SerDeserError::InvalidInputLength); 65 | } 66 | 67 | Ok(b.copy_to_bytes(len)) 68 | } 69 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/constants.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | /// TCP backlog size 4 | pub const TCP_BACKLOG: u32 = 1024; 5 | 6 | /// TCP timeout to read first few bytes in milliseconds 7 | pub const TCP_PROTOCOL_DETECTION_TIMEOUT_MSEC: u64 = 100; 8 | 9 | /// TCP buffer size for protocol detection 10 | /// The maximum size of the TLS record is 64KB = 2^14 bytes. 11 | /// But considering the hybrid post-quantum key exchange (key_share extension is > 1KB in X25519MLKEM768), 12 | /// the buffer size should be large, at least 2KB, to parse the Client Hello message. 13 | /// https://datatracker.ietf.org/doc/html/rfc8446#section-5.1 14 | pub const TCP_PROTOCOL_DETECTION_BUFFER_SIZE: usize = 16384; 15 | 16 | /// UDP buffer size, theoretical limit is 65535 bytes in IPv4 17 | /// But the practical limit is, due to the MTU, less than 1500 bytes. 18 | pub const UDP_BUFFER_SIZE: usize = 65536; 19 | 20 | /// UDP channel Capacity TODO: めちゃ適当 21 | pub const UDP_CHANNEL_CAPACITY: usize = 1024; 22 | 23 | /// Max TCP concurrent connections in total of all spawned TCP proxies 24 | pub const MAX_TCP_CONCURRENT_CONNECTIONS: usize = 1024; 25 | 26 | /// Max UDP concurrent connections in total of all spawned UDP proxies 27 | /// For UDP, the connection remains until the lifetime expires. 28 | /// This means that even a short communication, e.g., DNS, does not immediately release the connection. 29 | pub const MAX_UDP_CONCURRENT_CONNECTIONS: usize = 2048; 30 | 31 | /// Default UDP connection lifetime in seconds, can be configured for each protocol 32 | /// UDP connection is managed by the source address + port. 33 | /// If the connection is not used for this duration, it is pruned. 34 | pub const UDP_CONNECTION_IDLE_LIFETIME: u32 = 30; 35 | 36 | /// Periodic interval to prune inactive UDP connections 37 | pub const UDP_CONNECTION_PRUNE_INTERVAL: u64 = 10; 38 | 39 | /// UDP initial buffer packet lifetime in seconds 40 | pub const UDP_INITIAL_BUFFER_LIFETIME: u64 = 1; 41 | 42 | /// Logging event name TODO: Other separated logs? 43 | pub mod log_event_names { 44 | /// access log 45 | pub const ACCESS_LOG_START: &str = "rpxy-l4::conn::start"; 46 | pub const ACCESS_LOG_FINISH: &str = "rpxy-l4::conn::finish"; 47 | } 48 | 49 | /// DNS cache minimum TTL 50 | /// Default: 30 seconds 51 | pub const DNS_CACHE_MIN_TTL: Duration = Duration::from_secs(30); 52 | /// DNS cache maximum TTL 53 | /// Default: 1 hour 54 | pub const DNS_CACHE_MAX_TTL: Duration = Duration::from_secs(3600); 55 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | # Example configuration file for rpxy-L4 2 | 3 | # Global settings 4 | listen_port = 8448 5 | listen_ipv6 = true 6 | tcp_backlog = 1024 7 | tcp_max_connections = 1024 8 | udp_max_connections = 2048 9 | 10 | # DNS cache configuration 11 | dns_cache_min_ttl = "30s" # Minimum DNS cache TTL 12 | dns_cache_max_ttl = "1h" # Maximum DNS cache TTL 13 | 14 | # Default TCP target using domain names and IPs 15 | tcp_target = [ 16 | "one.one.one.one:53", # Cloudflare DNS (resolved via DNS) 17 | "1.1.1.1:53", # Direct IP 18 | ] 19 | tcp_load_balance = "source_ip" 20 | 21 | # Default UDP target using domain names 22 | udp_target = [ 23 | "dns.google:53", # Google DNS (resolved via DNS) 24 | "8.8.8.8:53", # Direct IP 25 | ] 26 | udp_load_balance = "source_ip" 27 | udp_idle_lifetime = 30 28 | 29 | [protocols] 30 | 31 | # HTTP 32 | [protocols."http_1"] 33 | # check with `curl -H "Host: 1.1.1.1" http://localhost:8448 -v` 34 | protocol = "http" 35 | target = ["1.1.1.1:80"] 36 | load_balance = "none" 37 | 38 | # DNS over TLS with domain name resolution 39 | [protocols."tls_1"] 40 | # check with `dig t.co @localhost -p 8448 +tls` 41 | protocol = "tls" 42 | target = [ 43 | "dot.cloudflare.com:853", # Will be resolved via DNS 44 | "1.1.1.1:853", # Direct IP as fallback 45 | ] 46 | load_balance = "source_ip" 47 | alpn = ["dot"] 48 | 49 | # HTTP/2 over TLS with mixed targets 50 | [protocols."tls_2"] 51 | # check with `curl https://localhost:8448 -v --http2 --insecure` 52 | protocol = "tls" 53 | target = [ 54 | "api.internal:4433", # Will be resolved via DNS 55 | "backend.local:4433", # Will be resolved via DNS 56 | "127.0.0.1:4433", # Direct IP as fallback 57 | ] 58 | load_balance = "source_ip" 59 | server_names = ["localhost"] 60 | alpn = ["h2"] 61 | 62 | # HTTP/3 over QUIC with domain targets 63 | [protocols."quic_1"] 64 | # check with `curl https://localhost:8448 -v --http3-only --insecure` 65 | protocol = "quic" 66 | target = [ 67 | "h3.example.com:4433", # Will be resolved via DNS 68 | "127.0.0.1:4433", # Direct IP as fallback 69 | ] 70 | load_balance = "source_ip" 71 | idle_lifetime = 30 72 | alpn = ["h3"] 73 | server_names = ["localhost"] 74 | 75 | # WireGuard with domain target 76 | [protocols."wireguard_1"] 77 | protocol = "wireguard" 78 | target = [ 79 | "wg.example.com:51820", # Will be resolved via DNS 80 | "192.168.1.2:51820", # Direct IP as fallback 81 | ] 82 | load_balance = "source_ip" # Consistent hashing for domain targets 83 | idle_lifetime = 30 # Longer than the keepalive interval 84 | -------------------------------------------------------------------------------- /.build/RPM/rpxy-l4.spec: -------------------------------------------------------------------------------- 1 | Name: rpxy-l4 2 | Version: @BUILD_VERSION@ 3 | Release: 1%{?dist} 4 | Summary: A simple and ultrafast Layer 4 reverse-proxy serving multiple domain names, written in Rust 5 | 6 | License: MIT 7 | URL: https://github.com/junkurihara/rust-rpxy-l4 8 | Source0: @Source0@ 9 | BuildArch: x86_64 10 | 11 | Requires: systemd 12 | 13 | %description 14 | This rpm installs rpxy-l4 into /usr/bin and sets up a systemd service. 15 | rpxy-l4 is a Layer 4 reverse proxy supporting both TCP and UDP protocols 16 | with protocol multiplexing capabilities for high-performance traffic forwarding. 17 | 18 | # Prep section: Unpack the source 19 | %prep 20 | %autosetup 21 | 22 | # Install section: Copy files to their destinations 23 | %install 24 | rm -rf %{buildroot} 25 | 26 | # Create necessary directories 27 | mkdir -p %{buildroot}%{_bindir} 28 | mkdir -p %{buildroot}%{_sysconfdir}/systemd/system 29 | mkdir -p %{buildroot}%{_sysconfdir}/rpxy-l4 30 | mkdir -p %{buildroot}%{_docdir}/rpxy-l4 31 | 32 | # Copy files 33 | cp rpxy-l4 %{buildroot}%{_bindir}/ 34 | cp rpxy-l4-start.sh %{buildroot}%{_bindir}/ 35 | cp rpxy-l4.service %{buildroot}%{_sysconfdir}/systemd/system/ 36 | cp config.toml %{buildroot}%{_sysconfdir}/rpxy-l4/ 37 | cp LICENSE README.md %{buildroot}%{_docdir}/rpxy-l4/ 38 | 39 | # Clean section: Remove buildroot 40 | %clean 41 | rm -rf %{buildroot} 42 | 43 | # Pre-install script 44 | %pre 45 | # Create the rpxy-l4 user if it does not exist 46 | if ! getent passwd rpxy-l4 >/dev/null; then 47 | useradd -r -s /sbin/nologin -d / -c "rpxy-l4 system user" rpxy-l4 48 | fi 49 | 50 | # Post-install script 51 | %post 52 | # Set ownership of config file to rpxy-l4 user 53 | chown -R rpxy-l4:rpxy-l4 %{_sysconfdir}/rpxy-l4 54 | 55 | # Reload systemd, enable and start rpxy-l4 service 56 | %systemd_post rpxy-l4.service 57 | 58 | # Pre-uninstall script 59 | %preun 60 | %systemd_preun rpxy-l4.service 61 | 62 | # Post-uninstall script 63 | %postun 64 | %systemd_postun_with_restart rpxy-l4.service 65 | 66 | # Only remove user and config on full uninstall 67 | if [ $1 -eq 0 ]; then 68 | # Remove rpxy-l4 user 69 | userdel rpxy-l4 70 | 71 | # Remove the configuration directory if it exists 72 | [ -d %{_sysconfdir}/rpxy-l4 ] && rm -rf %{_sysconfdir}/rpxy-l4 73 | fi 74 | 75 | # Files section: List all files included in the package 76 | %files 77 | %license %{_docdir}/rpxy-l4/LICENSE 78 | %doc %{_docdir}/rpxy-l4/README.md 79 | %{_sysconfdir}/systemd/system/rpxy-l4.service 80 | %attr(755, rpxy-l4, rpxy-l4) %{_bindir}/rpxy-l4 81 | %attr(755, rpxy-l4, rpxy-l4) %{_bindir}/rpxy-l4-start.sh 82 | %attr(644, rpxy-l4, rpxy-l4) %config(noreplace) %{_sysconfdir}/rpxy-l4/config.toml 83 | -------------------------------------------------------------------------------- /.build/rpxy-l4-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | CACHE_DIR="/tmp/rpxy-l4/.cache" 6 | CONFIG_DIR="/etc/rpxy-l4" 7 | CONFIG_FILE="$CONFIG_DIR/config.toml" 8 | WEBUI_CONFIG="/var/www/rpxy-webui/storage/app/config.toml" 9 | COMMENT_MARKER="# IMPORTANT: DEACTIVATED This config is deactivated because rpxy-webui is installed" 10 | LOG_DIR="/var/log/rpxy-l4" 11 | 12 | setup_directories() { 13 | # Check if systemd is available 14 | if [ -d /run/systemd/system ]; then 15 | # Use systemd RuntimeDirectory if available 16 | if [ -d /run/rpxy-l4 ]; then 17 | RUNTIME_DIR="/run/rpxy-l4" 18 | # If not available use PrivateTmp 19 | elif [ -d /tmp/systemd-private-*/tmp ]; then 20 | RUNTIME_DIR=$(find /tmp/systemd-private-*/tmp -type d -name "rpxy-l4" 2>/dev/null | head -n 1) 21 | fi 22 | 23 | # Create subdirectory for cache 24 | CACHE_DIR="$RUNTIME_DIR/.cache" 25 | # Ensure the cache directory exists as it could get deleted on system restart 26 | mkdir -p "$CACHE_DIR" 27 | chown rpxy-l4:rpxy-l4 "$CACHE_DIR" # not recursively because parent folder is managed by systemd 28 | chmod 700 "$CACHE_DIR" 29 | else 30 | # Fallback to linux tmp directory if no systemd is found 31 | RUNTIME_DIR="/tmp/rpxy-l4" 32 | CACHE_DIR="$RUNTIME_DIR/.cache" 33 | # Ensure the cache directory exists as it could get deleted on system restart 34 | mkdir -p "$CACHE_DIR" 35 | chown -R rpxy-l4:rpxy-l4 "$RUNTIME_DIR" 36 | chmod 700 "$CACHE_DIR" 37 | fi 38 | 39 | echo "Using runtime directory: $RUNTIME_DIR" 40 | echo "Using cache directory: $CACHE_DIR" 41 | echo "Using log directory: $LOG_DIR" 42 | } 43 | 44 | # Check if rpxy-webui is installed 45 | is_package_installed() { 46 | if command -v rpm >/dev/null 2>&1; then 47 | rpm -q "$1" >/dev/null 2>&1 48 | elif command -v dpkg-query >/dev/null 2>&1; then 49 | dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "install ok installed" 50 | else 51 | echo "Neither rpm nor dpkg-query found. Cannot verify installation status of rpxy-webui package." >&2 52 | return 1 53 | fi 54 | } 55 | 56 | # Create the config file if it doesn't exist 57 | ensure_config_exists() { 58 | mkdir -p "$CONFIG_DIR" 59 | [ -f "$CONFIG_FILE" ] || echo "# Standard rpxy Konfigurationsdatei" > "$CONFIG_FILE" 60 | } 61 | 62 | add_comment_to_config() { 63 | if ! grep -q "^$COMMENT_MARKER" "$CONFIG_FILE"; then 64 | sed -i "1i$COMMENT_MARKER\n" "$CONFIG_FILE" 65 | fi 66 | } 67 | 68 | remove_comment_from_config() { 69 | sed -i "/^$COMMENT_MARKER/d" "$CONFIG_FILE" 70 | } 71 | 72 | main() { 73 | setup_directories 74 | ensure_config_exists 75 | 76 | if is_package_installed rpxy-webui; then 77 | echo "rpxy-webui is installed. Starting rpxy-l4 with rpxy-webui" 78 | add_comment_to_config 79 | exec /usr/bin/rpxy-l4 -c "$WEBUI_CONFIG" -l "$LOG_DIR" 80 | else 81 | echo "rpxy-webui is not installed. Starting with default config" 82 | remove_comment_from_config 83 | exec /usr/bin/rpxy-l4 -c "$CONFIG_FILE" -l "$LOG_DIR" 84 | fi 85 | } 86 | 87 | main "$@" 88 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/count.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{ 2 | Arc, 3 | atomic::{AtomicUsize, Ordering}, 4 | }; 5 | 6 | /// DashMap type alias, uses ahash::RandomState as hashbuilder 7 | type DashMap = dashmap::DashMap; 8 | 9 | #[derive(Debug, Clone, Default)] 10 | /// Counter for serving connections 11 | pub struct ConnectionCount(Arc); 12 | 13 | impl ConnectionCount { 14 | pub(crate) fn current(&self) -> usize { 15 | self.0.load(Ordering::Relaxed) 16 | } 17 | 18 | pub(crate) fn increment(&self) -> usize { 19 | self.0.fetch_add(1, Ordering::Relaxed) 20 | } 21 | 22 | pub(crate) fn decrement(&self) -> usize { 23 | let mut count; 24 | while { 25 | count = self.0.load(Ordering::Relaxed); 26 | count > 0 27 | && self 28 | .0 29 | .compare_exchange(count, count - 1, Ordering::Relaxed, Ordering::Relaxed) 30 | != Ok(count) 31 | } {} 32 | count 33 | } 34 | } 35 | 36 | #[derive(Debug, Clone)] 37 | /// Counter for serving connections that must be counted as the sum of integer values given from multiple threads 38 | pub struct ConnectionCountSum(Arc>) 39 | where 40 | T: Eq + std::hash::Hash; 41 | 42 | impl ConnectionCountSum 43 | where 44 | T: Eq + std::hash::Hash, 45 | { 46 | pub(crate) fn current(&self) -> usize { 47 | self.0.iter().map(|v| *v.value()).sum() 48 | } 49 | /// Set or update the value for the key, returning the previous value for the key 50 | pub(crate) fn set(&self, key: T, value: usize) -> usize { 51 | self.0.insert(key, value).unwrap_or(0) 52 | } 53 | } 54 | 55 | impl Default for ConnectionCountSum 56 | where 57 | T: Eq + std::hash::Hash, 58 | { 59 | fn default() -> Self { 60 | Self(Arc::new(DashMap::default())) 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | 68 | #[test] 69 | fn test_connection_count_basic() { 70 | let count = ConnectionCount::default(); 71 | 72 | assert_eq!(count.current(), 0); 73 | 74 | count.increment(); 75 | assert_eq!(count.current(), 1); 76 | 77 | count.increment(); 78 | assert_eq!(count.current(), 2); 79 | 80 | count.decrement(); 81 | assert_eq!(count.current(), 1); 82 | } 83 | 84 | #[test] 85 | fn test_connection_count_multiple_operations() { 86 | let count = ConnectionCount::default(); 87 | 88 | // Simulate multiple connections over time 89 | for _ in 0..5 { 90 | count.increment(); 91 | count.decrement(); 92 | } 93 | 94 | assert_eq!(count.current(), 0); 95 | } 96 | 97 | #[test] 98 | fn test_connection_count_sum_basic() { 99 | let count = ConnectionCountSum::<&str>::default(); 100 | 101 | assert_eq!(count.current(), 0); 102 | 103 | count.set("addr1", 3); 104 | assert_eq!(count.current(), 3); 105 | 106 | count.set("addr2", 2); 107 | assert_eq!(count.current(), 5); 108 | 109 | // Reducing connections 110 | count.set("addr1", 1); 111 | assert_eq!(count.current(), 3); 112 | } 113 | 114 | #[test] 115 | fn test_connection_count_sum_operations() { 116 | let count = ConnectionCountSum::<&str>::default(); 117 | 118 | // Test setting and updating values 119 | let old = count.set("addr1", 5); 120 | assert_eq!(old, 0); 121 | assert_eq!(count.current(), 5); 122 | 123 | let old = count.set("addr1", 8); 124 | assert_eq!(old, 5); 125 | assert_eq!(count.current(), 8); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_run: 4 | workflows: 5 | - "Build and Push Docker Image" 6 | types: 7 | - "completed" 8 | branches: 9 | - main 10 | - develop 11 | repository_dispatch: 12 | types: 13 | - release-event 14 | 15 | jobs: 16 | on-success: 17 | permissions: 18 | contents: read 19 | packages: read 20 | 21 | runs-on: ubuntu-latest 22 | if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' }} || ${{ github.event_name == 'repositry_dispatch' }} 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | include: 27 | - target: "musl" 28 | platform: linux/amd64 29 | 30 | - target: "musl" 31 | platform: linux/arm64 32 | 33 | steps: 34 | - run: "echo 'The release triggering workflows passed'" 35 | 36 | - name: "set env" 37 | id: "set-env" 38 | run: | 39 | if [ ${{ matrix.platform }} == 'linux/amd64' ]; then PLATFORM_MAP="x86_64"; else PLATFORM_MAP="aarch64"; fi 40 | if [ ${{ github.ref_name }} == 'main' ]; then BUILD_IMG="latest"; else BUILD_IMG="nightly"; fi 41 | echo "build_img=${BUILD_IMG}" >> $GITHUB_OUTPUT 42 | echo "target_name=rpxy-l4-${PLATFORM_MAP}-unknown-linux-${{ matrix.target }}" >> $GITHUB_OUTPUT 43 | 44 | - name: "docker pull and extract binary from docker image" 45 | id: "extract-binary" 46 | run: | 47 | CONTAINER_ID=`docker create --platform=${{ matrix.platform }} ghcr.io/junkurihara/rust-rpxy-l4:${{ steps.set-env.outputs.build_img }}` 48 | docker cp ${CONTAINER_ID}:/rpxy-l4/bin/rpxy-l4 /tmp/${{ steps.set-env.outputs.target_name }} 49 | 50 | - name: "upload artifacts" 51 | uses: actions/upload-artifact@v6 52 | with: 53 | name: ${{ steps.set-env.outputs.target_name }} 54 | path: "/tmp/${{ steps.set-env.outputs.target_name }}" 55 | 56 | on-failure: 57 | permissions: 58 | contents: read 59 | 60 | runs-on: ubuntu-latest 61 | if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure' }} 62 | steps: 63 | - run: echo 'The release triggering workflows failed' 64 | 65 | release: 66 | permissions: 67 | contents: write 68 | 69 | runs-on: ubuntu-latest 70 | if: ${{ github.event_name == 'repository_dispatch' }} 71 | needs: on-success 72 | steps: 73 | - name: check pull_request title 74 | uses: kaisugi/action-regex-match@v1.0.2 75 | id: regex-match 76 | with: 77 | text: ${{ github.event.client_payload.pull_request.title }} 78 | regex: "^(\\d+\\.\\d+\\.\\d+)$" 79 | 80 | - name: checkout 81 | if: ${{ steps.regex-match.outputs.match != '' }} 82 | uses: actions/checkout@v6 83 | 84 | - name: download artifacts 85 | if: ${{ steps.regex-match.outputs.match != ''}} 86 | uses: actions/download-artifact@v7 87 | with: 88 | path: /tmp/rpxy-l4 89 | 90 | - name: make tar.gz of assets 91 | if: ${{ steps.regex-match.outputs.match != ''}} 92 | run: | 93 | mkdir /tmp/assets 94 | cd /tmp/rpxy-l4 95 | for i in ./*; do sh -c "cd $i && tar zcvf $i.tar.gz $i && mv $i.tar.gz /tmp/assets/"; done 96 | ls -lha /tmp/assets 97 | 98 | - name: release 99 | if: ${{ steps.regex-match.outputs.match != ''}} 100 | uses: softprops/action-gh-release@v2 101 | with: 102 | files: /tmp/assets/*.tar.gz 103 | name: ${{ github.event.client_payload.pull_request.title }} 104 | tag_name: ${{ github.event.client_payload.pull_request.title }} 105 | body: ${{ github.event.client_payload.pull_request.body }} 106 | draft: true 107 | prerelease: false 108 | generate_release_notes: true 109 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | LOG_DIR=/rpxy-l4/log 3 | SYSTEM_LOG_FILE=${LOG_DIR}/rpxy-l4.log 4 | ACCESS_LOG_FILE=${LOG_DIR}/access.log 5 | LOG_SIZE=10M 6 | LOG_NUM=10 7 | 8 | LOGGING=${LOG_TO_FILE:-false} 9 | USER=${HOST_USER:-rpxy} 10 | USER_ID=${HOST_UID:-900} 11 | GROUP_ID=${HOST_GID:-900} 12 | 13 | CONFIG_FILE=/etc/rpxy-l4.toml 14 | CONFIG_DIR=/rpxy-l4/config 15 | CONFIG_FILE_IN_DIR=${CONFIG_FILENAME:-config.toml} 16 | 17 | ####################################### 18 | # Setup logrotate 19 | function setup_logrotate () { 20 | if [ $LOGROTATE_NUM ]; then 21 | LOG_NUM=${LOGROTATE_NUM} 22 | fi 23 | if [ $LOGROTATE_SIZE ]; then 24 | LOG_SIZE=${LOGROTATE_SIZE} 25 | fi 26 | 27 | cat > /etc/logrotate.conf << EOF 28 | # see "man logrotate" for details 29 | # rotate log files weekly 30 | weekly 31 | # use the adm group by default, since this is the owning group 32 | # of /var/log/syslog. 33 | # su root adm 34 | # keep 4 weeks worth of backlogs 35 | rotate 4 36 | # create new (empty) log files after rotating old ones 37 | create 38 | # use date as a suffix of the rotated file 39 | #dateext 40 | # uncomment this if you want your log files compressed 41 | #compress 42 | # packages drop log rotation information into this directory 43 | include /etc/logrotate.d 44 | # system-specific logs may be also be configured here. 45 | EOF 46 | 47 | cat > /etc/logrotate.d/rpxy-l4-system.conf << EOF 48 | ${SYSTEM_LOG_FILE} { 49 | dateext 50 | daily 51 | missingok 52 | rotate ${LOG_NUM} 53 | notifempty 54 | compress 55 | delaycompress 56 | dateformat -%Y-%m-%d-%s 57 | size ${LOG_SIZE} 58 | copytruncate 59 | su ${USER} ${USER} 60 | } 61 | EOF 62 | 63 | cat > /etc/logrotate.d/rpxy-l4-access.conf << EOF 64 | ${ACCESS_LOG_FILE} { 65 | dateext 66 | daily 67 | missingok 68 | rotate ${LOG_NUM} 69 | notifempty 70 | compress 71 | delaycompress 72 | dateformat -%Y-%m-%d-%s 73 | size ${LOG_SIZE} 74 | copytruncate 75 | su ${USER} ${USER} 76 | } 77 | EOF 78 | } 79 | 80 | ####################################### 81 | function setup_alpine () { 82 | id ${USER} > /dev/null 83 | # Check the existence of the user, if not exist, create it. 84 | if [ $? -eq 1 ]; then 85 | echo "rpxy-l4: Create user ${USER} with ${USER_ID}:${GROUP_ID}" 86 | addgroup -g ${GROUP_ID} ${USER} 87 | adduser -H -D -u ${USER_ID} -G ${USER} ${USER} 88 | fi 89 | 90 | # for crontab when logging 91 | if "${LOGGING}"; then 92 | # Set up logrotate 93 | setup_logrotate 94 | 95 | # Setup cron 96 | cp -f /etc/periodic/daily/logrotate /etc/periodic/15min 97 | crond -b -l 8 98 | fi 99 | } 100 | 101 | ####################################### 102 | 103 | if [ $(whoami) != "root" -o $(id -u) -ne 0 -a $(id -g) -ne 0 ]; then 104 | echo "Do not execute 'docker run' or 'docker-compose up' with a specific user through '-u'." 105 | echo "If you want to run 'rpxy-l4' with a specific user, use HOST_USER, HOST_UID and HOST_GID environment variables." 106 | exit 1 107 | fi 108 | 109 | # Setup Alpine Linux 110 | setup_alpine 111 | 112 | # Add user CAs to OS trusted CA store (does not affect webpki) 113 | update-ca-certificates 114 | 115 | # Check the given user and its uid:gid 116 | if [ $(id -u ${USER}) -ne ${USER_ID} -a $(id -g ${USER}) -ne ${GROUP_ID} ]; then 117 | echo "${USER} exists or was previously created. However, its uid and gid are inconsistent. Please recreate your container." 118 | exit 1 119 | fi 120 | 121 | # Change permission according to the given user 122 | # except for the config dir that possibly get mounted with read-only 123 | find /rpxy-l4 -path ${CONFIG_DIR} -prune -o -exec chown ${USER_ID}:${USER_ID} {} + 124 | 125 | # Check the config file existence 126 | if [[ ! -f ${CONFIG_FILE} ]]; then 127 | if [[ ! -f ${CONFIG_DIR}/${CONFIG_FILE_IN_DIR} ]]; then 128 | echo "No config file is given. Mount a config dir or file." 129 | exit 1 130 | fi 131 | echo "rpxy-l4: config file: ${CONFIG_DIR}/${CONFIG_FILE_IN_DIR}" 132 | ln -s ${CONFIG_DIR}/${CONFIG_FILE_IN_DIR} ${CONFIG_FILE} 133 | else 134 | echo "rpxy-l4: config file: ${CONFIG_FILE}" 135 | fi 136 | 137 | # Run rpxy-l4 138 | cd /rpxy-l4 139 | echo "rpxy-l4: Start with user: ${USER} (${USER_ID}:${GROUP_ID})" 140 | su-exec ${USER} sh -c "/rpxy-l4/run.sh 2>&1" 141 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod access_log; 2 | mod config; 3 | mod constants; 4 | mod count; 5 | mod destination; 6 | mod error; 7 | mod probe; 8 | mod proto; 9 | mod socket; 10 | mod target; 11 | mod tcp_proxy; 12 | mod time_util; 13 | mod trace; 14 | mod udp_conn; 15 | mod udp_proxy; 16 | 17 | use constants::{DNS_CACHE_MAX_TTL, DNS_CACHE_MIN_TTL}; 18 | use std::sync::Arc; 19 | use target::DnsCache; 20 | 21 | pub use config::{Config, EchProtocolConfig, ProtocolConfig}; 22 | pub use constants::log_event_names; 23 | pub use count::{ConnectionCount as TcpConnectionCount, ConnectionCountSum as UdpConnectionCount}; 24 | pub use destination::LoadBalance; 25 | pub use error::{ProxyBuildError, ProxyError}; 26 | pub use proto::ProtocolType; 27 | pub use target::TargetAddr; 28 | pub use tcp_proxy::{TcpDestinationMux, TcpDestinationMuxBuilder, TcpProxy, TcpProxyBuilder}; 29 | pub use udp_proxy::{UdpDestinationMux, UdpDestinationMuxBuilder, UdpProxy, UdpProxyBuilder}; 30 | 31 | /* ---------------------------------------- */ 32 | /// Build TCP and UDP multiplexers from the configuration 33 | pub fn build_multiplexers(config: &Config) -> Result<(TcpDestinationMux, UdpDestinationMux), ProxyBuildError> { 34 | // Validate configuration first using centralized validation 35 | config::validate_config(config)?; 36 | 37 | let mut tcp_mux_builder = TcpDestinationMuxBuilder::default(); 38 | let mut udp_mux_builder = UdpDestinationMuxBuilder::default(); 39 | 40 | // Generate DNS cache 41 | let dns_cache = Arc::new(DnsCache::new( 42 | config.dns_cache_min_ttl.unwrap_or(DNS_CACHE_MIN_TTL), 43 | config.dns_cache_max_ttl.unwrap_or_else(|| DNS_CACHE_MAX_TTL), 44 | )); 45 | 46 | // For default targets 47 | if let Some(tcp_target) = config.tcp_target.as_ref() { 48 | tcp_mux_builder.set_base( 49 | proto::TcpProtocolType::Any, 50 | tcp_target.as_slice(), 51 | &dns_cache, 52 | config.tcp_load_balance.as_ref(), 53 | ); 54 | } 55 | if let Some(udp_target) = config.udp_target.as_ref() { 56 | udp_mux_builder.set_base( 57 | proto::UdpProtocolType::Any, 58 | udp_target.as_slice(), 59 | &dns_cache, 60 | config.udp_load_balance.as_ref(), 61 | config.udp_idle_lifetime, 62 | ); 63 | } 64 | 65 | // Implement protocol specific routers 66 | for (_key, spec) in config.protocols.iter() { 67 | let target: &[_] = spec.target.as_ref(); 68 | // No need to check if target is empty - already validated in validate_config() 69 | 70 | match spec.protocol { 71 | ProtocolType::Http => { 72 | tcp_mux_builder.set_base(proto::TcpProtocolType::Http, target, &dns_cache, spec.load_balance.as_ref()); 73 | } 74 | /* ---------------------------------------- */ 75 | ProtocolType::Ssh => { 76 | tcp_mux_builder.set_base(proto::TcpProtocolType::Ssh, target, &dns_cache, spec.load_balance.as_ref()); 77 | } 78 | /* ---------------------------------------- */ 79 | ProtocolType::Wireguard => { 80 | udp_mux_builder.set_base( 81 | proto::UdpProtocolType::Wireguard, 82 | target, 83 | &dns_cache, 84 | spec.load_balance.as_ref(), 85 | spec.idle_lifetime, 86 | ); 87 | } 88 | /* ---------------------------------------- */ 89 | ProtocolType::Tls => { 90 | let alpn = spec 91 | .alpn 92 | .as_ref() 93 | .map(|v| v.iter().map(|x| x.as_str()).collect::>()); 94 | let server_names = spec 95 | .server_names 96 | .as_ref() 97 | .map(|v| v.iter().map(|x| x.as_str()).collect::>()); 98 | tcp_mux_builder.set_tls( 99 | target, 100 | &dns_cache, 101 | spec.load_balance.as_ref(), 102 | server_names.as_deref(), 103 | alpn.as_deref(), 104 | spec.ech.as_ref(), 105 | ); 106 | } 107 | /* ---------------------------------------- */ 108 | ProtocolType::Quic => { 109 | let alpn = spec 110 | .alpn 111 | .as_ref() 112 | .map(|v| v.iter().map(|x| x.as_str()).collect::>()); 113 | let server_names = spec 114 | .server_names 115 | .as_ref() 116 | .map(|v| v.iter().map(|x| x.as_str()).collect::>()); 117 | // Note: QUIC ECH validation already handled in validate_config() 118 | udp_mux_builder.set_quic( 119 | target, 120 | &dns_cache, 121 | spec.load_balance.as_ref(), 122 | spec.idle_lifetime, 123 | server_names.as_deref(), 124 | alpn.as_deref(), 125 | spec.ech.as_ref(), 126 | ); 127 | } 128 | } 129 | } 130 | 131 | Ok((tcp_mux_builder.build()?, udp_mux_builder.build()?)) 132 | } 133 | -------------------------------------------------------------------------------- /proxy-l4/src/log.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | pub use tracing::{debug, error, info, warn}; 3 | 4 | use crate::{ACCESS_LOG_FILE, SYSTEM_LOG_FILE}; 5 | use rpxy_l4_lib::log_event_names; 6 | use std::str::FromStr; 7 | use tracing_subscriber::{fmt, prelude::*}; 8 | 9 | pub fn init_logger(log_dir_path: Option<&str>) { 10 | let level_string = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); 11 | let level = tracing::Level::from_str(level_string.as_str()).unwrap_or(tracing::Level::INFO); 12 | 13 | match log_dir_path { 14 | None => { 15 | // log to stdout 16 | init_stdio_logger(level); 17 | } 18 | Some(log_dir_path) => { 19 | // log to files 20 | println!("Activate logging to files: {log_dir_path}"); 21 | init_file_logger(level, log_dir_path); 22 | } 23 | } 24 | } 25 | 26 | /// stdio logging 27 | fn init_stdio_logger(level: tracing::Level) { 28 | // This limits the logger to emits only this crate with any level above RUST_LOG, for included crates it will emit only ERROR (in prod)/INFO (in dev) or above level. 29 | let stdio_layer = fmt::layer().with_level(true).with_thread_ids(false); 30 | if level <= tracing::Level::INFO { 31 | // in normal deployment environment 32 | let stdio_layer = stdio_layer 33 | .with_target(false) 34 | .compact() 35 | .with_filter(tracing_subscriber::filter::filter_fn(move |metadata| { 36 | (metadata 37 | .target() 38 | .starts_with(env!("CARGO_PKG_NAME").replace('-', "_").as_str()) 39 | && metadata.level() <= &level) 40 | || metadata.level() <= &tracing::Level::WARN.min(level) 41 | })); 42 | tracing_subscriber::registry().with(stdio_layer).init(); 43 | } else { 44 | // debugging 45 | let stdio_layer = stdio_layer 46 | .with_line_number(true) 47 | .with_target(true) 48 | .with_thread_names(true) 49 | .with_target(true) 50 | .compact() 51 | .with_filter(tracing_subscriber::filter::filter_fn(move |metadata| { 52 | (metadata 53 | .target() 54 | .starts_with(env!("CARGO_PKG_NAME").replace('-', "_").as_str()) 55 | && metadata.level() <= &level) 56 | || metadata.level() <= &tracing::Level::INFO.min(level) 57 | })); 58 | tracing_subscriber::registry().with(stdio_layer).init(); 59 | }; 60 | } 61 | 62 | /// file logging 63 | fn init_file_logger(level: tracing::Level, log_dir_path: &str) { 64 | let log_dir_path = std::path::PathBuf::from(log_dir_path); 65 | // create the directory if it does not exist 66 | if !log_dir_path.exists() { 67 | println!("Directory does not exist, creating: {}", log_dir_path.display()); 68 | std::fs::create_dir_all(&log_dir_path).expect("Failed to create log directory"); 69 | } 70 | let access_log_path = log_dir_path.join(ACCESS_LOG_FILE); 71 | let system_log_path = log_dir_path.join(SYSTEM_LOG_FILE); 72 | println!("Access log: {}", access_log_path.display()); 73 | println!("System and error log: {}", system_log_path.display()); 74 | 75 | let access_log = open_log_file(&access_log_path); 76 | let system_log = open_log_file(&system_log_path); 77 | 78 | let reg = tracing_subscriber::registry(); 79 | 80 | let access_log_base = fmt::layer() 81 | .with_line_number(false) 82 | .with_thread_ids(false) 83 | .with_thread_names(false) 84 | .with_target(false) 85 | .with_level(false) 86 | .compact() 87 | .with_ansi(false); 88 | let reg = reg.with(access_log_base.with_writer(access_log).with_filter(AccessLogFilter)); 89 | 90 | let system_log_base = fmt::layer() 91 | .with_line_number(false) 92 | .with_thread_ids(false) 93 | .with_thread_names(false) 94 | .with_target(false) 95 | .with_level(true) // with level for system log 96 | .compact() 97 | .with_ansi(false); 98 | let reg = reg.with( 99 | system_log_base 100 | .with_writer(system_log) 101 | .with_filter(tracing_subscriber::filter::filter_fn(move |metadata| { 102 | (metadata 103 | .target() 104 | .starts_with(env!("CARGO_PKG_NAME").replace('-', "_").as_str()) 105 | && metadata.name() != log_event_names::ACCESS_LOG_START 106 | && metadata.name() != log_event_names::ACCESS_LOG_FINISH 107 | && metadata.level() <= &level) 108 | || metadata.level() <= &tracing::Level::WARN.min(level) 109 | })), 110 | ); 111 | 112 | reg.init(); 113 | } 114 | 115 | /// Access log filter 116 | struct AccessLogFilter; 117 | impl tracing_subscriber::layer::Filter for AccessLogFilter { 118 | fn enabled(&self, metadata: &tracing::Metadata<'_>, _: &tracing_subscriber::layer::Context<'_, S>) -> bool { 119 | metadata 120 | .target() 121 | .starts_with(env!("CARGO_PKG_NAME").replace('-', "_").as_str()) 122 | && (metadata.name().contains(log_event_names::ACCESS_LOG_START) 123 | || metadata.name().contains(log_event_names::ACCESS_LOG_FINISH)) 124 | && metadata.level() <= &tracing::Level::INFO 125 | } 126 | } 127 | 128 | #[inline] 129 | /// Create a file for logging 130 | fn open_log_file

(path: P) -> std::fs::File 131 | where 132 | P: AsRef, 133 | { 134 | // crate a file if it does not exist 135 | std::fs::OpenOptions::new() 136 | .create(true) 137 | .append(true) 138 | .open(path) 139 | .expect("Failed to open the log file") 140 | } 141 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | on: 3 | push: 4 | branches: 5 | - "develop" 6 | pull_request: 7 | types: [closed] 8 | branches: 9 | - main 10 | 11 | env: 12 | GHCR: ghcr.io 13 | GHCR_IMAGE_NAME: ${{ github.repository }} 14 | DH_REGISTRY_NAME: jqtype/rpxy-l4 15 | 16 | jobs: 17 | build_and_push: 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | runs-on: ubuntu-22.04 23 | if: ${{ github.event_name == 'push' }} || ${{ github.event_name == 'pull_request' && github.event.pull_request.merged == true }} 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | include: 28 | - target: "default" 29 | dockerfile: ./docker/Dockerfile 30 | platforms: linux/amd64,linux/arm64 31 | build-contexts: | 32 | messense/rust-musl-cross:amd64-musl=docker-image://messense/rust-musl-cross:x86_64-musl 33 | messense/rust-musl-cross:arm64-musl=docker-image://messense/rust-musl-cross:aarch64-musl 34 | # Aliases must be used only for release builds 35 | aliases: | 36 | jqtype/rpxy-l4:latest 37 | ghcr.io/junkurihara/rust-rpxy-l4:latest 38 | 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v6 42 | with: 43 | submodules: recursive 44 | 45 | - name: Docker meta 46 | id: meta 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: ${{ env.GHCR }}/${{ env.GHCR_IMAGE_NAME }} 50 | 51 | - name: Set up QEMU 52 | uses: docker/setup-qemu-action@v3 53 | 54 | - name: Set up Docker Buildx 55 | uses: docker/setup-buildx-action@v3 56 | 57 | - name: Login to Docker Hub 58 | uses: docker/login-action@v3 59 | with: 60 | username: ${{ secrets.DOCKERHUB_USERNAME }} 61 | password: ${{ secrets.DOCKERHUB_TOKEN }} 62 | 63 | - name: Login to GitHub Container Registry 64 | uses: docker/login-action@v3 65 | with: 66 | registry: ${{ env.GHCR }} 67 | username: ${{ github.actor }} 68 | password: ${{ secrets.GITHUB_TOKEN }} 69 | 70 | - name: Nightly build and push from develop branch 71 | if: ${{ (github.ref_name == 'develop') && (github.event_name == 'push') }} 72 | uses: docker/build-push-action@v6 73 | with: 74 | context: . 75 | push: true 76 | tags: | 77 | ${{ env.DH_REGISTRY_NAME }}:nightly 78 | ${{ env.GHCR }}/${{ env.GHCR_IMAGE_NAME }}:nightly 79 | file: ${{ matrix.dockerfile }} 80 | build-contexts: ${{ matrix.build-contexts }} 81 | cache-from: type=gha,scope=rpxy-l4-nightly-${{ matrix.target }} 82 | cache-to: type=gha,mode=max,scope=rpxy-l4-nightly-${{ matrix.target }} 83 | platforms: ${{ matrix.platforms }} 84 | labels: ${{ steps.meta.outputs.labels }} 85 | 86 | - name: check pull_request title 87 | if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'develop' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true }} 88 | uses: kaisugi/action-regex-match@v1.0.2 89 | id: regex-match 90 | with: 91 | text: ${{ github.event.pull_request.title }} 92 | regex: "^(\\d+\\.\\d+\\.\\d+)$" 93 | 94 | - name: Release build and push from main branch 95 | if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'develop' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true }} 96 | uses: docker/build-push-action@v6 97 | with: 98 | context: . 99 | push: true 100 | tags: | 101 | ${{ env.GHCR }}/${{ env.GHCR_IMAGE_NAME }}:latest 102 | ${{ env.DH_REGISTRY_NAME }}:latest 103 | ${{ matrix.aliases }} 104 | ${{ env.GHCR }}/${{ env.GHCR_IMAGE_NAME }}:${{ github.event.pull_request.title }} 105 | ${{ env.DH_REGISTRY_NAME }}:${{ github.event.pull_request.title }} 106 | file: ${{ matrix.dockerfile }} 107 | build-contexts: ${{ matrix.build-contexts }} 108 | cache-from: type=gha,scope=rpxy-l4-latest-${{ matrix.target }} 109 | cache-to: type=gha,mode=max,scope=rpxy-l4-latest-${{ matrix.target }} 110 | platforms: ${{ matrix.platforms }} 111 | labels: ${{ steps.meta.outputs.labels }} 112 | 113 | dispatch_release_event: 114 | permissions: 115 | contents: write 116 | actions: write 117 | 118 | runs-on: ubuntu-latest 119 | if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'develop' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true }} 120 | needs: build_and_push 121 | steps: 122 | - name: Repository dispatch for release 123 | uses: peter-evans/repository-dispatch@v4 124 | with: 125 | event-type: release-event 126 | client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "pull_request": { "title": "${{ github.event.pull_request.title }}", "body": ${{ toJson(github.event.pull_request.body) }}, "number": "${{ github.event.pull_request.number }}", "head": "${{ github.event.pull_request.head.ref }}", "base": "${{ github.event.pull_request.base.ref}}"}}' 127 | -------------------------------------------------------------------------------- /quic-tls/src/ech_extension.rs: -------------------------------------------------------------------------------- 1 | //! TLS Encrypted ClientHello (ECH) extension based on Draft 24 2 | //! [IETF ECH Draft-24](https://www.ietf.org/archive/id/draft-ietf-tls-esni-24.html) 3 | 4 | use bytes::Bytes; 5 | 6 | use crate::{ 7 | ech_config::HpkeSymmetricCipherSuite, 8 | error::TlsClientHelloError, 9 | serialize::{Deserialize, SerDeserError, Serialize, read_lengthed}, 10 | trace::*, 11 | }; 12 | 13 | /* ------------------------------------------- */ 14 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 15 | /// TLS ClientHello EncryptedClientHello extension 16 | pub enum EncryptedClientHello { 17 | /// outer ClientHello (0) 18 | Outer(ClientHelloOuter), 19 | /// inner ClientHello, which is always empty (1) 20 | Inner, 21 | } 22 | 23 | impl std::fmt::Display for EncryptedClientHello { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match self { 26 | EncryptedClientHello::Outer(payload) => write!(f, "ECH Outer: {:?}", payload), 27 | EncryptedClientHello::Inner => write!(f, "ECH Inner"), 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 33 | /// Outer ClientHello 34 | pub struct ClientHelloOuter { 35 | /// Cipher suite 36 | cipher_suite: HpkeSymmetricCipherSuite, 37 | /// Config ID 38 | config_id: u8, 39 | /// enc (e.g, Public key of the peer) 40 | enc: Bytes, 41 | /// payload (encrypted body) 42 | payload: Bytes, 43 | } 44 | impl ClientHelloOuter { 45 | /// Fill the payload with zeros for AAD calculation 46 | pub(crate) fn fill_payload_with_zeros(&mut self) { 47 | // Replace the payload field with zeros 48 | let payload_len = self.payload.len(); 49 | let payload = vec![0; payload_len]; 50 | self.payload = Bytes::from(payload); 51 | } 52 | pub(crate) fn cipher_suite(&self) -> &HpkeSymmetricCipherSuite { 53 | &self.cipher_suite 54 | } 55 | pub(crate) fn config_id(&self) -> u8 { 56 | self.config_id 57 | } 58 | pub(crate) fn enc(&self) -> &Bytes { 59 | &self.enc 60 | } 61 | pub(crate) fn payload(&self) -> &Bytes { 62 | &self.payload 63 | } 64 | } 65 | /* ------------------------------------------- */ 66 | 67 | impl Deserialize for EncryptedClientHello { 68 | type Error = TlsClientHelloError; 69 | /// Deserialize the EncryptedClientHello 70 | fn deserialize(buf: &mut B) -> Result 71 | where 72 | Self: Sized, 73 | { 74 | let ech_client_hello_type = buf.get_u8(); 75 | match ech_client_hello_type { 76 | 0 => { 77 | let payload = ClientHelloOuter::deserialize(buf)?; 78 | Ok(EncryptedClientHello::Outer(payload)) 79 | } 80 | 1 => Ok(EncryptedClientHello::Inner), 81 | _ => { 82 | error!("Unknown ECH ClientHello type: {}", ech_client_hello_type); 83 | Err(TlsClientHelloError::InvalidEchExtension) 84 | } 85 | } 86 | } 87 | } 88 | 89 | impl Serialize for EncryptedClientHello { 90 | type Error = TlsClientHelloError; 91 | /// Serialize the EncryptedClientHello 92 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error> { 93 | match self { 94 | EncryptedClientHello::Outer(payload) => { 95 | buf.put_u8(0); 96 | payload.serialize(buf) 97 | } 98 | EncryptedClientHello::Inner => { 99 | buf.put_u8(1); 100 | Ok(()) 101 | } 102 | } 103 | } 104 | } 105 | 106 | impl Deserialize for ClientHelloOuter { 107 | type Error = TlsClientHelloError; 108 | /// Deserialize the outer ClientHello 109 | fn deserialize(buf: &mut B) -> Result 110 | where 111 | Self: Sized, 112 | { 113 | let cipher_suite = HpkeSymmetricCipherSuite::deserialize(buf)?; 114 | if buf.remaining() < 5 { 115 | error!("Not enough data as ECH ClientHelloOuter"); 116 | return Err(SerDeserError::ShortInput.into()); 117 | } 118 | let config_id = buf.get_u8(); 119 | let enc = read_lengthed(buf, 2)?; 120 | let payload = read_lengthed(buf, 2)?; 121 | 122 | if payload.is_empty() { 123 | error!("Empty ech payload for ClientHelloOuter"); 124 | return Err(TlsClientHelloError::InvalidEchExtension); 125 | } 126 | 127 | Ok(ClientHelloOuter { 128 | cipher_suite, 129 | config_id, 130 | enc, 131 | payload, 132 | }) 133 | } 134 | } 135 | 136 | impl Serialize for ClientHelloOuter { 137 | type Error = TlsClientHelloError; 138 | /// Serialize the outer ClientHello 139 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error> { 140 | // Serialize the outer ClientHello 141 | self.cipher_suite.serialize(buf)?; 142 | buf.put_u8(self.config_id); 143 | buf.put_u16(self.enc.len() as u16); 144 | buf.put_slice(&self.enc); 145 | if self.payload.is_empty() { 146 | error!("Empty ech payload for ClientHelloOuter"); 147 | return Err(TlsClientHelloError::InvalidEchExtension); 148 | } 149 | buf.put_u16(self.payload.len() as u16); 150 | buf.put_slice(&self.payload); 151 | 152 | Ok(()) 153 | } 154 | } 155 | 156 | /* ------------------------------------------- */ 157 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 158 | /// TLS ClientHello OuterExtensions extension, presented only in inner ClientHello (decrypted ECH payload) 159 | pub struct OuterExtensions { 160 | /// Extension types removed from the ClientHelloInner 161 | outer_extensions: Vec, 162 | } 163 | 164 | impl OuterExtensions { 165 | // Iterator for outer extensions 166 | pub(crate) fn iter(&self) -> impl Iterator + '_ { 167 | self.outer_extensions.iter().copied() 168 | } 169 | // length of outer extensions 170 | pub(crate) fn len(&self) -> usize { 171 | self.outer_extensions.len() 172 | } 173 | } 174 | 175 | impl std::fmt::Display for OuterExtensions { 176 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 177 | write!(f, "ECH OuterExtensions: {:?}", self.outer_extensions) 178 | } 179 | } 180 | 181 | impl Deserialize for OuterExtensions { 182 | type Error = TlsClientHelloError; 183 | /// Deserialize the OuterExtensions 184 | fn deserialize(buf: &mut B) -> Result 185 | where 186 | Self: Sized, 187 | { 188 | if buf.remaining() < 1 { 189 | error!("Not enough data as OuterExtensions"); 190 | return Err(SerDeserError::ShortInput.into()); 191 | } 192 | 193 | let outer_extensions = read_lengthed(buf, 1)?; 194 | 195 | if outer_extensions.is_empty() || outer_extensions.len() % 2 != 0 { 196 | error!("Invalid OuterExtensions"); 197 | return Err(TlsClientHelloError::InvalidOuterExtensionsExtension); 198 | } 199 | 200 | let outer_extensions = outer_extensions 201 | .chunks_exact(2) 202 | .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) 203 | .collect(); 204 | Ok(OuterExtensions { outer_extensions }) 205 | } 206 | } 207 | 208 | impl Serialize for OuterExtensions { 209 | type Error = TlsClientHelloError; 210 | /// Serialize the OuterExtensions 211 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error> { 212 | // Serialize the outer extensions 213 | if self.outer_extensions.is_empty() { 214 | error!("Empty outer extensions"); 215 | return Err(TlsClientHelloError::InvalidOuterExtensionsExtension); 216 | } 217 | buf.put_u8(self.outer_extensions.len() as u8); 218 | for ext in self.outer_extensions { 219 | buf.put_u16(ext); 220 | } 221 | Ok(()) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /.build/config.toml: -------------------------------------------------------------------------------- 1 | ############################################## 2 | # Configuration specification of rpxy-L4 # 3 | ############################################## 4 | 5 | ################### 6 | # Global settings # 7 | ################### 8 | 9 | # Listen port, must be set 10 | listen_port = 8448 11 | 12 | # (Optional) Listen [::], [default: false] 13 | listen_ipv6 = true 14 | 15 | # (Optional) TCP backlog size [default: 1024] 16 | tcp_backlog = 1024 17 | 18 | # (Optional) Maximum TCP connections [default: 1024] 19 | tcp_max_connections = 1024 20 | 21 | # (Optional) Maximum UDP connections [default: 2048] 22 | udp_max_connections = 2048 23 | 24 | # (Optional) Default targets for TCP connections. [default: empty] 25 | # If specified, connections that are not detected as specified protocols will be forwarded to these targets. 26 | # Otherwise, only specified protocols pass through and others are dropped. 27 | # Format: [":", ":", ...] 28 | #tcp_target = ["192.168.122.4:53"] 29 | 30 | # (Optional) Load balancing method for default targets [default: none] 31 | # - source_ip: based on source IP hash 32 | # - source_socket: based on source IP and port hash 33 | # - random: random selection 34 | # - none: always use the first target 35 | tcp_load_balance = "source_ip" 36 | 37 | # (Optional) Default targets for UDP connections. [default: empty] 38 | #udp_target = ["192.168.122.4:53"] 39 | 40 | # (Optional) Load balancing method for default targets [default: none] 41 | #udp_load_balance = "source_ip" 42 | 43 | # (Optional) Udp connection idle lifetime in seconds [default: 30] 44 | udp_idle_lifetime = 30 45 | 46 | # (Optional) DNS cache minimum TTL in duration format (e.g., "30s", "1m", "1h") [default: 30s] 47 | # This sets the minimum time resolved DNS records are cached, even if the DNS response 48 | # specifies a shorter TTL. 49 | dns_cache_min_ttl = "30s" 50 | 51 | # (Optional) DNS cache maximum TTL in duration format [default: 1h] 52 | # This sets the maximum time resolved DNS records are cached, even if the DNS response 53 | # specifies a longer TTL. 54 | dns_cache_max_ttl = "1h" 55 | 56 | ######################### 57 | # Per-protocol settings # 58 | ######################### 59 | [protocols] 60 | 61 | # Note that multiple entries with `protocols = "tls"` or `"quic"` can be configured. 62 | # On the other hand, only one entry with `protocols = "ssh"`, `"http"`, or `"wireguard"` can be configured. 63 | 64 | ######################### 65 | ### Example for TLS 66 | [protocols."tls_1"] 67 | # Name of protocol tls|ssh|http|wireguard|quic 68 | #protocol = "tls" 69 | 70 | # Target for connections detected as the `protocol`. 71 | #target = ["192.168.122.4:443"] # ip:port or domain:port 72 | 73 | # (Optional) Load balancing method specific to this connections [default: none] 74 | #load_balance = "source_ip" 75 | 76 | 77 | ######################### 78 | ### Example for TLS with SNI routing 79 | [protocols."tls_2"] 80 | #protocol = "tls" 81 | #target = ["192.168.122.5:443"] # ip:port or domain:port 82 | #load_balance = "source_ip" 83 | 84 | # (Optional) SNI-based routing for TLS/QUIC connections, only valid when `protocol = "tls"` or `"quic"`. 85 | # If specified, only TLS/QUIC connections matched to the given SNI(s) are forwarded to the target. 86 | # Format: ["", "", ...] 87 | #server_names = ["example.com", "example.org"] 88 | 89 | ######################### 90 | ### Example for TLS with SNI routing 91 | #[protocols."tls_3"] 92 | #protocol = "tls" 93 | #target = ["192.168.122.6:443"] # ip:port or domain:port 94 | #load_balance = "source_ip" 95 | 96 | # (Optional) ALPN-based routing for TLS/QUIC connections, only valid when `protocol = "tls"` or `"quic"`. 97 | # If specified, only TLS/QUIC connections matched to the given ALPN(s) are forwarded to the target. 98 | # Format: ["", "", ...] 99 | #alpns = ["h2", "http/1.1"] 100 | 101 | # NOTE: If both `server_names` and `alpns` are specified, the proxy forwards connections that match simultaneously both of them. 102 | 103 | ######################### 104 | ### Example for TLS with ECH support 105 | [protocols."tls_4"] 106 | #protocol = "tls" 107 | #target = [ 108 | # "192.168.122.7:443", 109 | # "default-backend.example.com:443", 110 | #] # ip:port or domain:port 111 | #load_balance = "source_ip" 112 | # (Optional) If specified, TLS connection with SNI in the intersection of `server_names` and [`ech_config.content.public_name`] are evaluated. 113 | # In other words, even if not specified, allowed public names in ech config are only evaluated and possibly passed to the target. 114 | # This should be specified if the target is not the same as the public name in the ECH config. 115 | #server_names = ["my-public-name.example.com"] 116 | 117 | # (Optional) Support for encrypted client hello (ECH) for TLS connections, only valid when `protocol = "tls"` or `"quic"` and `server_name` is set. 118 | # If specified, the proxy will attempt to decrypt the encrypted InnerClientHello contained in the OuterClientHello matched to the SNI-matched connections. 119 | # For instance, when TLS ClientHello with SNI "example.net" (Outer) is given, the proxy will decrypts the encrypted part of the ClientHello (Inner). 120 | # Then it does not forward the plaintext ClientHello to the target specified above. The decrypted ClientHello Inner will be routed to the private target server contained in itself. 121 | # For instance, if the ClientHello Inner contains the SNI "my-private-backend.example.com" (Inner), 122 | # the connection will be routed to the private target server "my-private-backend.example.com:8448", 123 | # where the port number is preserved from the OuterClientHello, i.e., 8448 (listen port), *unless specified". 124 | # If it is specified with the port number, e.g., "xxx.com:443", the port number is forced to be used as the target port instead of listen port. 125 | # If decryption fails (e.g. the ECHConfigList is not matched to the OuterClientHello), the connection simply be passed to the target with no modification. Namely, the server names give above are the default target. 126 | [protocols."tls_4".ech] 127 | # ECHConfigList for the server, base64 encoded. 128 | #ech_config_list = "" 129 | # Private keys for ECH decryption, matched to entries in ech_config_list. 130 | #private_keys = [""] 131 | # Private server names that only be routed after ECH decryption towards the target address 132 | # This is used to route the connection to the private backend server. 133 | # Then port is the same as the listen port unless specified. 134 | #private_server_names = [ 135 | # "my-private-backend.example.com", 136 | # "my-private-backend.example.org:8448", 137 | #] 138 | 139 | 140 | ######################### 141 | ### Example for QUIC 142 | [protocols."quic"] 143 | # Name of protocol tls|ssh|http|wireguard|quic 144 | #protocol = "quic" 145 | 146 | # Target for connections detected as QUIC. 147 | #target = ["192.168.122.4:443"] 148 | 149 | # Load balancing method for QUIC connections [default: none] 150 | #load_balance = "source_ip" 151 | 152 | # (Optional) Idle lifetime for QUIC connections in seconds [default: 30] 153 | #idle_lifetime = 30 154 | 155 | # server_names = ["example.com", "example.org"] 156 | # ech = { private_keys = ["/path/to/private.key"], server_names = ["example.org"] } 157 | 158 | ######################### 159 | ### Example for SSH 160 | [protocols."ssh"] 161 | # Name of protocol tls|ssh|http|wireguard|quic 162 | #protocol = "ssh" 163 | 164 | # Target for connections detected as SSH. 165 | #target = ["192.168.122.4:22"] 166 | 167 | # Load balancing method for SSH connections [default: none] 168 | #load_balance = "source_ip" 169 | -------------------------------------------------------------------------------- /proxy-l4/src/config/toml.rs: -------------------------------------------------------------------------------- 1 | use crate::log::warn; 2 | use anyhow::anyhow; 3 | use rpxy_l4_lib::{Config, EchProtocolConfig, LoadBalance, ProtocolConfig, ProtocolType, TargetAddr}; 4 | use serde::Deserialize; 5 | use std::{ 6 | collections::{HashMap, HashSet}, 7 | fs, 8 | time::Duration, 9 | }; 10 | 11 | #[derive(Deserialize, Debug, Default, PartialEq, Eq, Clone)] 12 | pub struct ConfigToml { 13 | pub listen_port: Option, 14 | pub listen_ipv6: Option, 15 | pub tcp_backlog: Option, 16 | pub tcp_max_connections: Option, 17 | pub udp_max_connections: Option, 18 | // tcp default target 19 | pub tcp_target: Option>, 20 | pub tcp_load_balance: Option, 21 | // udp default target 22 | pub udp_target: Option>, 23 | pub udp_load_balance: Option, 24 | pub udp_idle_lifetime: Option, 25 | // DNS cache configuration 26 | pub dns_cache_min_ttl: Option, 27 | pub dns_cache_max_ttl: Option, 28 | // protocols 29 | pub protocols: Option, 30 | } 31 | 32 | #[derive(Deserialize, Debug, Default, PartialEq, Eq, Clone)] 33 | pub struct ProtocolsToml(pub HashMap); 34 | 35 | #[derive(Deserialize, Debug, Default, PartialEq, Eq, Clone)] 36 | pub struct ProtocolToml { 37 | /// Protocol type name 38 | pub protocol: Option, 39 | /// Common for specific protocols 40 | pub target: Option>, 41 | /// Common for specific protocols 42 | pub load_balance: Option, 43 | /// Only UDP based protocol 44 | pub idle_lifetime: Option, 45 | /// Only TLS 46 | pub alpn: Option>, 47 | /// Only TLS 48 | pub server_names: Option>, 49 | /// Only TLS 50 | pub ech: Option, 51 | } 52 | 53 | #[derive(Deserialize, Debug, Default, PartialEq, Eq, Clone)] 54 | pub struct EchToml { 55 | /// Base64 encoded ECH config list object 56 | pub ech_config_list: String, 57 | /// List of base64 encoded raw private keys 58 | pub private_keys: Vec, 59 | /// The list of accepted ECH private server names 60 | pub private_server_names: Vec, 61 | } 62 | 63 | impl ConfigToml { 64 | pub fn new(config_file: &str) -> Result { 65 | let config_str = fs::read_to_string(config_file)?; 66 | 67 | // Check unused fields during deserialization 68 | let t = toml::Deserializer::parse(&config_str)?; 69 | let mut unused = HashSet::new(); 70 | 71 | let res = serde_ignored::deserialize(t, |path| { 72 | unused.insert(path.to_string()); 73 | }) 74 | .map_err(|e| anyhow::anyhow!(e)); 75 | 76 | if !unused.is_empty() { 77 | let str = unused.iter().fold(String::new(), |acc, x| acc + x + "\n"); 78 | warn!("Configuration file contains unsupported fields. Check typos:\n{}", str); 79 | } 80 | 81 | res 82 | } 83 | } 84 | 85 | impl TryFrom for Config { 86 | type Error = anyhow::Error; 87 | 88 | fn try_from(config_toml: ConfigToml) -> Result { 89 | let Some(listen_port) = config_toml.listen_port else { 90 | return Err(anyhow!("listen_port is required")); 91 | }; 92 | 93 | let mut protocols = HashMap::new(); 94 | 95 | if let Some(protocols_toml) = config_toml.protocols { 96 | for (name, protocol_toml) in protocols_toml.0 { 97 | let Some(proto_type) = protocol_toml.protocol.as_ref() else { 98 | return Err(anyhow!("protocol is required for key: {name}")); 99 | }; 100 | let proto_type: ProtocolType = proto_type.as_str().try_into()?; 101 | let Some(target) = protocol_toml.target.as_ref() else { 102 | return Err(anyhow!("target is required for key: {name}")); 103 | }; 104 | if target.is_empty() { 105 | return Err(anyhow!("target is empty for key: {name}")); 106 | } 107 | let target = target.iter().map(|x| x.parse()).collect::, _>>()?; 108 | 109 | let load_balance: Option = protocol_toml 110 | .load_balance 111 | .as_ref() 112 | .map(|x| x.as_str().try_into()) 113 | .transpose()?; 114 | 115 | let ech = protocol_toml 116 | .ech 117 | .as_ref() 118 | .map(|v| EchProtocolConfig::try_new(&v.ech_config_list, &v.private_keys, &v.private_server_names, &listen_port)) 119 | .transpose()?; 120 | if ech.is_some() { 121 | warn!("ECH is configured for protocol: {name}"); 122 | warn!("Make sure that the ECH config has been set up correctly as the client can refer to it."); 123 | warn!( 124 | "If DNS HTTPS RR is used for that, check if its value contains \"ech={}\"", 125 | &protocol_toml.ech.as_ref().unwrap().ech_config_list 126 | ); 127 | warn!( 128 | "For the configuration, ECH private server names accepted and routed are: {:?}", 129 | protocol_toml.ech.as_ref().unwrap().private_server_names 130 | ); 131 | } 132 | 133 | let protocol = ProtocolConfig { 134 | protocol: proto_type, 135 | target, 136 | load_balance, 137 | idle_lifetime: protocol_toml.idle_lifetime, 138 | alpn: protocol_toml.alpn, 139 | server_names: protocol_toml.server_names, 140 | ech, 141 | }; 142 | 143 | protocols.insert(name, protocol); 144 | } 145 | } 146 | 147 | let tcp_target = config_toml 148 | .tcp_target 149 | .map(|x| x.iter().map(|x| x.parse()).collect::, _>>()) 150 | .transpose()?; 151 | let tcp_load_balance = config_toml 152 | .tcp_load_balance 153 | .as_ref() 154 | .map(|x| x.as_str().try_into()) 155 | .transpose()?; 156 | let udp_target = config_toml 157 | .udp_target 158 | .map(|x| x.iter().map(|x| x.parse()).collect::, _>>()) 159 | .transpose()?; 160 | let udp_load_balance = config_toml 161 | .udp_load_balance 162 | .as_ref() 163 | .map(|x| x.as_str().try_into()) 164 | .transpose()?; 165 | 166 | // Parse DNS cache configuration 167 | let dns_cache_min_ttl = config_toml 168 | .dns_cache_min_ttl 169 | .as_ref() 170 | .map(|x| parse_duration(x)) 171 | .transpose()?; 172 | let dns_cache_max_ttl = config_toml 173 | .dns_cache_max_ttl 174 | .as_ref() 175 | .map(|x| parse_duration(x)) 176 | .transpose()?; 177 | 178 | Ok(Self { 179 | listen_port, 180 | listen_ipv6: config_toml.listen_ipv6.unwrap_or(false), 181 | tcp_backlog: config_toml.tcp_backlog, 182 | tcp_max_connections: config_toml.tcp_max_connections, 183 | udp_max_connections: config_toml.udp_max_connections, 184 | tcp_target, 185 | tcp_load_balance, 186 | udp_target, 187 | udp_load_balance, 188 | udp_idle_lifetime: config_toml.udp_idle_lifetime, 189 | dns_cache_min_ttl, 190 | dns_cache_max_ttl, 191 | protocols, 192 | }) 193 | } 194 | } 195 | 196 | /// Parse duration string like "30s", "5m", "1h" into Duration 197 | fn parse_duration(s: &str) -> Result { 198 | let s = s.trim(); 199 | if s.is_empty() { 200 | return Err(anyhow!("Empty duration string")); 201 | } 202 | 203 | let (num_part, unit_part) = if let Some(pos) = s.find(|c: char| c.is_alphabetic()) { 204 | (&s[..pos], &s[pos..]) 205 | } else { 206 | return Err(anyhow!("Duration must include a unit (s, m, h)")); 207 | }; 208 | 209 | let num: u64 = num_part 210 | .parse() 211 | .map_err(|_| anyhow!("Invalid number in duration: {}", num_part))?; 212 | 213 | let duration = match unit_part.to_lowercase().as_str() { 214 | "s" | "sec" | "secs" | "second" | "seconds" => Duration::from_secs(num), 215 | "m" | "min" | "mins" | "minute" | "minutes" => Duration::from_secs(num * 60), 216 | "h" | "hr" | "hrs" | "hour" | "hours" => Duration::from_secs(num * 3600), 217 | _ => return Err(anyhow!("Invalid duration unit: {}. Use s, m, or h", unit_part)), 218 | }; 219 | 220 | Ok(duration) 221 | } 222 | -------------------------------------------------------------------------------- /config.spec.toml: -------------------------------------------------------------------------------- 1 | ############################################## 2 | # Configuration specification of rpxy-L4 # 3 | ############################################## 4 | 5 | ################### 6 | # Global settings # 7 | ################### 8 | 9 | # Listen port, must be set 10 | # TODO: Is it nice to have multiple listen ports? 11 | listen_port = 8448 12 | 13 | # (Optional) Listen [::], [default: false] 14 | listen_ipv6 = true 15 | 16 | # (Optional) TCP backlog size [default: 1024] 17 | tcp_backlog = 1024 18 | 19 | # (Optional) Maximum TCP connections [default: 1024] 20 | tcp_max_connections = 1024 21 | 22 | # (Optional) Maximum UDP connections [default: 2048] 23 | udp_max_connections = 2048 24 | 25 | # (Optional) Default targets for TCP connections. [default: empty] 26 | # If specified, connections that are not detected as specified protocols will be forwarded to these targets. 27 | # Otherwise, only specified protocols pass through and others are dropped. 28 | # Format: [":", ":", ...] 29 | tcp_target = ["192.168.122.4:53"] # ip:port or domain:port 30 | 31 | # (Optional) Load balancing method for default targets [default: none] 32 | # - source_ip: based on source IP hash 33 | # - source_socket: based on source IP and port hash 34 | # - random: random selection 35 | # - none: always use the first target 36 | tcp_load_balance = "source_ip" # source_ip, source_socket, random, or none 37 | 38 | # (Optional) Default targets for UDP connections. [default: empty] 39 | udp_target = ["192.168.122.4:53"] # ip:port or domain:port 40 | 41 | # (Optional) Load balancing method for default targets [default: none] 42 | udp_load_balance = "source_ip" 43 | 44 | # (Optional) Udp connection idle lifetime in seconds [default: 30] 45 | udp_idle_lifetime = 30 46 | 47 | # (Optional) DNS cache minimum TTL in duration format (e.g., "30s", "1m", "1h") [default: 30s] 48 | # This sets the minimum time resolved DNS records are cached, even if the DNS response 49 | # specifies a shorter TTL. 50 | dns_cache_min_ttl = "30s" 51 | 52 | # (Optional) DNS cache maximum TTL in duration format [default: 1h] 53 | # This sets the maximum time resolved DNS records are cached, even if the DNS response 54 | # specifies a longer TTL. 55 | dns_cache_max_ttl = "1h" 56 | 57 | ######################### 58 | # Per-protocol settings # 59 | ######################### 60 | [protocols] 61 | 62 | # Note that multiple entries with `protocols = "tls"` or `"quic"` can be configured. 63 | # On the other hand, only one entry with `protocols = "ssh"`, `"http"`, or `"wireguard"` can be configured. 64 | 65 | ######################### 66 | ### Example for TLS 67 | [protocols."tls_1"] 68 | # Name of protocol tls|ssh|http|wireguard|quic 69 | protocol = "tls" 70 | 71 | # Target for connections detected as the `protocol`. 72 | target = ["192.168.122.4:443"] # ip:port or domain:port 73 | 74 | # (Optional) Load balancing method specific to this connections [default: none] 75 | load_balance = "source_ip" 76 | 77 | 78 | ######################### 79 | ### Example for TLS with SNI routing 80 | [protocols."tls_2"] 81 | protocol = "tls" 82 | target = ["192.168.122.5:443"] # ip:port or domain:port 83 | load_balance = "source_ip" 84 | 85 | # (Optional) SNI-based routing for TLS/QUIC connections, only valid when `protocol = "tls"` or `"quic"`. 86 | # If specified, only TLS/QUIC connections matched to the given SNI(s) are forwarded to the target. 87 | # Format: ["", "", ...] 88 | server_names = ["example.com", "example.org"] 89 | 90 | ######################### 91 | ### Example for TLS with SNI routing 92 | [protocols."tls_3"] 93 | protocol = "tls" 94 | target = ["192.168.122.6:443"] # ip:port or domain:port 95 | load_balance = "source_ip" 96 | 97 | # (Optional) ALPN-based routing for TLS/QUIC connections, only valid when `protocol = "tls"` or `"quic"`. 98 | # If specified, only TLS/QUIC connections matched to the given ALPN(s) are forwarded to the target. 99 | # Format: ["", "", ...] 100 | alpns = ["h2", "http/1.1"] 101 | 102 | # NOTE: If both `server_names` and `alpns` are specified, the proxy forwards connections that match simultaneously both of them. 103 | 104 | ######################### 105 | ### Example for TLS with ECH support 106 | [protocols."tls_4"] 107 | protocol = "tls" 108 | target = [ 109 | "192.168.122.7:443", 110 | "default-backend.example.com:443", 111 | ] # ip:port or domain:port 112 | load_balance = "source_ip" 113 | # (Optional) If specified, TLS connection with SNI in the intersection of `server_names` and [`ech_config.content.public_name`] are evaluated. 114 | # In other words, even if not specified, allowed public names in ech config are only evaluated and possibly passed to the target. 115 | # This should be specified if the target is not the same as the public name in the ECH config. 116 | server_names = ["my-public-name.example.com"] 117 | 118 | # (Optional) Support for encrypted client hello (ECH) for TLS connections, only valid when `protocol = "tls"` or `"quic"` and `server_name` is set. 119 | # If specified, the proxy will attempt to decrypt the encrypted InnerClientHello contained in the OuterClientHello matched to the SNI-matched connections. 120 | # For instance, when TLS ClientHello with SNI "example.net" (Outer) is given, the proxy will decrypts the encrypted part of the ClientHello (Inner). 121 | # Then it does not forward the plaintext ClientHello to the target specified above. The decrypted ClientHello Inner will be routed to the private target server contained in itself. 122 | # For instance, if the ClientHello Inner contains the SNI "my-private-backend.example.com" (Inner), 123 | # the connection will be routed to the private target server "my-private-backend.example.com:8448", 124 | # where the port number is preserved from the OuterClientHello, i.e., 8448 (listen port), *unless specified". 125 | # If it is specified with the port number, e.g., "xxx.com:443", the port number is forced to be used as the target port instead of listen port. 126 | # If decryption fails (e.g. the ECHConfigList is not matched to the OuterClientHello), the connection simply be passed to the target with no modification. Namely, the server names give above are the default target. 127 | [protocols."tls_4".ech] 128 | # ECHConfigList for the server, base64 encoded. 129 | ech_config_list = "" 130 | # Private keys for ECH decryption, matched to entries in ech_config_list. 131 | private_keys = [""] 132 | # Private server names that only be routed after ECH decryption towards the target address 133 | # This is used to route the connection to the private backend server. 134 | # Then port is the same as the listen port unless specified. 135 | private_server_names = [ 136 | "my-private-backend.example.com", 137 | "my-private-backend.example.org:8448", 138 | ] 139 | 140 | 141 | ######################### 142 | ### Example for QUIC 143 | [protocols."quic"] 144 | # Name of protocol tls|ssh|http|wireguard|quic 145 | protocol = "quic" 146 | 147 | # Target for connections detected as QUIC. 148 | target = ["192.168.122.4:443"] 149 | 150 | # Load balancing method for QUIC connections [default: none] 151 | load_balance = "source_ip" 152 | 153 | # (Optional) Idle lifetime for QUIC connections in seconds [default: 30] 154 | idle_lifetime = 30 155 | 156 | # server_names = ["example.com", "example.org"] 157 | # ech = { private_keys = ["/path/to/private.key"], server_names = ["example.org"] } 158 | 159 | ######################### 160 | ### Example for SSH 161 | [protocols."ssh"] 162 | # Name of protocol tls|ssh|http|wireguard|quic 163 | protocol = "ssh" 164 | 165 | # Target for connections detected as SSH. 166 | target = ["192.168.122.4:22"] 167 | 168 | # Load balancing method for SSH connections [default: none] 169 | load_balance = "source_ip" 170 | 171 | 172 | # ################################# 173 | # # Experimental setting # 174 | # ################################# 175 | # [experimental] 176 | 177 | 178 | ################################################################################################ 179 | # NOT IMPLEMENTED JUST AN IDEA 180 | # # (Optional) Acceptable incoming ports for this protocol [default: same as `listen_ports`] 181 | # # If not specified, all TCP or UDP connections through `listen_ports` are probed for the specified protocol. 182 | # # If specified, only connections through the specified ports are probed. 183 | # listen_ports = [50443] 184 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | /// Errors that happens during the proxy operation 4 | #[derive(thiserror::Error, Debug)] 5 | pub enum ProxyError { 6 | /* --------------------------------------- */ 7 | #[error("IO error: {0}")] 8 | IoError(#[from] std::io::Error), 9 | 10 | /* --------------------------------------- */ 11 | /// Single destination: failed to get destination address 12 | #[error("No destination address{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 13 | NoDestinationAddress(String), 14 | 15 | /// No ECH private destination server name, 16 | /// happens when the decrypted private server name in ClientHello Inner is not in the configured ECH private server name list 17 | #[error("No ECH private destination server name{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 18 | EchNoMatchingPrivateServerName(String), 19 | 20 | /* --------------------------------------- */ 21 | #[error("No destination address for the protocol{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 22 | NoDestinationAddressForProtocol(String), 23 | 24 | #[error("Failed to read first few bytes of TCP stream{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 25 | TimeOutToReadTcpStream(String), 26 | 27 | #[error("No data received from TCP stream{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 28 | NoDataReceivedTcpStream(String), 29 | 30 | #[error("Too many UDP connections{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 31 | TooManyUdpConnections(String), 32 | 33 | #[error("Broken UDP connection{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 34 | BrokenUdpConnection(String), 35 | 36 | /* --------------------------------------- */ 37 | #[error("TLS or Quic error: {0}")] 38 | TlsError(#[from] quic_tls::TlsClientHelloError), 39 | 40 | #[error("TLS Alert write error{}", if .0.is_empty() { String::new() } else { format!(": {}", .0) })] 41 | TlsAlertWriteError(String), 42 | 43 | /* --------------------------------------- */ 44 | #[error("DNS resolution error: {0}")] 45 | DnsResolutionError(String), 46 | 47 | #[error("Invalid address: {0}")] 48 | InvalidAddress(String), 49 | } 50 | 51 | impl ProxyError { 52 | /// Add connection context to the error (source and destination addresses) 53 | pub fn with_connection_context(self, src_addr: SocketAddr, dst_addr: SocketAddr) -> Self { 54 | let context = format!("connection {src_addr} -> {dst_addr}"); 55 | match self { 56 | Self::IoError(io_err) => Self::IoError(std::io::Error::new(io_err.kind(), format!("{context}: {io_err}"))), 57 | Self::NoDestinationAddress(msg) => Self::NoDestinationAddress(if msg.is_empty() { 58 | context 59 | } else { 60 | format!("{context}: {msg}") 61 | }), 62 | Self::DnsResolutionError(msg) => Self::DnsResolutionError(format!("{context}: {msg}")), 63 | Self::InvalidAddress(addr) => Self::InvalidAddress(format!("{addr} for {context}")), 64 | _ => self, // For other errors, return as-is 65 | } 66 | } 67 | 68 | /// Add protocol context to the error 69 | pub fn with_protocol_context(self, protocol: &str) -> Self { 70 | let context = format!("{protocol} protocol"); 71 | match self { 72 | Self::NoDestinationAddressForProtocol(msg) => Self::NoDestinationAddressForProtocol(if msg.is_empty() { 73 | format!("no destination configured for {context}") 74 | } else { 75 | format!("{context}: {msg}") 76 | }), 77 | Self::TimeOutToReadTcpStream(msg) => Self::TimeOutToReadTcpStream(if msg.is_empty() { 78 | format!("timeout reading {context} stream") 79 | } else { 80 | format!("{context}: {msg}") 81 | }), 82 | Self::NoDataReceivedTcpStream(msg) => Self::NoDataReceivedTcpStream(if msg.is_empty() { 83 | format!("no data received from {context} stream") 84 | } else { 85 | format!("{context}: {msg}") 86 | }), 87 | Self::TooManyUdpConnections(msg) => Self::TooManyUdpConnections(if msg.is_empty() { 88 | format!("too many {context} connections") 89 | } else { 90 | format!("{context}: {msg}") 91 | }), 92 | Self::BrokenUdpConnection(msg) => Self::BrokenUdpConnection(if msg.is_empty() { 93 | format!("broken {context} connection") 94 | } else { 95 | format!("{context}: {msg}") 96 | }), 97 | Self::TlsAlertWriteError(msg) => Self::TlsAlertWriteError(if msg.is_empty() { 98 | format!("{context} alert write error") 99 | } else { 100 | format!("{context}: {msg}") 101 | }), 102 | Self::IoError(io_err) => Self::IoError(std::io::Error::new(io_err.kind(), format!("{context}: {io_err}"))), 103 | Self::DnsResolutionError(msg) => Self::DnsResolutionError(format!("{context}: {msg}")), 104 | _ => self, 105 | } 106 | } 107 | 108 | /// Add source address context to the error 109 | pub fn with_source_context(self, src_addr: SocketAddr) -> Self { 110 | let context = format!("from {src_addr}"); 111 | match self { 112 | Self::NoDestinationAddressForProtocol(msg) => Self::NoDestinationAddressForProtocol(if msg.is_empty() { 113 | format!("no destination address for protocol requested {context}") 114 | } else { 115 | format!("{context}: {msg}") 116 | }), 117 | Self::TimeOutToReadTcpStream(msg) => Self::TimeOutToReadTcpStream(if msg.is_empty() { 118 | format!("timeout reading TCP stream {context}") 119 | } else { 120 | format!("{context}: {msg}") 121 | }), 122 | Self::NoDataReceivedTcpStream(msg) => Self::NoDataReceivedTcpStream(if msg.is_empty() { 123 | format!("no data received from TCP stream {context}") 124 | } else { 125 | format!("{context}: {msg}") 126 | }), 127 | Self::TooManyUdpConnections(msg) => Self::TooManyUdpConnections(if msg.is_empty() { 128 | format!("too many UDP connections, rejecting connection {context}") 129 | } else { 130 | format!("{context}: {msg}") 131 | }), 132 | Self::BrokenUdpConnection(msg) => Self::BrokenUdpConnection(if msg.is_empty() { 133 | format!("broken UDP connection {context}") 134 | } else { 135 | format!("{context}: {msg}") 136 | }), 137 | _ => self, 138 | } 139 | } 140 | } 141 | 142 | /// Errors that happens during building the proxy 143 | #[derive(thiserror::Error, Debug)] 144 | pub enum ProxyBuildError { 145 | /* --------------------------------------- */ 146 | /// Configuration error: ech 147 | #[error("ECH config list error: {0}")] 148 | EchConfigError(#[from] quic_tls::EchConfigError), 149 | 150 | /// Invalid Ech private key 151 | #[error("Invalid ECH private key: {0}")] 152 | InvalidEchPrivateKey(String), 153 | 154 | /// Invalid Ech private server name 155 | #[error("Invalid ECH private server name: {0}")] 156 | InvalidEchPrivateServerName(String), 157 | 158 | /// Configuration error: protocol 159 | #[error("Unsupported protocol: {0}")] 160 | UnsupportedProtocol(String), 161 | 162 | /// Configuration error: load balance 163 | #[error("Invalid load balance: {0}")] 164 | InvalidLoadBalance(String), 165 | 166 | /// Single target destination builder error 167 | #[error("Target destination builder error: {0}")] 168 | TargetDestinationBuilderError(#[from] crate::destination::TargetDestinationBuilderError), 169 | 170 | /* --------------------------------------- */ 171 | /// Multiplexer builder error UDP 172 | #[error("UDP destination mux error: {0}")] 173 | UdpDestinationMuxError(#[from] crate::udp_proxy::UdpDestinationMuxBuilderError), 174 | 175 | /// Multiplexer builder error TCP 176 | #[error("TCP destination mux error: {0}")] 177 | TcpDestinationMuxError(#[from] crate::tcp_proxy::TcpDestinationMuxBuilderError), 178 | 179 | /// Both TCP UDP mux builder error called from the top level 180 | #[error("Build error for multiplexers: {0}")] 181 | BuildMultiplexersError(String), 182 | } 183 | 184 | #[cfg(test)] 185 | mod tests { 186 | use super::*; 187 | 188 | #[test] 189 | fn test_error_context_helpers() { 190 | let src_addr = "192.168.1.100:45000".parse().unwrap(); 191 | let dst_addr = "10.0.0.50:443".parse().unwrap(); 192 | 193 | // Test connection context 194 | let io_error = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"); 195 | let proxy_error = ProxyError::IoError(io_error); 196 | let contextual_error = proxy_error.with_connection_context(src_addr, dst_addr); 197 | 198 | let error_msg = format!("{contextual_error}"); 199 | assert!(error_msg.contains("192.168.1.100:45000")); 200 | assert!(error_msg.contains("10.0.0.50:443")); 201 | 202 | // Test protocol context 203 | let timeout_error = ProxyError::TimeOutToReadTcpStream(String::new()); 204 | let contextual_error = timeout_error.with_protocol_context("TLS"); 205 | let error_msg = format!("{contextual_error}"); 206 | assert!(error_msg.contains("TLS")); 207 | assert!(error_msg.contains("timeout")); 208 | 209 | // Test source context 210 | let no_dest_error = ProxyError::NoDestinationAddressForProtocol(String::new()); 211 | let contextual_error = no_dest_error.with_source_context(src_addr); 212 | let error_msg = format!("{contextual_error}"); 213 | assert!(error_msg.contains("192.168.1.100:45000")); 214 | assert!(error_msg.contains("protocol")); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /proxy-l4/src/main.rs: -------------------------------------------------------------------------------- 1 | #[global_allocator] 2 | static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; 3 | 4 | mod config; 5 | mod log; 6 | 7 | use crate::{config::parse_opts, log::*}; 8 | use config::{ConfigToml, ConfigTomlReloader}; 9 | use hot_reload::{ReloaderReceiver, ReloaderService}; 10 | use rpxy_l4_lib::*; 11 | use std::sync::Arc; 12 | 13 | /// Delay in seconds to watch the config file 14 | const CONFIG_WATCH_DELAY_SECS: u32 = 15; 15 | /// Listen on v4 address 16 | const LISTEN_ON_V4: &str = "0.0.0.0"; 17 | /// Listen on v6 address 18 | const LISTEN_ON_V6: &str = "[::]"; 19 | /// Access log file name 20 | pub(crate) const ACCESS_LOG_FILE: &str = "access.log"; 21 | /// System log file name 22 | pub(crate) const SYSTEM_LOG_FILE: &str = "rpxy-l4.log"; 23 | 24 | fn main() { 25 | let mut runtime_builder = tokio::runtime::Builder::new_multi_thread(); 26 | runtime_builder.enable_all(); 27 | runtime_builder.thread_name("rpxy-l4"); 28 | let runtime = runtime_builder.build().unwrap(); 29 | 30 | runtime.block_on(async { 31 | let Ok(parsed_opts) = parse_opts() else { 32 | error!("Invalid toml file"); 33 | std::process::exit(1); 34 | }; 35 | init_logger(parsed_opts.log_dir_path.as_deref()); 36 | 37 | info!("Starting rpxy for layer 4"); 38 | 39 | // config service watches the service config file. 40 | // if the base service config file is updated, the entrypoint will be restarted. 41 | let (config_service, config_rx) = ReloaderService::::with_delay( 42 | &parsed_opts.config_file_path, 43 | CONFIG_WATCH_DELAY_SECS, 44 | ) 45 | .await 46 | .unwrap(); 47 | 48 | tokio::select! { 49 | config_res = config_service.start() => { 50 | if let Err(e) = config_res { 51 | error!("config reloader service exited: {e}"); 52 | std::process::exit(1); 53 | } 54 | } 55 | res = entrypoint(config_rx, runtime.handle().clone()) => { 56 | if let Err(e) = res { 57 | error!("Service exited: {e}"); 58 | std::process::exit(1); 59 | } 60 | } 61 | } 62 | std::process::exit(0); 63 | }); 64 | } 65 | 66 | /// Entrypoint for the service 67 | async fn entrypoint( 68 | mut config_rx: ReloaderReceiver, 69 | runtime_handle: tokio::runtime::Handle, 70 | ) -> Result<(), anyhow::Error> { 71 | // Initial loading 72 | config_rx.changed().await?; 73 | let config_toml = config_rx 74 | .get() 75 | .ok_or(anyhow::anyhow!("Something wrong in config reloader receiver"))?; 76 | 77 | let mut proxy_service = ProxyService::try_new(&config_toml, runtime_handle.clone())?; 78 | 79 | // Continuous monitoring 80 | loop { 81 | // Notifier for proxy service termination 82 | let cancel_token = tokio_util::sync::CancellationToken::new(); 83 | 84 | tokio::select! { 85 | res = proxy_service.start(cancel_token.child_token()) => { 86 | if let Err(ref e) = res { 87 | error!("Proxy service stopped: {e}"); 88 | } else { 89 | error!("Proxy service exited"); 90 | } 91 | return res.map_err(|e| anyhow::anyhow!(e)); 92 | } 93 | _ = config_rx.changed() => { 94 | let Some(new_config_toml) = config_rx.get() else { 95 | error!("Something wrong in config reloader receiver"); 96 | return Err(anyhow::anyhow!("Something wrong in config reloader receiver")); 97 | }; 98 | match ProxyService::try_new(&new_config_toml, runtime_handle.clone()) { 99 | Ok(new_proxy_service) => { 100 | info!("Configuration reloaded"); 101 | proxy_service = new_proxy_service; 102 | } 103 | Err(e) => { 104 | error!("Failed to create a new proxy service: {e}"); 105 | } 106 | } 107 | 108 | // Kill the previous proxy service 109 | info!("Terminate all spawned services and force to re-bind TCP/UDP sockets"); 110 | cancel_token.cancel(); 111 | } 112 | } 113 | } 114 | } 115 | 116 | /* ---------------------------------------------------------- */ 117 | #[derive(Debug)] 118 | /// Proxy service struct 119 | struct ProxyService { 120 | runtime_handle: tokio::runtime::Handle, 121 | listen_port: u16, 122 | listen_ipv6: bool, 123 | tcp_backlog: Option, 124 | tcp_max_connections: Option, 125 | udp_max_connections: Option, 126 | tcp_proxy_mux: Arc, 127 | udp_proxy_mux: Arc, 128 | } 129 | 130 | impl ProxyService { 131 | /// Create a new proxy service 132 | fn try_new(config_toml: &ConfigToml, runtime_handle: tokio::runtime::Handle) -> Result { 133 | let config = Config::try_from(config_toml.clone())?; 134 | let (tcp_proxy_mux, udp_proxy_mux) = build_multiplexers(&config)?; 135 | 136 | let res = Self { 137 | runtime_handle, 138 | listen_port: config.listen_port, 139 | listen_ipv6: config.listen_ipv6, 140 | tcp_backlog: config.tcp_backlog, 141 | tcp_max_connections: config.tcp_max_connections, 142 | udp_max_connections: config.udp_max_connections, 143 | tcp_proxy_mux: Arc::new(tcp_proxy_mux), 144 | udp_proxy_mux: Arc::new(udp_proxy_mux), 145 | }; 146 | debug!("Service configuration: {:#?}", res); 147 | Ok(res) 148 | } 149 | 150 | /// Start the proxy service 151 | async fn start(&self, cancel_token: tokio_util::sync::CancellationToken) -> Result<(), anyhow::Error> { 152 | let mut join_handles = Vec::new(); 153 | 154 | let listen_on_v4 = format!("{LISTEN_ON_V4}:{}", self.listen_port).parse()?; 155 | let listen_on_v6 = format!("{LISTEN_ON_V6}:{}", self.listen_port).parse()?; 156 | /* -------------------------- Tcp -------------------------- */ 157 | if !self.tcp_proxy_mux.is_empty() { 158 | // connection count will be shared among all TCP proxies 159 | let tcp_conn_count = TcpConnectionCount::default(); 160 | let tcp_proxy_v4 = self 161 | .tcp_builder() 162 | .listen_on(listen_on_v4) 163 | .connection_count(tcp_conn_count.clone()) 164 | .build()?; 165 | let tcp_proxy_v4_handle = self.runtime_handle.spawn({ 166 | let cancel_token = cancel_token.child_token(); 167 | async move { 168 | if let Err(e) = tcp_proxy_v4.start(cancel_token).await { 169 | error!("TCPv4 proxy stopped: {e}"); 170 | } 171 | } 172 | }); 173 | join_handles.push(tcp_proxy_v4_handle); 174 | 175 | if self.listen_ipv6 { 176 | let tcp_proxy_v6 = self 177 | .tcp_builder() 178 | .listen_on(listen_on_v6) 179 | .connection_count(tcp_conn_count) 180 | .build()?; 181 | let tcp_proxy_v6_handle = self.runtime_handle.spawn({ 182 | let cancel_token = cancel_token.child_token(); 183 | async move { 184 | if let Err(e) = tcp_proxy_v6.start(cancel_token).await { 185 | error!("TCPv6 proxy stopped: {e}"); 186 | } 187 | } 188 | }); 189 | join_handles.push(tcp_proxy_v6_handle); 190 | } 191 | } 192 | 193 | /* -------------------------- Udp -------------------------- */ 194 | if !self.udp_proxy_mux.is_empty() { 195 | // connection count will be shared among all UDP proxies 196 | let udp_conn_count = UdpConnectionCount::::default(); 197 | let udp_proxy_v4 = self 198 | .udp_builder() 199 | .listen_on(listen_on_v4) 200 | .connection_count(udp_conn_count.clone()) 201 | .build()?; 202 | let udp_proxy_v4_handle = self.runtime_handle.spawn({ 203 | let cancel_token = cancel_token.child_token(); 204 | async move { 205 | if let Err(e) = udp_proxy_v4.start(cancel_token).await { 206 | error!("UDPv4 proxy stopped: {e}"); 207 | } 208 | } 209 | }); 210 | join_handles.push(udp_proxy_v4_handle); 211 | 212 | if self.listen_ipv6 { 213 | let udp_proxy_v6 = self 214 | .udp_builder() 215 | .listen_on(listen_on_v6) 216 | .connection_count(udp_conn_count) 217 | .build()?; 218 | let udp_proxy_v6_handle = self.runtime_handle.spawn({ 219 | let cancel_token = cancel_token.child_token(); 220 | async move { 221 | if let Err(e) = udp_proxy_v6.start(cancel_token).await { 222 | error!("UDPv6 proxy stopped: {e}"); 223 | } 224 | } 225 | }); 226 | join_handles.push(udp_proxy_v6_handle); 227 | } 228 | } 229 | 230 | if join_handles.is_empty() { 231 | error!("No proxy service is configured"); 232 | return Err(anyhow::anyhow!("No proxy service is configured")); 233 | } 234 | 235 | let _ = futures::future::select_all(join_handles.into_iter()).await; 236 | // Kill all spawned services 237 | cancel_token.cancel(); 238 | Ok(()) 239 | } 240 | 241 | /// Create a TCP proxy builder common for v4 and v6 242 | fn tcp_builder(&self) -> TcpProxyBuilder { 243 | let mut tcp_proxy_builder = TcpProxyBuilder::default(); 244 | tcp_proxy_builder 245 | .destination_mux(self.tcp_proxy_mux.clone()) 246 | .runtime_handle(self.runtime_handle.clone()); 247 | if let Some(tcp_backlog) = self.tcp_backlog { 248 | tcp_proxy_builder.backlog(tcp_backlog); 249 | } 250 | if let Some(tcp_max_connections) = self.tcp_max_connections { 251 | tcp_proxy_builder.max_connections(tcp_max_connections as usize); 252 | } 253 | tcp_proxy_builder 254 | } 255 | 256 | /// Create a UDP proxy builder common for v4 and v6 257 | fn udp_builder(&self) -> UdpProxyBuilder { 258 | let mut udp_proxy_builder = UdpProxyBuilder::default(); 259 | udp_proxy_builder 260 | .destination_mux(self.udp_proxy_mux.clone()) 261 | .runtime_handle(self.runtime_handle.clone()); 262 | if let Some(udp_max_connections) = self.udp_max_connections { 263 | udp_proxy_builder.max_connections(udp_max_connections as usize); 264 | } 265 | udp_proxy_builder 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /examples/src/bin/ech-client.rs: -------------------------------------------------------------------------------- 1 | //! **Test code based on [Rustls Examples](https://github.com/rustls/rustls/tree/main/examples)** 2 | //! Using static keys and ech config for testing 3 | //! - secret key: "KwyvZOuPlflYlcmJbwhA24HMWUvxXXyan/oh9cJ6lNw" 4 | //! - ech config list (base64): ADz+DQA4ugAgACA9U8FCH7vKOFXVCCcAdpUUSfu3rzlooRNflhOXyV0uTwAEAAEAAQAJbG9jYWxob3N0AAA 5 | //! 6 | //! `cargo run --package rpxy-l4-examples --bin ech-client -- --host localhost localhost www.defo.ie` 7 | //! 8 | //! ============================================================================ 9 | //! This is a simple example demonstrating how to use Encrypted Client Hello (ECH) with 10 | //! rustls and hickory-dns. 11 | //! 12 | //! Note that `unwrap()` is used to deal with networking errors; this is not something 13 | //! that is sensible outside of example code. 14 | //! 15 | //! Example usage: 16 | //! ``` 17 | //! cargo run --package rustls-examples --bin ech-client -- --host defo.ie defo.ie www.defo.ie 18 | //! ``` 19 | //! 20 | //! This will perform a DNS-over-HTTPS lookup for the defo.ie ECH config, using it to determine 21 | //! the plaintext SNI to send to the server. The protected encrypted SNI will be "www.defo.ie". 22 | //! An HTTP request for Host: defo.ie will be made once the handshake completes. You should 23 | //! observe output that contains: 24 | //! ``` 25 | //!

SSL_ECH_OUTER_SNI: cover.defo.ie
26 | //! SSL_ECH_INNER_SNI: www.defo.ie
27 | //! SSL_ECH_STATUS: success good
28 | //!

29 | //! ``` 30 | 31 | // use std::fs; 32 | use std::io::{BufReader, Read, Write, stdout}; 33 | use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; 34 | use std::sync::Arc; 35 | 36 | use base64::{Engine, prelude::BASE64_STANDARD_NO_PAD}; 37 | use clap::Parser; 38 | // use hickory_resolver::Resolver; 39 | // use hickory_resolver::config::{ResolverConfig, ResolverOpts}; 40 | // use hickory_resolver::proto::rr::rdata::svcb::{SvcParamKey, SvcParamValue}; 41 | // use hickory_resolver::proto::rr::{RData, RecordType}; 42 | use log::trace; 43 | use rustls::RootCertStore; 44 | use rustls::client::{EchConfig, EchGreaseConfig, EchStatus}; 45 | use rustls::crypto::aws_lc_rs; 46 | use rustls::crypto::aws_lc_rs::hpke::ALL_SUPPORTED_SUITES; 47 | use rustls::crypto::hpke::Hpke; 48 | use rustls::pki_types::pem::PemObject; 49 | use rustls::pki_types::{CertificateDer, EchConfigListBytes, ServerName}; 50 | 51 | const ECH_CONFIG: &str = "ADz+DQA4ugAgACA9U8FCH7vKOFXVCCcAdpUUSfu3rzlooRNflhOXyV0uTwAEAAEAAQAJbG9jYWxob3N0AAA"; 52 | const LOCAL_SOCK: &str = "127.0.0.1:8448"; 53 | 54 | fn main() { 55 | let args = Args::parse(); 56 | 57 | let static_ech_config = EchConfigListBytes::from(BASE64_STANDARD_NO_PAD.decode(ECH_CONFIG).unwrap()); 58 | 59 | // // Find raw ECH configs using DNS-over-HTTPS with Hickory DNS. 60 | // let resolver_config = if args.use_cloudflare_dns { 61 | // ResolverConfig::cloudflare_https() 62 | // } else { 63 | // ResolverConfig::google_https() 64 | // }; 65 | // let resolver = Resolver::new(resolver_config, ResolverOpts::default()).unwrap(); 66 | // let server_ech_config = match args.grease { 67 | // true => None, // Force the use of the GREASE ext by skipping ECH config lookup 68 | // false => match args.ech_config { 69 | // Some(path) => Some(read_ech(&path)), 70 | // None => lookup_ech_configs(&resolver, &args.outer_hostname, args.port), 71 | // }, 72 | // }; 73 | let server_ech_config = Some(static_ech_config.clone()); 74 | 75 | // NOTE: we defer setting up env_logger and setting the trace default filter level until 76 | // after doing the DNS-over-HTTPS lookup above - we don't want to muddy the output 77 | // with the rustls debug logs from the lookup. 78 | env_logger::Builder::new().parse_filters("trace").init(); 79 | 80 | let ech_mode = match server_ech_config { 81 | Some(ech_config_list) => EchConfig::new(ech_config_list, ALL_SUPPORTED_SUITES).unwrap().into(), 82 | None => { 83 | let (public_key, _) = GREASE_HPKE_SUITE.generate_key_pair().unwrap(); 84 | EchGreaseConfig::new(GREASE_HPKE_SUITE, public_key).into() 85 | } 86 | }; 87 | 88 | let root_store = match args.cafile { 89 | Some(file) => { 90 | let mut root_store = RootCertStore::empty(); 91 | root_store.add_parsable_certificates( 92 | CertificateDer::pem_file_iter(file) 93 | .expect("Cannot open CA file") 94 | .map(|result| result.unwrap()), 95 | ); 96 | root_store 97 | } 98 | None => RootCertStore { 99 | roots: webpki_roots::TLS_SERVER_ROOTS.into(), 100 | }, 101 | }; 102 | 103 | // Construct a rustls client config with a custom provider, and ECH enabled. 104 | let mut config = rustls::ClientConfig::builder_with_provider(aws_lc_rs::default_provider().into()) 105 | .with_ech(ech_mode) 106 | .unwrap() 107 | .with_root_certificates(root_store) 108 | .with_no_client_auth(); 109 | 110 | // Allow using SSLKEYLOGFILE. 111 | config.key_log = Arc::new(rustls::KeyLogFile::new()); 112 | let config = Arc::new(config); 113 | 114 | // The "inner" SNI that we're really trying to reach. 115 | let server_name: ServerName<'static> = args.inner_hostname.clone().try_into().unwrap(); 116 | 117 | for i in 0..args.num_reqs { 118 | trace!("\nRequest {} of {}", i + 1, args.num_reqs); 119 | let mut conn = rustls::ClientConnection::new(config.clone(), server_name.clone()).unwrap(); 120 | // The "outer" server that we're connecting to. 121 | // let sock_addr = (args.outer_hostname.as_str(), args.port) 122 | // .to_socket_addrs() 123 | // .unwrap() 124 | // .next() 125 | // .unwrap(); 126 | let sock_addr: std::net::SocketAddr = LOCAL_SOCK.parse().unwrap(); 127 | let mut sock = TcpStream::connect(sock_addr).unwrap(); 128 | let mut tls = rustls::Stream::new(&mut conn, &mut sock); 129 | 130 | let request = format!( 131 | "GET /{} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n", 132 | args.path, 133 | args.host.as_ref().unwrap_or(&args.inner_hostname), 134 | ); 135 | dbg!(&request); 136 | let res = tls.write_all(request.as_bytes()); 137 | if let Err(e) = res { 138 | eprintln!("Error writing to socket: {:#?}", e); 139 | return; 140 | } 141 | assert!(!tls.conn.is_handshaking()); 142 | assert_eq!( 143 | tls.conn.ech_status(), 144 | match args.grease { 145 | true => EchStatus::Grease, 146 | false => EchStatus::Accepted, 147 | } 148 | ); 149 | let mut plaintext = Vec::new(); 150 | tls.read_to_end(&mut plaintext).unwrap(); 151 | stdout().write_all(&plaintext).unwrap(); 152 | } 153 | } 154 | 155 | /// Connects to the TLS server at hostname:PORT. The default PORT 156 | /// is 443. If an ECH config can be fetched for hostname using 157 | /// DNS-over-HTTPS, ECH is enabled. Otherwise, a placeholder ECH 158 | /// extension is sent for anti-ossification testing. 159 | /// 160 | /// Example: 161 | /// ech-client --host defo.ie defo.ie www.defo.ie 162 | #[derive(Debug, Parser)] 163 | #[clap(version)] 164 | struct Args { 165 | /// Connect to this TCP port. 166 | #[clap(short, long, default_value = "443")] 167 | port: u16, 168 | 169 | /// Read root certificates from this file. 170 | /// 171 | /// If --cafile is not supplied, a built-in set of CA certificates 172 | /// are used from the webpki-roots crate. 173 | #[clap(long)] 174 | cafile: Option, 175 | 176 | /// HTTP GET this PATH. 177 | #[clap(long, default_value = "ech-check.php")] 178 | path: String, 179 | 180 | /// HTTP HOST to use for GET request (defaults to value of inner-hostname). 181 | #[clap(long)] 182 | host: Option, 183 | 184 | /// Use Google DNS for the DNS-over-HTTPS lookup (default). 185 | #[clap(long, group = "dns")] 186 | use_google_dns: bool, 187 | /// Use Cloudflare DNS for the DNS-over-HTTPS lookup. 188 | #[clap(long, group = "dns")] 189 | use_cloudflare_dns: bool, 190 | 191 | /// Skip looking up an ECH config and send a GREASE placeholder. 192 | #[clap(long)] 193 | grease: bool, 194 | 195 | /// Skip looking up an ECH config and read it from the provided file (in binary TLS encoding). 196 | #[clap(long)] 197 | ech_config: Option, 198 | 199 | /// Number of requests to make. 200 | #[clap(long, default_value = "1")] 201 | num_reqs: usize, 202 | 203 | /// Outer hostname. 204 | outer_hostname: String, 205 | 206 | /// Inner hostname. 207 | inner_hostname: String, 208 | } 209 | 210 | // // TODO(@cpu): consider upstreaming to hickory-dns 211 | // fn lookup_ech_configs(resolver: &Resolver, domain: &str, port: u16) -> Option> { 212 | // // For non-standard ports, lookup the ECHConfig using port-prefix naming 213 | // // See: https://datatracker.ietf.org/doc/html/rfc9460#section-9.1 214 | // let qname_to_lookup = match port { 215 | // 443 => domain.to_owned(), 216 | // port => format!("_{port}._https.{domain}"), 217 | // }; 218 | 219 | // resolver 220 | // .lookup(qname_to_lookup, RecordType::HTTPS) 221 | // .ok()? 222 | // .record_iter() 223 | // .find_map(|r| match r.data() { 224 | // RData::HTTPS(svcb) => svcb.svc_params().iter().find_map(|sp| match sp { 225 | // (SvcParamKey::EchConfigList, SvcParamValue::EchConfigList(e)) => Some(e.clone().0), 226 | // _ => None, 227 | // }), 228 | // _ => None, 229 | // }) 230 | // .map(Into::into) 231 | // } 232 | 233 | // fn read_ech(path: &str) -> EchConfigListBytes<'static> { 234 | // let file = fs::File::open(path).unwrap_or_else(|_| panic!("Cannot open ECH file: {path}")); 235 | // let mut reader = BufReader::new(file); 236 | // let mut bytes = Vec::new(); 237 | // reader 238 | // .read_to_end(&mut bytes) 239 | // .unwrap_or_else(|_| panic!("Cannot read ECH file: {path}")); 240 | // bytes.into() 241 | // } 242 | 243 | /// A HPKE suite to use for GREASE ECH. 244 | /// 245 | /// A real implementation should vary this suite across all of the suites that are supported. 246 | static GREASE_HPKE_SUITE: &dyn Hpke = aws_lc_rs::hpke::DH_KEM_X25519_HKDF_SHA256_AES_128; 247 | -------------------------------------------------------------------------------- /quic-tls/src/tls.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | SUPPORTED_TLS_VERSIONS, 3 | client_hello::{TlsClientHello, TlsHandshakeMessageHeader, probe_tls_client_hello, probe_tls_handshake_message}, 4 | error::{TlsClientHelloError, TlsProbeFailure}, 5 | serialize::{Deserialize, SerDeserError, Serialize, compose}, 6 | trace::*, 7 | }; 8 | use bytes::{Buf, BufMut, Bytes, BytesMut}; 9 | 10 | const TLS_RECORD_HEADER_LEN: usize = 5; 11 | const TLS_HANDSHAKE_CONTENT_TYPE: u8 = 0x16; 12 | const TLS_ALERT_CONTENT_TYPE: u8 = 0x15; 13 | 14 | /* ---------------------------------------------------------- */ 15 | #[derive(Debug, Clone, PartialEq, Eq)] 16 | /// TLS Record Layer 17 | pub struct TlsRecordHeader { 18 | /// Content type 19 | pub(crate) content_type: u8, 20 | /// Version 21 | pub(crate) version: u16, 22 | /// Length 23 | pub(crate) length: u16, 24 | } 25 | impl Default for TlsRecordHeader { 26 | fn default() -> Self { 27 | TlsRecordHeader { 28 | content_type: TLS_HANDSHAKE_CONTENT_TYPE, 29 | version: SUPPORTED_TLS_VERSIONS[0], 30 | length: 0, 31 | } 32 | } 33 | } 34 | impl Serialize for TlsRecordHeader { 35 | type Error = SerDeserError; 36 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error> { 37 | buf.put_u8(self.content_type); 38 | buf.put_u16(self.version); 39 | buf.put_u16(self.length); 40 | Ok(()) 41 | } 42 | } 43 | 44 | impl Deserialize for TlsRecordHeader { 45 | type Error = SerDeserError; 46 | fn deserialize(buf: &mut B) -> Result 47 | where 48 | Self: Sized, 49 | { 50 | if buf.remaining() < TLS_RECORD_HEADER_LEN { 51 | return Err(SerDeserError::ShortInput); 52 | } 53 | let content_type = buf.get_u8(); 54 | let version = buf.get_u16(); 55 | let length = buf.get_u16(); 56 | Ok(TlsRecordHeader { 57 | content_type, 58 | version, 59 | length, 60 | }) 61 | } 62 | } 63 | 64 | /* ---------------------------------------------------------- */ 65 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 66 | pub struct TlsClientHelloBuffer { 67 | /// Tls record header 68 | pub record_header: TlsRecordHeader, 69 | /// Tls handshake message 70 | pub handshake_message_header: TlsHandshakeMessageHeader, 71 | /// Tls client hello 72 | pub client_hello: TlsClientHello, 73 | } 74 | impl TlsClientHelloBuffer { 75 | /// Is Ech Outer 76 | pub fn is_ech_outer(&self) -> bool { 77 | self.client_hello.is_ech_outer() 78 | } 79 | /// to Bytes 80 | pub fn try_to_bytes(&self) -> Result { 81 | compose(self.clone()).map(|b| b.freeze()) 82 | } 83 | } 84 | 85 | impl Serialize for TlsClientHelloBuffer { 86 | type Error = TlsClientHelloError; 87 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error> { 88 | let client_hello_bytes = compose(self.client_hello)?; 89 | 90 | // Make length fields consistent 91 | let client_hello_len = client_hello_bytes.len(); 92 | let mut handshake_msg_len_field = [0u8; 3]; 93 | handshake_msg_len_field[0] = (client_hello_len >> 16) as u8; 94 | handshake_msg_len_field[1] = (client_hello_len >> 8) as u8; 95 | handshake_msg_len_field[2] = client_hello_len as u8; 96 | let mut handshake_message_header = self.handshake_message_header.clone(); 97 | handshake_message_header.length = handshake_msg_len_field; 98 | let handshake_message_header_bytes = compose(handshake_message_header)?; 99 | 100 | let record_layer_len_field = client_hello_len + handshake_message_header_bytes.len(); 101 | let mut record_header = self.record_header.clone(); 102 | record_header.length = record_layer_len_field as u16; 103 | let record_header_bytes = compose(record_header)?; 104 | 105 | buf.put_slice(&record_header_bytes); 106 | buf.put_slice(&handshake_message_header_bytes); 107 | buf.put_slice(&client_hello_bytes); 108 | Ok(()) 109 | } 110 | } 111 | 112 | impl Deserialize for TlsClientHelloBuffer { 113 | type Error = TlsClientHelloError; 114 | fn deserialize(buf: &mut B) -> Result 115 | where 116 | Self: Sized, 117 | { 118 | let record_header = TlsRecordHeader::deserialize(buf)?; 119 | let handshake_message_header = TlsHandshakeMessageHeader::deserialize(buf)?; 120 | let client_hello = TlsClientHello::deserialize(buf)?; 121 | 122 | Ok(TlsClientHelloBuffer { 123 | record_header, 124 | handshake_message_header, 125 | client_hello, 126 | }) 127 | } 128 | } 129 | 130 | /// Check if the buffer is a TLSPlaintext record 131 | /// This is inspired by https://github.com/yrutschle/sslh/blob/master/tls.c 132 | /// Support TLS Record layer fragmentation https://datatracker.ietf.org/doc/html/rfc8446#section-5.1 133 | pub fn probe_tls_handshake(buf: &mut B) -> Result { 134 | let mut tls_plaintext = BytesMut::new(); 135 | let mut record_headers = Vec::new(); 136 | 137 | while buf.remaining() > 0 { 138 | // TLS record header (5) 139 | if buf.remaining() < TLS_RECORD_HEADER_LEN { 140 | return Err(TlsProbeFailure::PollNext); 141 | } 142 | // TLS record header: https://tools.ietf.org/html/rfc5246#section-6.2 , https://datatracker.ietf.org/doc/html/rfc8446#section-5.1 143 | // - content type: 1 byte 144 | // - version: 2 bytes 145 | // - length: 2 bytes 146 | // content type should be 0x16 (handshake) 147 | let content_type = buf.get_u8(); 148 | if !content_type.eq(&TLS_HANDSHAKE_CONTENT_TYPE) { 149 | return Err(TlsProbeFailure::Failure); 150 | } 151 | 152 | // Initial client hello possibly has the legacy versions for interoperability, like 0x03 0x01 = TLS 1.0 153 | let tls_version = buf.get_u16(); 154 | if !SUPPORTED_TLS_VERSIONS.contains(&tls_version) { 155 | // Omit the legacy SSL and unknown versions 156 | return Err(TlsProbeFailure::Failure); 157 | } 158 | let payload_len = buf.get_u16() as usize; 159 | if buf.remaining() < payload_len { 160 | debug!("Read buffer for TLS handshake detection is not enough"); 161 | return Err(TlsProbeFailure::PollNext); 162 | } 163 | debug!("TLS Payload length: {}", payload_len); 164 | 165 | let b = buf.copy_to_bytes(payload_len); 166 | tls_plaintext.extend_from_slice(&b); 167 | 168 | record_headers.push(TlsRecordHeader { 169 | content_type, 170 | version: tls_version, 171 | length: payload_len as u16, 172 | }); 173 | } 174 | 175 | // Check if all the TLS record headers are the same 176 | if record_headers.len() > 1 { 177 | let first_header = &record_headers[0]; 178 | for header in &record_headers[1..] { 179 | if header != first_header { 180 | debug!("TLS record headers are not the same"); 181 | return Err(TlsProbeFailure::Failure); 182 | } 183 | } 184 | } 185 | 186 | // Check if the buffer is a TLS handshake 187 | let handshake_message_header = probe_tls_handshake_message(&mut tls_plaintext)?; 188 | 189 | // Check if the buffer is a TLS ClientHello 190 | match probe_tls_client_hello(&mut tls_plaintext) { 191 | Some(client_hello) => Ok(TlsClientHelloBuffer { 192 | record_header: record_headers[0].clone(), 193 | handshake_message_header, 194 | client_hello, 195 | }), 196 | None => Err(TlsProbeFailure::Failure), 197 | } 198 | } 199 | 200 | /* ---------------------------------------------------------- */ 201 | #[derive(Debug, Clone, PartialEq, Eq)] 202 | /// https://datatracker.ietf.org/doc/html/rfc8446#section-6 203 | pub struct TlsAlertBuffer { 204 | /// Tls record header 205 | pub record_header: TlsRecordHeader, 206 | /// alert level 207 | pub alert_level: TlsAlertLevel, 208 | /// alert description 209 | pub alert_description: TlsAlertDescription, 210 | } 211 | 212 | #[derive(Debug, Clone, PartialEq, Eq)] 213 | /// TLS Alert Level 214 | /// https://datatracker.ietf.org/doc/html/rfc8446#section-6 215 | #[allow(unused)] 216 | pub enum TlsAlertLevel { 217 | /// Warning 218 | Warning = 1, 219 | /// Fatal 220 | Fatal = 2, 221 | } 222 | 223 | #[derive(Debug, Clone, PartialEq, Eq)] 224 | /// TLS Alert Description 225 | /// https://datatracker.ietf.org/doc/html/rfc8446#section-6 226 | /// Define only some of the alert descriptions used for ECH 227 | #[allow(unused)] 228 | pub enum TlsAlertDescription { 229 | /// Illegal parameter 230 | IllegalParameter = 47, 231 | /// Decrypt error 232 | DecryptError = 21, 233 | } 234 | 235 | impl Default for TlsAlertBuffer { 236 | fn default() -> Self { 237 | Self::new(TlsAlertLevel::Fatal, TlsAlertDescription::IllegalParameter) 238 | } 239 | } 240 | 241 | impl TlsAlertBuffer { 242 | /// Create a new instance 243 | pub fn new(level: TlsAlertLevel, description: TlsAlertDescription) -> Self { 244 | Self { 245 | record_header: TlsRecordHeader { 246 | content_type: TLS_ALERT_CONTENT_TYPE, 247 | version: SUPPORTED_TLS_VERSIONS[0], 248 | length: 2, 249 | }, 250 | alert_level: level, 251 | alert_description: description, 252 | } 253 | } 254 | 255 | /// to Bytes 256 | pub fn to_bytes(&self) -> Bytes { 257 | compose(self.clone()) 258 | .expect("TlsAlertBuffer serialization should not fail") 259 | .freeze() 260 | } 261 | } 262 | 263 | impl Serialize for TlsAlertBuffer { 264 | type Error = SerDeserError; 265 | fn serialize(self, buf: &mut B) -> Result<(), Self::Error> { 266 | let record_header_bytes = compose(self.record_header)?; 267 | buf.put_slice(&record_header_bytes); 268 | buf.put_u8(self.alert_level as u8); 269 | buf.put_u8(self.alert_description as u8); 270 | Ok(()) 271 | } 272 | } 273 | 274 | impl Deserialize for TlsAlertBuffer { 275 | type Error = SerDeserError; 276 | fn deserialize(buf: &mut B) -> Result 277 | where 278 | Self: Sized, 279 | { 280 | let record_header = TlsRecordHeader::deserialize(buf)?; 281 | if buf.remaining() < 2 { 282 | return Err(SerDeserError::ShortInput); 283 | } 284 | let alert_level = match buf.get_u8() { 285 | 1 => TlsAlertLevel::Warning, 286 | 2 => TlsAlertLevel::Fatal, 287 | _ => return Err(SerDeserError::InvalidInput), 288 | }; 289 | let alert_description = match buf.get_u8() { 290 | 21 => TlsAlertDescription::DecryptError, 291 | 47 => TlsAlertDescription::IllegalParameter, 292 | _ => return Err(SerDeserError::InvalidInput), 293 | }; 294 | Ok(TlsAlertBuffer { 295 | record_header, 296 | alert_level, 297 | alert_description, 298 | }) 299 | } 300 | } 301 | 302 | /* ---------------------------------------------------------- */ 303 | #[cfg(test)] 304 | mod tests { 305 | use super::*; 306 | use crate::serialize::parse; 307 | 308 | #[test] 309 | fn test_tls_record_header_serdeser() { 310 | let header = TlsRecordHeader { 311 | content_type: TLS_HANDSHAKE_CONTENT_TYPE, 312 | version: SUPPORTED_TLS_VERSIONS[0], 313 | length: 1234, 314 | }; 315 | 316 | let mut serialized = compose(header.clone()).unwrap(); 317 | let deserialized: TlsRecordHeader = parse(&mut serialized).unwrap(); 318 | assert_eq!(header, deserialized); 319 | } 320 | 321 | #[test] 322 | fn test_tls_alert_buffer_serdeser() { 323 | let alert = TlsAlertBuffer::new(TlsAlertLevel::Fatal, TlsAlertDescription::IllegalParameter); 324 | 325 | let mut serialized = compose(alert.clone()).unwrap(); 326 | let deserialized: TlsAlertBuffer = parse(&mut serialized).unwrap(); 327 | assert_eq!(alert, deserialized); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/target.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | constants::{DNS_CACHE_MAX_TTL, DNS_CACHE_MIN_TTL}, 3 | error::ProxyError, 4 | }; 5 | use dashmap::DashMap; 6 | use hickory_resolver::{TokioResolver, config::ResolverOpts}; 7 | use std::{ 8 | fmt, 9 | net::SocketAddr, 10 | str::FromStr, 11 | time::{Duration, Instant}, 12 | }; 13 | use tracing::{debug, trace, warn}; 14 | 15 | /// Represents a target address that can be either a direct socket address or a domain name with port 16 | #[derive(Debug, Clone)] 17 | pub enum TargetAddr { 18 | /// Direct socket address (IP and port) 19 | Socket(SocketAddr), 20 | /// Domain name and port combination 21 | Domain(String, u16), 22 | } 23 | 24 | /// DNS cache entry containing resolved addresses with TTL information 25 | #[derive(Debug, Clone)] 26 | struct CacheEntry { 27 | /// Resolved socket addresses 28 | addresses: Vec, 29 | /// When this entry expires 30 | expires_at: Instant, 31 | } 32 | 33 | impl CacheEntry { 34 | /// Create a new cache entry 35 | fn new(addresses: Vec, expires_at: Instant) -> Self { 36 | Self { addresses, expires_at } 37 | } 38 | 39 | /// Check if this entry is expired 40 | fn is_expired(&self) -> bool { 41 | Instant::now() > self.expires_at 42 | } 43 | } 44 | 45 | /// DNS cache for domain name resolution with TTL-based expiration 46 | #[derive(Debug)] 47 | pub struct DnsCache { 48 | /// Cache entries indexed by domain name 49 | entries: DashMap, 50 | /// Minimum TTL to enforce (default: 30 seconds) 51 | min_ttl: Duration, 52 | /// Maximum TTL to enforce (default: 1 hour) 53 | max_ttl: Duration, 54 | } 55 | 56 | impl Default for DnsCache { 57 | fn default() -> Self { 58 | Self::new(DNS_CACHE_MIN_TTL, DNS_CACHE_MAX_TTL) 59 | } 60 | } 61 | 62 | impl DnsCache { 63 | /// Create a new DNS cache with specified TTL bounds 64 | pub fn new(min_ttl: Duration, max_ttl: Duration) -> Self { 65 | Self { 66 | entries: DashMap::new(), 67 | min_ttl, 68 | max_ttl, 69 | } 70 | } 71 | 72 | /// Get or resolve a domain name with caching 73 | pub async fn get_or_resolve(&self, domain: &str, port: u16) -> Result, ProxyError> { 74 | // Check cache first 75 | let Some(entry) = self.entries.get(domain) else { 76 | // No cache entry exists - resolve for the first time 77 | return self.resolve_and_cache(domain, port).await; 78 | }; 79 | let entry_clone = entry.value().clone(); 80 | drop(entry); 81 | 82 | if !entry_clone.is_expired() { 83 | debug!("DNS cache hit for domain: {}", domain); 84 | return Ok(entry_clone.addresses.clone()); 85 | } 86 | 87 | // Entry is expired - try to resolve, but keep old IPs as fallback 88 | match self.resolve_and_cache(domain, port).await { 89 | Ok(addresses) => Ok(addresses), 90 | Err(e) => { 91 | warn!("Failed to refresh expired DNS entry for {}: {}", domain, e); 92 | // Continue to use expired entry rather than failing 93 | Ok(entry_clone.addresses.clone()) 94 | } 95 | } 96 | } 97 | 98 | /// Resolve domain and update cache 99 | async fn resolve_and_cache(&self, domain: &str, port: u16) -> Result, ProxyError> { 100 | debug!("Resolving DNS for: {}", domain); 101 | 102 | // Create resolver with default system config 103 | let mut opts = ResolverOpts::default(); 104 | opts.cache_size = 0; // Disable internal cache since we implement our own 105 | let resolver = TokioResolver::builder_tokio() 106 | .map_err(|e| ProxyError::DnsResolutionError(format!("Failed to create resolver: {}", e)))? 107 | .with_options(opts) 108 | .build(); 109 | 110 | trace!("domain: {}", domain); 111 | 112 | // Perform DNS resolution 113 | let response = resolver 114 | .lookup_ip(domain) 115 | .await 116 | .map_err(|e| ProxyError::DnsResolutionError(format!("Failed to resolve {}: {}", domain, e)))?; 117 | 118 | trace!("Response: {:?}", response); 119 | 120 | if response.iter().next().is_none() { 121 | // Try using last known good IPs if available 122 | if let Some(entry) = self.entries.get(domain) { 123 | debug!("No new addresses found, using last known good IPs for {}", domain); 124 | return Ok(entry.addresses.clone()); 125 | } 126 | return Err(ProxyError::DnsResolutionError(format!("No addresses found for {}", domain))); 127 | } 128 | 129 | trace!("Response IPs: {:?}", response); 130 | 131 | // Convert IPs to socket addresses with port 132 | let addresses: Vec = response.iter().map(|ip| SocketAddr::new(ip, port)).collect(); 133 | 134 | trace!("Addresses: {:?}", addresses); 135 | 136 | // Get minimum TTL from DNS response (or use default) 137 | let expired_at = self.clamp_ttl(response.valid_until().clone()); 138 | 139 | trace!("Expired at: {:?}", expired_at); 140 | 141 | // Update cache with new addresses and TTL 142 | let entry = CacheEntry::new(addresses.clone(), expired_at); 143 | 144 | trace!("Cache entry: {:?}", entry); 145 | 146 | self.entries.insert(domain.to_string(), entry); 147 | 148 | trace!("Cache updated for {}: {:?}", domain, addresses); 149 | 150 | debug!( 151 | "DNS resolved {} to {} addresses, expires at {:?}", 152 | domain, 153 | addresses.len(), 154 | expired_at 155 | ); 156 | 157 | Ok(addresses) 158 | } 159 | 160 | /// Clamp TTL to configured bounds 161 | fn clamp_ttl(&self, expires_at: Instant) -> Instant { 162 | let ttl = expires_at.duration_since(Instant::now()); 163 | let min_ttl = self.min_ttl; 164 | let max_ttl = self.max_ttl; 165 | let clamped_ttl = if ttl < min_ttl { 166 | min_ttl 167 | } else if ttl > max_ttl { 168 | max_ttl 169 | } else { 170 | ttl 171 | }; 172 | Instant::now() + clamped_ttl 173 | } 174 | } 175 | 176 | impl TargetAddr { 177 | /// Validates if the given domain name follows basic DNS naming rules 178 | /// Allows alphanumeric characters (a-z, A-Z, 0-9), dots (.), and hyphens (-) 179 | /// Does not allow: 180 | /// - Empty domains 181 | /// - Domains longer than 253 characters 182 | /// - Consecutive dots 183 | /// - Leading or trailing dots 184 | fn validate_domain(domain: &str) -> bool { 185 | !domain.is_empty() 186 | && domain.len() <= 253 187 | && domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') 188 | && !domain.starts_with('.') 189 | && !domain.ends_with('.') 190 | && !domain.contains("..") 191 | } 192 | 193 | /// Resolves the target address using a DNS cache 194 | /// 195 | /// For Socket variants, returns the socket address directly. 196 | /// For Domain variants, uses the provided DNS cache for resolution with TTL-based caching. 197 | pub async fn resolve_cached(&self, cache: &DnsCache) -> Result, ProxyError> { 198 | match self { 199 | TargetAddr::Socket(addr) => Ok(vec![*addr]), 200 | TargetAddr::Domain(domain, port) => cache.get_or_resolve(domain, *port).await, 201 | } 202 | } 203 | 204 | /// Returns the domain or IP address as a string 205 | pub fn domain_or_ip(&self) -> String { 206 | match self { 207 | TargetAddr::Socket(addr) => addr.ip().to_string(), 208 | TargetAddr::Domain(domain, _) => domain.clone(), 209 | } 210 | } 211 | } 212 | 213 | impl fmt::Display for TargetAddr { 214 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 215 | match self { 216 | TargetAddr::Socket(addr) => write!(f, "{}", addr), 217 | TargetAddr::Domain(domain, port) => write!(f, "{}:{}", domain, port), 218 | } 219 | } 220 | } 221 | 222 | impl FromStr for TargetAddr { 223 | type Err = ProxyError; 224 | 225 | /// Parses a string into a TargetAddr 226 | /// 227 | /// The string should be in one of these formats: 228 | /// - IP:PORT (e.g., "127.0.0.1:8080") 229 | /// - DOMAIN:PORT (e.g., "example.com:8080") 230 | fn from_str(s: &str) -> Result { 231 | // First try to parse as a socket address 232 | if let Ok(socket_addr) = s.parse::() { 233 | return Ok(TargetAddr::Socket(socket_addr)); 234 | } 235 | 236 | // If that fails, try to parse as domain:port 237 | match s.rsplit_once(':') { 238 | Some((domain, port)) => { 239 | if !Self::validate_domain(domain) { 240 | return Err(ProxyError::InvalidAddress(String::from("Invalid domain name"))); 241 | } 242 | 243 | let port = port 244 | .parse::() 245 | .map_err(|_| ProxyError::InvalidAddress(String::from("Invalid port number")))?; 246 | Ok(TargetAddr::Domain(domain.to_string(), port)) 247 | } 248 | None => Err(ProxyError::InvalidAddress(String::from( 249 | "Invalid address format - missing port number", 250 | ))), 251 | } 252 | } 253 | } 254 | 255 | #[cfg(test)] 256 | mod tests { 257 | use super::*; 258 | 259 | #[test] 260 | fn test_parse_socket_addr() { 261 | let addr = "127.0.0.1:8080".parse::().unwrap(); 262 | match addr { 263 | TargetAddr::Socket(socket) => { 264 | assert_eq!(socket.to_string(), "127.0.0.1:8080"); 265 | } 266 | _ => panic!("Expected Socket variant"), 267 | } 268 | } 269 | 270 | #[test] 271 | fn test_parse_domain() { 272 | let addr = "example.com:8080".parse::().unwrap(); 273 | match addr { 274 | TargetAddr::Domain(domain, port) => { 275 | assert_eq!(domain, "example.com"); 276 | assert_eq!(port, 8080); 277 | } 278 | _ => panic!("Expected Domain variant"), 279 | } 280 | } 281 | 282 | #[test] 283 | fn test_invalid_address() { 284 | assert!("invalid".parse::().is_err()); 285 | assert!("invalid:invalid".parse::().is_err()); 286 | assert!("example.com".parse::().is_err()); 287 | assert!("..example.com:8080".parse::().is_err()); 288 | assert!("example..com:8080".parse::().is_err()); 289 | assert!(".example.com:8080".parse::().is_err()); 290 | assert!("example.com.:8080".parse::().is_err()); 291 | } 292 | 293 | #[test] 294 | fn test_display() { 295 | let socket_addr = TargetAddr::Socket("127.0.0.1:8080".parse().unwrap()); 296 | assert_eq!(socket_addr.to_string(), "127.0.0.1:8080"); 297 | 298 | let domain_addr = TargetAddr::Domain("example.com".to_string(), 8080); 299 | assert_eq!(domain_addr.to_string(), "example.com:8080"); 300 | } 301 | 302 | #[tokio::test] 303 | async fn test_dns_cache() { 304 | let cache = DnsCache::default(); 305 | 306 | // Test initial resolution 307 | let resolved1 = cache.get_or_resolve("localhost", 8080).await.unwrap(); 308 | assert!(!resolved1.is_empty()); 309 | 310 | // Test cache hit 311 | let resolved2 = cache.get_or_resolve("localhost", 8080).await.unwrap(); 312 | assert_eq!(resolved1, resolved2); 313 | } 314 | 315 | #[tokio::test] 316 | async fn test_dns_cache_expiration() { 317 | use tokio::time::sleep; 318 | 319 | // Create cache with short TTL bounds 320 | let cache = DnsCache::new( 321 | Duration::from_secs(1), // min TTL 322 | Duration::from_secs(2), // max TTL 323 | ); 324 | 325 | // Initial resolution - use Cloudflare DNS which should be reliable 326 | let test_domain = "one.one.one.one"; 327 | let resolved1 = cache.get_or_resolve(test_domain, 53).await.unwrap(); 328 | assert!(!resolved1.is_empty()); 329 | 330 | // Check the resolved IPs contain at least one Cloudflare DNS IP 331 | let resolved_ips: Vec = resolved1.iter().map(|addr| addr.ip().to_string()).collect(); 332 | let expected_ips = ["1.1.1.1", "1.0.0.1"]; 333 | assert!( 334 | expected_ips.iter().any(|ip| resolved_ips.contains(&ip.to_string())), 335 | "Expected one of {:?} in resolved IPs: {:?}", 336 | expected_ips, 337 | resolved_ips 338 | ); 339 | 340 | // Test cache hit (should be immediate, no DNS query) 341 | let resolved2 = cache.get_or_resolve(test_domain, 53).await.unwrap(); 342 | assert_eq!(resolved1, resolved2); 343 | 344 | // Wait for min TTL to expire 345 | sleep(Duration::from_secs(3)).await; 346 | 347 | // Should refetch DNS entry since TTL expired 348 | let resolved3 = cache.get_or_resolve(test_domain, 53).await.unwrap(); 349 | assert!(!resolved3.is_empty()); 350 | } 351 | 352 | #[tokio::test] 353 | async fn test_dns_ttl_bounds() { 354 | let min_ttl = Duration::from_secs(10); 355 | let max_ttl = Duration::from_secs(60); 356 | let cache = DnsCache::new(min_ttl, max_ttl); 357 | 358 | // Test TTL clamping 359 | let expires_at = Instant::now() + Duration::from_secs(5); 360 | let clamped = cache.clamp_ttl(expires_at); 361 | assert!(Instant::now() < clamped); 362 | } 363 | 364 | #[tokio::test] 365 | async fn test_dns_resolution_error() { 366 | let cache = DnsCache::default(); 367 | 368 | // First successful resolution 369 | let resolved1 = cache.get_or_resolve("localhost", 8080).await.unwrap(); 370 | 371 | // Try with invalid domain, should fail with no fallback since no cache exists 372 | let err = cache.get_or_resolve("invalid.domain", 8080).await.unwrap_err(); 373 | assert!(matches!(err, ProxyError::DnsResolutionError(_))); 374 | 375 | // Try localhost again, should still work 376 | let resolved2 = cache.get_or_resolve("localhost", 8080).await.unwrap(); 377 | assert_eq!(resolved1, resolved2); 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /.build/Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent none 3 | 4 | environment { 5 | // Define common variables used throughout the pipeline 6 | REPO_URL = 'https://github.com/junkurihara/rust-rpxy-l4.git' 7 | BINARY_NAME = 'rpxy-l4' 8 | // BUILD_VERSION is not set because it will be extracted from Cargo.toml in the first step 9 | // BUILD_VERSION = '' 10 | } 11 | 12 | stages { 13 | stage('Prepare Build Environment') { 14 | agent { 15 | kubernetes { 16 | inheritFrom 'default' 17 | yaml """ 18 | apiVersion: v1 19 | kind: Pod 20 | spec: 21 | containers: 22 | - name: rust-cargo 23 | image: rust:slim 24 | command: 25 | - cat 26 | tty: true 27 | """ 28 | } 29 | } 30 | steps { 31 | container('rust-cargo') { 32 | // Install git 33 | sh 'apt-get update && apt-get -y install git --no-install-recommends' 34 | 35 | // Clone and Prepare Repository 36 | sh "git clone ${REPO_URL}" 37 | 38 | dir('rust-rpxy-l4') { 39 | sh """ 40 | # Initialize and update submodules 41 | git submodule update --init 42 | """ 43 | 44 | // Extract BUILD_VERSION from Cargo.toml 45 | script { 46 | // Extract version from Cargo.toml and set it as an environment variable 47 | def buildVersion = sh(script: 'grep "^version" Cargo.toml | sed \'s/version = "\\([0-9.]*\\)"/\\1/\'', returnStdout: true).trim() 48 | 49 | if (buildVersion) { 50 | env.BUILD_VERSION = buildVersion 51 | echo "Using extracted version: ${env.BUILD_VERSION}" 52 | } else { 53 | error "Version not found in Cargo.toml" 54 | } 55 | } 56 | 57 | // Build the binary 58 | sh 'cargo build --release' 59 | 60 | // Prepare and stash files 61 | sh """ 62 | # Move binary to workspace root for easier access 63 | mv target/release/${BINARY_NAME} .. 64 | 65 | # Move necessary files for packaging 66 | mv .build/DEB/* .. 67 | mv .build/RPM/* .. 68 | mv .build/${BINARY_NAME}* .. 69 | mv .build/config.toml .. 70 | mv README.md .. 71 | mv LICENSE .. 72 | """ 73 | } 74 | 75 | // Stash files for use in later stages 76 | stash includes: "${BINARY_NAME}", name: "binary" 77 | stash includes: "control, postinst, prerm, postrm", name: "deb-files" 78 | stash includes: "${BINARY_NAME}.spec", name: "rpm-files" 79 | stash includes: "${BINARY_NAME}.service, config.toml, ${BINARY_NAME}-start.sh", name: "service-file" 80 | stash includes: "LICENSE, README.md", name: "docs" 81 | 82 | // Archive the binary as an artifact 83 | archiveArtifacts artifacts: "${BINARY_NAME}", allowEmptyArchive: false, fingerprint: true 84 | } 85 | } 86 | } 87 | 88 | stage('Build RPM Packages') { 89 | parallel { 90 | stage('Build EL9 RPM Package') { 91 | agent { 92 | kubernetes { 93 | inheritFrom 'default' 94 | yaml """ 95 | apiVersion: v1 96 | kind: Pod 97 | spec: 98 | containers: 99 | - name: rpm-build-el9 100 | image: rockylinux/rockylinux:9 101 | command: 102 | - cat 103 | tty: true 104 | """ 105 | } 106 | } 107 | steps { 108 | container('rpm-build-el9') { 109 | // Prepare the RPM build environment 110 | unstash 'binary' 111 | unstash 'rpm-files' 112 | unstash 'service-file' 113 | unstash 'docs' 114 | 115 | // Install necessary tools for RPM building 116 | sh 'dnf update -y && dnf install -y rpmdevtools tar' 117 | 118 | // Set EL version for EL9 119 | script { 120 | echo "Building for Rocky Linux 9 (EL9)" 121 | } 122 | 123 | // Create the RPM package 124 | sh """ 125 | # Create RPM build directory structure 126 | mkdir -p rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} 127 | mkdir -p ${BINARY_NAME}-${BUILD_VERSION} 128 | 129 | # Move files to the appropriate locations 130 | mv ${BINARY_NAME} ${BINARY_NAME}-start.sh ${BINARY_NAME}.service LICENSE README.md config.toml ${BINARY_NAME}-${BUILD_VERSION}/ 131 | tar -czf rpmbuild/SOURCES/${BINARY_NAME}-${BUILD_VERSION}.tar.gz ${BINARY_NAME}-${BUILD_VERSION}/ 132 | mv ${BINARY_NAME}.spec rpmbuild/SPECS/ 133 | 134 | # Update spec file with correct version and source 135 | sed -i 's/@BUILD_VERSION@/${BUILD_VERSION}/; s/@Source0@/${BINARY_NAME}-${BUILD_VERSION}.tar.gz/' rpmbuild/SPECS/${BINARY_NAME}.spec 136 | 137 | # Build the RPM package 138 | rpmbuild --define "_topdir ${WORKSPACE}/rpmbuild" --define "_version ${BUILD_VERSION}" -bb rpmbuild/SPECS/${BINARY_NAME}.spec 139 | 140 | # Move RPM to root for archiving 141 | mv rpmbuild/RPMS/x86_64/${BINARY_NAME}-${BUILD_VERSION}-1.el9.x86_64.rpm . 142 | """ 143 | 144 | // Archive the EL9 RPM package 145 | archiveArtifacts artifacts: "${BINARY_NAME}-${BUILD_VERSION}-1.el9.x86_64.rpm", allowEmptyArchive: false, fingerprint: true 146 | } 147 | } 148 | } 149 | 150 | stage('Build EL10 RPM Package') { 151 | agent { 152 | kubernetes { 153 | inheritFrom 'default' 154 | yaml """ 155 | apiVersion: v1 156 | kind: Pod 157 | spec: 158 | containers: 159 | - name: rpm-build-el10 160 | image: rockylinux/rockylinux:10 161 | command: 162 | - cat 163 | tty: true 164 | """ 165 | } 166 | } 167 | steps { 168 | container('rpm-build-el10') { 169 | // Prepare the RPM build environment 170 | unstash 'binary' 171 | unstash 'rpm-files' 172 | unstash 'service-file' 173 | unstash 'docs' 174 | 175 | // Install necessary tools for RPM building 176 | sh 'dnf update -y && dnf install -y rpmdevtools tar' 177 | 178 | // Set EL version for EL10 179 | script { 180 | echo "Building for Rocky Linux 10 (EL10)" 181 | } 182 | 183 | // Create the RPM package 184 | sh """ 185 | # Create RPM build directory structure 186 | mkdir -p rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} 187 | mkdir -p ${BINARY_NAME}-${BUILD_VERSION} 188 | 189 | # Move files to the appropriate locations 190 | mv ${BINARY_NAME} ${BINARY_NAME}-start.sh ${BINARY_NAME}.service LICENSE README.md config.toml ${BINARY_NAME}-${BUILD_VERSION}/ 191 | tar -czf rpmbuild/SOURCES/${BINARY_NAME}-${BUILD_VERSION}.tar.gz ${BINARY_NAME}-${BUILD_VERSION}/ 192 | mv ${BINARY_NAME}.spec rpmbuild/SPECS/ 193 | 194 | # Update spec file with correct version and source 195 | sed -i 's/@BUILD_VERSION@/${BUILD_VERSION}/; s/@Source0@/${BINARY_NAME}-${BUILD_VERSION}.tar.gz/' rpmbuild/SPECS/${BINARY_NAME}.spec 196 | 197 | # Build the RPM package 198 | rpmbuild --define "_topdir ${WORKSPACE}/rpmbuild" --define "_version ${BUILD_VERSION}" -bb rpmbuild/SPECS/${BINARY_NAME}.spec 199 | 200 | # Move RPM to root for archiving 201 | mv rpmbuild/RPMS/x86_64/${BINARY_NAME}-${BUILD_VERSION}-1.el10.x86_64.rpm . 202 | """ 203 | 204 | // Archive the EL10 RPM package 205 | archiveArtifacts artifacts: "${BINARY_NAME}-${BUILD_VERSION}-1.el10.x86_64.rpm", allowEmptyArchive: false, fingerprint: true 206 | } 207 | } 208 | } 209 | } 210 | } 211 | 212 | stage('Build DEB Package') { 213 | agent { 214 | kubernetes { 215 | inheritFrom 'default' 216 | yaml """ 217 | apiVersion: v1 218 | kind: Pod 219 | spec: 220 | containers: 221 | - name: debian-build 222 | image: debian:stable-slim 223 | command: 224 | - cat 225 | tty: true 226 | """ 227 | } 228 | } 229 | steps { 230 | container('debian-build') { 231 | // Prepare the DEB build environment 232 | unstash 'binary' 233 | unstash 'deb-files' 234 | unstash 'service-file' 235 | unstash 'docs' 236 | 237 | // Install necessary tools for DEB building 238 | sh 'apt-get update && apt-get install -y dpkg-dev --no-install-recommends' 239 | 240 | // Create the DEB package 241 | sh """ 242 | # Define DEB package directory 243 | DEB_DIR=${BINARY_NAME}_${BUILD_VERSION}-1_amd64 244 | 245 | # Create directory structure for DEB package 246 | bash -c \"mkdir -p \$DEB_DIR/{DEBIAN,usr/{bin,share/doc/${BINARY_NAME}},etc/{systemd/system,${BINARY_NAME}}}\" 247 | 248 | # Move files to appropriate locations 249 | mv postinst prerm postrm \$DEB_DIR/DEBIAN/ 250 | chmod 755 \$DEB_DIR/DEBIAN/postinst 251 | chmod 755 \$DEB_DIR/DEBIAN/prerm 252 | chmod 755 \$DEB_DIR/DEBIAN/postrm 253 | mv ${BINARY_NAME}-start.sh \$DEB_DIR/usr/bin/ 254 | chmod 0755 \$DEB_DIR/usr/bin/${BINARY_NAME}-start.sh 255 | mv ${BINARY_NAME} \$DEB_DIR/usr/bin/ 256 | mv ${BINARY_NAME}.service \$DEB_DIR/etc/systemd/system/ 257 | mv LICENSE README.md \$DEB_DIR/usr/share/doc/${BINARY_NAME}/ 258 | mv config.toml \$DEB_DIR/etc/${BINARY_NAME}/ 259 | mv control \$DEB_DIR/DEBIAN/ 260 | 261 | # Update control file with correct version 262 | sed -i 's/@BUILD_VERSION@/${BUILD_VERSION}/' \$DEB_DIR/DEBIAN/control 263 | 264 | # Build the DEB package 265 | dpkg-deb --build --root-owner-group \$DEB_DIR 266 | """ 267 | 268 | // Archive the DEB package 269 | archiveArtifacts artifacts: "${BINARY_NAME}_${BUILD_VERSION}-1_amd64.deb", allowEmptyArchive: false, fingerprint: true 270 | } 271 | } 272 | } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/probe.rs: -------------------------------------------------------------------------------- 1 | use crate::{constants::TCP_PROTOCOL_DETECTION_BUFFER_SIZE, error::ProxyError, trace::*}; 2 | use bytes::BytesMut; 3 | use quic_tls::{TlsClientHello, TlsClientHelloBuffer, TlsProbeFailure, probe_quic_initial_packets, probe_tls_handshake}; 4 | use std::{ 5 | collections::HashSet, 6 | sync::{Arc, atomic::AtomicU64}, 7 | }; 8 | use tokio::{io::AsyncReadExt, net::TcpStream}; 9 | 10 | #[derive(Clone, Debug, PartialEq, Eq)] 11 | /// Probe result 12 | pub(crate) enum ProbeResult { 13 | /// Success to probe protocol 14 | Success(T), 15 | /// Not enough buffer to probe 16 | PollNext, 17 | /// Failed to probe 18 | Failure, 19 | } 20 | 21 | /* ---------------------------------------------------------- */ 22 | // TCP Protocol Detection Functions 23 | /* ---------------------------------------------------------- */ 24 | 25 | #[derive(Debug, Clone, PartialEq, Eq)] 26 | /// Probed TCP proxy protocol, specific protocols like SSH, and default is "any". 27 | pub(crate) enum TcpProbedProtocol { 28 | /// any, default 29 | Any, 30 | /// SSH 31 | Ssh, 32 | /// Plaintext HTTP 33 | Http, 34 | /// TLS 35 | Tls(TlsClientHelloBuffer), 36 | // TODO: and more ... 37 | } 38 | 39 | impl TcpProbedProtocol { 40 | /// Convert to the corresponding protocol type 41 | pub(crate) fn proto_type(&self) -> crate::proto::TcpProtocolType { 42 | match self { 43 | Self::Any => crate::proto::TcpProtocolType::Any, 44 | Self::Ssh => crate::proto::TcpProtocolType::Ssh, 45 | Self::Http => crate::proto::TcpProtocolType::Http, 46 | Self::Tls(_) => crate::proto::TcpProtocolType::Tls, 47 | } 48 | } 49 | } 50 | 51 | impl std::fmt::Display for TcpProbedProtocol { 52 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 53 | match self { 54 | Self::Any => write!(f, "Any"), 55 | Self::Ssh => write!(f, "SSH"), 56 | Self::Http => write!(f, "HTTP"), 57 | Self::Tls(_) => write!(f, "TLS"), 58 | // TODO: and more... 59 | } 60 | } 61 | } 62 | 63 | /// Poll the incoming TCP stream to detect the protocol 64 | async fn read_tcp_stream(incoming_stream: &mut TcpStream, buf: &mut BytesMut) -> Result { 65 | let read_len = incoming_stream.read_buf(buf).await?; 66 | if read_len == 0 { 67 | error!("No data received"); 68 | return Err(ProxyError::NoDataReceivedTcpStream(String::new())); 69 | } 70 | Ok(read_len) 71 | } 72 | 73 | /// Detect SSH protocol 74 | pub(crate) fn detect_ssh(buf: &[u8]) -> ProbeResult { 75 | if buf.len() < 4 { 76 | return ProbeResult::PollNext; 77 | } 78 | if buf.starts_with(b"SSH-") { 79 | debug!("SSH connection detected"); 80 | ProbeResult::Success(TcpProbedProtocol::Ssh) 81 | } else { 82 | ProbeResult::Failure 83 | } 84 | } 85 | 86 | /// Detect HTTP protocol 87 | pub(crate) fn detect_http(buf: &[u8]) -> ProbeResult { 88 | if buf.len() < 4 { 89 | return ProbeResult::PollNext; 90 | } 91 | if buf.windows(4).any(|w| w.eq(b"HTTP")) { 92 | debug!("HTTP connection detected"); 93 | ProbeResult::Success(TcpProbedProtocol::Http) 94 | } else { 95 | ProbeResult::Failure 96 | } 97 | } 98 | 99 | /// Detect TLS handshake 100 | pub(crate) fn detect_tls_handshake(buf: &[u8]) -> ProbeResult { 101 | let mut buf = BytesMut::from(buf); 102 | match probe_tls_handshake(&mut buf) { 103 | Err(TlsProbeFailure::Failure) => ProbeResult::Failure, 104 | Err(TlsProbeFailure::PollNext) => ProbeResult::PollNext, 105 | Ok(chi) => ProbeResult::Success(TcpProbedProtocol::Tls(chi)), 106 | } 107 | } 108 | 109 | impl TcpProbedProtocol { 110 | /// Detect the protocol from the first few bytes of the incoming stream 111 | pub(crate) async fn detect_protocol( 112 | incoming_stream: &mut TcpStream, 113 | buf: &mut BytesMut, 114 | ) -> Result, ProxyError> { 115 | let mut probe_functions = vec![detect_ssh, detect_http, detect_tls_handshake]; 116 | 117 | while !probe_functions.is_empty() { 118 | // Read the first several bytes to probe. at the first loop, the buffer is empty. 119 | let mut next_buf = BytesMut::with_capacity(TCP_PROTOCOL_DETECTION_BUFFER_SIZE); 120 | let _read_len = read_tcp_stream(incoming_stream, &mut next_buf).await?; 121 | buf.extend_from_slice(&next_buf[..]); 122 | 123 | // Check probe functions 124 | #[allow(clippy::type_complexity)] 125 | let (new_probe_fns, probe_res): (Vec ProbeResult<_>>, Vec<_>) = probe_functions 126 | .into_iter() 127 | .filter_map(|f| { 128 | let res = f(buf); 129 | match res { 130 | ProbeResult::Success(_) | ProbeResult::PollNext => Some((f, res)), 131 | _ => None, 132 | } 133 | }) 134 | .unzip(); 135 | 136 | // If any of them returns Success, return the protocol. 137 | if let Some(probe_success) = probe_res.into_iter().find(|r| matches!(r, ProbeResult::Success(_))) { 138 | return Ok(probe_success); 139 | }; 140 | 141 | // If the rest returned PollNext, fetch more data 142 | probe_functions = new_probe_fns; 143 | } 144 | 145 | debug!("Untyped TCP connection"); 146 | Ok(ProbeResult::Success(Self::Any)) 147 | } 148 | } 149 | 150 | /* ---------------------------------------------------------- */ 151 | // UDP Protocol Detection Functions 152 | /* ---------------------------------------------------------- */ 153 | 154 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 155 | /// UDP probed protocol, specific protocols like Wireguard and QUIC, and default is "any". 156 | pub(crate) enum UdpProbedProtocol { 157 | /// any, default 158 | Any, 159 | /// wireguard 160 | Wireguard, 161 | /// quic 162 | Quic(TlsClientHello), 163 | // TODO: and more ... 164 | } 165 | 166 | impl UdpProbedProtocol { 167 | /// Convert to the corresponding protocol type 168 | pub(crate) fn proto_type(&self) -> crate::proto::UdpProtocolType { 169 | match self { 170 | Self::Any => crate::proto::UdpProtocolType::Any, 171 | Self::Wireguard => crate::proto::UdpProtocolType::Wireguard, 172 | Self::Quic(_) => crate::proto::UdpProtocolType::Quic, 173 | } 174 | } 175 | } 176 | 177 | impl std::fmt::Display for UdpProbedProtocol { 178 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 179 | match self { 180 | Self::Any => write!(f, "Any"), 181 | Self::Wireguard => write!(f, "Wireguard"), 182 | Self::Quic(_) => write!(f, "QUIC"), 183 | // TODO: and more... 184 | } 185 | } 186 | } 187 | 188 | #[derive(Clone)] 189 | /// UDP initial datagrams buffer for protocol detection 190 | pub(crate) struct UdpInitialDatagrams { 191 | /// inner buffer of multiple UDP datagram payloads 192 | pub(crate) inner: Vec>, 193 | /// created at 194 | pub(crate) created_at: Arc, 195 | /// Protocols that were detected as 'poll_next' 196 | pub(crate) probed_as_pollnext: HashSet, 197 | } 198 | 199 | impl UdpInitialDatagrams { 200 | /// Get the first datagram 201 | pub(crate) fn first(&self) -> Option<&[u8]> { 202 | self.inner.first().map(|v| v.as_slice()) 203 | } 204 | } 205 | 206 | /// Detect Wireguard protocol 207 | pub(crate) fn detect_wireguard(initial_datagrams: &mut UdpInitialDatagrams) -> ProbeResult { 208 | // Wireguard protocol 'initiation' detection [only Handshake] 209 | // Thus this may not be a reliable way to detect Wireguard protocol 210 | // since UDP connection will be lost if the handshake interval is set to be longer than the connection timeout. 211 | // https://www.wireguard.com/protocol/ 212 | let Some(first) = initial_datagrams.first() else { 213 | return ProbeResult::Failure; // unreachable. just in case. 214 | }; 215 | 216 | if first.len() == 148 && first[0] == 0x01 && first[1] == 0x00 && first[2] == 0x00 && first[3] == 0x00 { 217 | debug!("Wireguard protocol (initiator to responder first message) detected"); 218 | ProbeResult::Success(UdpProbedProtocol::Wireguard) 219 | } else { 220 | ProbeResult::Failure 221 | } 222 | } 223 | 224 | /// Detect QUIC protocol 225 | pub(crate) fn detect_quic_initial(initial_datagrams: &mut UdpInitialDatagrams) -> ProbeResult { 226 | let initial_datagrams_inner = initial_datagrams.inner.as_slice(); 227 | 228 | match probe_quic_initial_packets(initial_datagrams_inner) { 229 | Err(TlsProbeFailure::Failure) => ProbeResult::Failure, 230 | Err(TlsProbeFailure::PollNext) => { 231 | initial_datagrams 232 | .probed_as_pollnext 233 | .insert(UdpProbedProtocol::Quic(Default::default())); 234 | ProbeResult::PollNext 235 | } 236 | Ok(client_hello_info) => ProbeResult::Success(UdpProbedProtocol::Quic(client_hello_info)), 237 | } 238 | } 239 | 240 | impl UdpProbedProtocol { 241 | /// Detect the protocol from the first few bytes of the incoming datagram 242 | pub(crate) async fn detect_protocol(initial_datagrams: &mut UdpInitialDatagrams) -> Result, ProxyError> { 243 | // TODO: Add more protocol detection patterns 244 | 245 | // Probe functions 246 | let probe_functions = if initial_datagrams.probed_as_pollnext.is_empty() { 247 | // No candidate probed as PollNext, i.e., Round 1 248 | vec![detect_wireguard, detect_quic_initial] 249 | } else { 250 | // Round 2 or later 251 | initial_datagrams 252 | .probed_as_pollnext 253 | .iter() 254 | .map(|p| match p { 255 | UdpProbedProtocol::Wireguard => detect_wireguard, 256 | UdpProbedProtocol::Quic(_) => detect_quic_initial, 257 | _ => unreachable!(), 258 | }) 259 | .collect() 260 | }; 261 | 262 | let probe_res = probe_functions.into_iter().map(|f| f(initial_datagrams)).collect::>(); 263 | 264 | // In case any of the probe results is a success, return it 265 | if let Some(probe_success) = probe_res.iter().find(|r| matches!(r, ProbeResult::Success(_))) { 266 | return Ok(probe_success.clone()); 267 | }; 268 | 269 | // In case any of the probe results is PollNext, return it 270 | if let Some(probe_pollnext) = probe_res.iter().find(|r| matches!(r, ProbeResult::PollNext)) { 271 | return Ok(probe_pollnext.to_owned()); 272 | }; 273 | 274 | // All detection finished as failure 275 | debug!("Untyped UDP connection detected"); 276 | Ok(ProbeResult::Success(Self::Any)) 277 | } 278 | } 279 | 280 | #[cfg(test)] 281 | mod tests { 282 | use super::*; 283 | use crate::time_util::get_since_the_epoch; 284 | 285 | #[test] 286 | fn test_ssh_detection() { 287 | // Test SSH-2.0 protocol detection 288 | let ssh_data = b"SSH-2.0-OpenSSH_8.3"; 289 | assert_eq!(detect_ssh(ssh_data), ProbeResult::Success(TcpProbedProtocol::Ssh)); 290 | 291 | // Test non-SSH data 292 | let non_ssh_data = b"HTTP/1.1 200 OK"; 293 | assert_eq!(detect_ssh(non_ssh_data), ProbeResult::Failure); 294 | 295 | // Test insufficient data 296 | let short_data = b"SS"; 297 | assert_eq!(detect_ssh(short_data), ProbeResult::PollNext); 298 | } 299 | 300 | #[test] 301 | fn test_http_detection() { 302 | // Test HTTP detection 303 | let http_data = b"GET / HTTP/1.1\r\nHost: example.com\r\n"; 304 | assert_eq!(detect_http(http_data), ProbeResult::Success(TcpProbedProtocol::Http)); 305 | 306 | // Test HTTP response detection 307 | let http_response = b"HTTP/1.1 200 OK\r\n"; 308 | assert_eq!(detect_http(http_response), ProbeResult::Success(TcpProbedProtocol::Http)); 309 | 310 | // Test non-HTTP data 311 | let non_http_data = b"SSH-2.0-OpenSSH_8.3"; 312 | assert_eq!(detect_http(non_http_data), ProbeResult::Failure); 313 | 314 | // Test insufficient data 315 | let short_data = b"HTT"; 316 | assert_eq!(detect_http(short_data), ProbeResult::PollNext); 317 | } 318 | 319 | #[test] 320 | fn test_tls_detection() { 321 | // Test invalid TLS data - should fail 322 | let invalid_tls = b"not a tls handshake"; 323 | assert_eq!(detect_tls_handshake(invalid_tls), ProbeResult::Failure); 324 | 325 | // Test insufficient data for TLS detection 326 | let short_data = b"abc"; 327 | assert_eq!(detect_tls_handshake(short_data), ProbeResult::PollNext); 328 | 329 | // Note: Testing valid TLS handshakes would require constructing complex binary data 330 | // which is beyond the scope of this unit test. The TLS detection logic is tested 331 | // via the quic_tls crate's own tests. 332 | } 333 | 334 | #[test] 335 | fn test_wireguard_detection() { 336 | // Create a valid Wireguard initiation packet (148 bytes, starts with 0x01000000) 337 | let mut wg_data = vec![0u8; 148]; 338 | wg_data[0] = 0x01; 339 | wg_data[1] = 0x00; 340 | wg_data[2] = 0x00; 341 | wg_data[3] = 0x00; 342 | 343 | let mut initial_datagrams = UdpInitialDatagrams { 344 | inner: vec![wg_data], 345 | created_at: Arc::new(AtomicU64::new(get_since_the_epoch())), 346 | probed_as_pollnext: Default::default(), 347 | }; 348 | 349 | assert_eq!( 350 | detect_wireguard(&mut initial_datagrams), 351 | ProbeResult::Success(UdpProbedProtocol::Wireguard) 352 | ); 353 | 354 | // Test invalid Wireguard data 355 | let invalid_wg = vec![0u8; 100]; // Wrong length 356 | let mut initial_datagrams_invalid = UdpInitialDatagrams { 357 | inner: vec![invalid_wg], 358 | created_at: Arc::new(AtomicU64::new(get_since_the_epoch())), 359 | probed_as_pollnext: Default::default(), 360 | }; 361 | 362 | assert_eq!(detect_wireguard(&mut initial_datagrams_invalid), ProbeResult::Failure); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rpxy-l4: A reverse proxy for the layer-4 (TCP+UDP) with protocol multiplexer, written in Rust 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 4 | ![Unit Test](https://github.com/junkurihara/rust-rpxy-l4/actions/workflows/ci.yml/badge.svg) 5 | ![Container Build](https://github.com/junkurihara/rust-rpxy-l4/actions/workflows/docker.yml/badge.svg) 6 | ![Release](https://github.com/junkurihara/rust-rpxy-l4/actions/workflows/release.yml/badge.svg) 7 | [![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/jqtype/rpxy-l4)](https://hub.docker.com/r/jqtype/rpxy-l4) 8 | 9 | > **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. 10 | 11 | ## Introduction 12 | 13 | `rpxy-l4` is an L4 reverse proxy supporting both TCP and UDP protocols, which is designed on the same philosophy as [`rpxy`](https://github.com/junkurihara/rust-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. 14 | 15 | ## Features 16 | 17 | - **Basic L4 reverse proxy feature**: `rpxy-l4` can forward TCP and UDP packets to the backend server. 18 | - **Protocol multiplexing**: `rpxy-l4` can multiplex multiple protocols over TCP/UDP on the same port, which means `rpxy-l4` routes specific protocols to their corresponding backend servers. Currently, it supports the following protocols: 19 | - TCP: HTTP (cleartext), TLS, SSH 20 | - UDP: QUIC (IETF QUIC[^quic]), WireGuard 21 | - **Load balancing**: `rpxy-l4` can distribute incoming connections to multiple backend servers based on the several simple load balancing algorithms. 22 | - **Protocol sanitization**: `rpxy-l4` can 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.) 23 | - **TLS/QUIC forwarder**: `rpxy-l4` can forward TLS/IETF QUIC streams to appropriate backend servers based on the ServerName Indication (SNI) and Application Layer Protocol Negotiation (ALPN) values. 24 | - **[Experimental] TLS Encrypted Client Hello (ECH) proxy**: `rpxy-l4` works as a proxy[^ech_proxy] to serve TLS/QUIC streams with IETF-Draft Encrypted Client Hello. In other words, `rpxy-l4` hosts ECH private keys and decrypts the ECH-encrypted Client Hello to route the stream to the appropriate backend server. 25 | 26 | [^quic]: Not Google QUIC. Both QUIC v1 ([RFC9000](https://datatracker.ietf.org/doc/html/rfc9000), [RFC9001](https://datatracker.ietf.org/doc/html/rfc9001)) and QUIC v2 ([RFC9369](https://datatracker.ietf.org/doc/html/rfc9369)) are supported. 27 | 28 | [^ech_proxy]: Client facing server in the context of [ECH Split Mode](https://www.ietf.org/archive/id/draft-ietf-tls-esni-24.html#section-3) 29 | 30 | ## Installation 31 | 32 | ### Building from Source 33 | You can build an executable binary yourself by checking out this Git repository. 34 | 35 | ```bash 36 | # Cloning the git repository 37 | % git clone https://github.com/junkurihara/rust-rpxy-l4 38 | % cd rust-rpxy-l4 39 | 40 | # Build 41 | % cargo build --release 42 | ``` 43 | 44 | Then you have an executive binary `rust-rpxy/target/release/rpxy-l4`. 45 | 46 | ### Package Installation for Linux (RPM/DEB) 47 | 48 | You can find the Jenkins CI/CD build scripts for `rpxy-l4` in the [./.build](./.build) directory. 49 | 50 | Prebuilt packages for Linux RPM and DEB are available at [https://rpxy.gamerboy59.dev](https://rpxy.gamerboy59.dev), provided by [@Gamerboy59](https://github.com/Gamerboy59). 51 | 52 | ## Usage 53 | 54 | `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. 55 | 56 | You can run `rpxy-l4` with a configuration file like 57 | 58 | ```bash 59 | % ./target/release/rpxy-l4 --config config.toml 60 | ``` 61 | 62 | `rpxy-l4` always tracks the change of `config.toml` in the real-time manner and apply the change immediately without restarting the process. 63 | 64 | Full command line options are as follows: 65 | 66 | ```bash 67 | % ./target/release/rpxy-l4 --help 68 | Usage: rpxy-l4 [OPTIONS] --config 69 | 70 | Options: 71 | -c, --config Configuration file path like ./config.toml 72 | -l, --log-dir Directory for log files. If not specified, logs are printed to stdout. 73 | -h, --help Print help 74 | -V, --version Print version 75 | ``` 76 | 77 | If you set `--log-dir=`, the log files are created in the specified directory. Otherwise, the log is printed to stdout. 78 | 79 | - `${log_dir}/access.log` for access log 80 | - `${log_dir}/rpxy-l4.log` for system and error log 81 | 82 | ## Basic configuration 83 | 84 | > [!NOTE] 85 | > A configuration example can be found at [./config.example.toml](./config.example.toml). Another toml file, [./config.spec.toml](./config.spec.toml), is a specification of the configuration file including **unimplemented features**. 86 | 87 | ### 1. First step: The fundamental TCP/UDP reverse proxy scenario 88 | 89 | The following is an example of the basic configuration for the TCP/UDP reverse proxy scenario. 90 | 91 | ```toml 92 | # Listen port, must be set 93 | listen_port = 8448 94 | 95 | # Default targets for TCP connections. [default: empty] 96 | # Format: [":", ":", ...] 97 | tcp_target = ["192.168.0.2:8000"] 98 | 99 | # Default targets for UDP connections. [default: empty] 100 | # Format: [":", ":", ...] 101 | udp_target = ["192.168.0.3:4000"] 102 | ``` 103 | 104 | The above configuration works as the following manner. 105 | 106 | - Forwards TCP packets received on port `8448` to the backend server `192.168.0.2:8000`; 107 | - Forwards UDP packets received on port `8448` to the backend server `192.168.0.3:4000`. 108 | 109 | > [!IMPORTANT] 110 | > 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. 111 | > 112 | > ```toml 113 | > # Udp connection idle lifetime in seconds [default: 30] 114 | > udp_idle_lifetime = 30 115 | > ``` 116 | 117 | ### 2.Second step: Load balancing 118 | 119 | `rpxy-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. 120 | 121 | ```toml 122 | # Listen port, must be set 123 | listen_port = 8448 124 | 125 | # Default targets for TCP connections. [default: empty] 126 | # Format: [":", ":", ...] 127 | tcp_target = ["192.168.0.2:8000", "192.168.0.3:8000"] 128 | 129 | # Load balancing method for default targets [default: none] 130 | tcp_load_balance = "source_ip" # source_ip, source_socket, random, or none 131 | 132 | # Default targets for UDP connections. [default: empty] 133 | # Format: [":", ":", ...] 134 | udp_target = ["192.168.0.2:4000", "192.168.0.3:4000"] 135 | 136 | # (Optional) Load balancing method for default targets [default: none] 137 | udp_load_balance = "source_socket" 138 | ``` 139 | 140 | Currently, `rpxy-l4` supports the following load balancing algorithms: 141 | 142 | - `source_ip`: based on source IP hash 143 | - `source_socket`: based on source IP and port hash 144 | - `random`: random selection 145 | - `none`: always use the first target [default] 146 | 147 | ### 3. Third step: Protocol multiplexing 148 | 149 | Here are examples/use-cases of the protocol multiplexing scenario over TCP/UDP. For protocol multiplexing, you need to set a `[protocol.]` filed in the configuration file as follows. 150 | 151 | ```toml 152 | listen_port = 8448 153 | ... 154 | 155 | # Set for each multiplexed service 156 | [protocol."http_service"] 157 | ... 158 | ``` 159 | 160 | Currently, `rpxy-l4` supports the following protocols for multiplexing: 161 | 162 | - TCP: HTTP (cleartext), TLS, SSH 163 | - UDP: QUIC (IETF [RFC9000](https://datatracker.ietf.org/doc/html/rfc9000)), WireGuard 164 | 165 | #### 3.1. Example of TLS/QUIC multiplexer with SNI/ALPN 166 | 167 | `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. 168 | 169 | ```toml 170 | listen_port = 8448 171 | tcp_target = ["192.168.0.2:8000"] 172 | udp_target = ["192.168.0.3:4000"] 173 | 174 | # TLS 175 | [protocol."tls_service"] 176 | # Name of protocol tls|ssh|http|wireguard|quic 177 | protocol = "tls" 178 | 179 | # Target for connections detected as TLS. 180 | target = ["192.168.0.5:443"] 181 | 182 | # (Optional) Load balancing method specific to this connections [default: none] 183 | load_balance = "source_ip" 184 | 185 | ##################### 186 | # IETF QUIC 187 | [protocol."quic_service"] 188 | # Name of protocol tls|ssh|http|wireguard|quic 189 | protocol = "quic" 190 | 191 | # Target for connections detected as QUIC. 192 | target = ["192.168.0.6:443"] 193 | 194 | # Load balancing method for QUIC connections [default: none] 195 | load_balance = "source_socket" 196 | 197 | # Idle lifetime for QUIC connections in seconds [default: 30] 198 | idle_lifetime = 30 199 | ``` 200 | 201 | > [!NOTE] 202 | > 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. 203 | 204 | 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. 205 | 206 | ```toml 207 | [protocol."tls_service"] 208 | protocol = "tls" 209 | target = ["192.168.0.5:443"] 210 | load_balance = "source_ip" 211 | 212 | # (Optional) SNI-based routing for TLS/QUIC connections. 213 | # If specified, only TLS/QUIC connections matched to the given SNI(s) are forwarded to the target. 214 | # Format: ["", "", ...] 215 | server_names = ["example.com", "example.org"] 216 | 217 | # (Optional) ALPN-based routing for TLS/QUIC connections. 218 | # If specified, only TLS/QUIC connections matched to the given ALPN(s) are forwarded to the target. 219 | # Format: ["", "", ...] 220 | alpns = ["h2", "http/1.1"] 221 | 222 | ``` 223 | 224 | > [!NOTE] 225 | > If both `server_names` and `alpns` are specified, the proxy forwards connections that match simultaneously both of them. 226 | 227 | #### 3.2. Example of WireGuard multiplexer 228 | 229 | `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. 230 | 231 | ```toml 232 | [protocols."wireguard_service"] 233 | protocol = "wireguard" 234 | target = ["192.168.0.10:51820"] 235 | load_balance = "none" 236 | # longer than the keepalive interval of the wireguard tunnel 237 | idle_lifetime = 30 238 | ``` 239 | 240 | > [!NOTE] 241 | > As well as QUIC, WireGuard is a UDP-based protocol. The `idle_lifetime` field is available for `protocol="wireguard"`. You should adjust the value according to your WireGuard configuration, especially the keep-alive interval. 242 | 243 | #### 3.3. Passing through only the expected protocols (protocol sanitization) 244 | 245 | 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. 246 | 247 | ### 4. Advanced: Experimental features 248 | 249 | #### 4.1. TLS Encrypted Client Hello (ECH) proxy 250 | 251 | See [./examples/README.md](./examples/README.md) for the ECH proxy configuration and client and backend server examples. 252 | 253 | ## Containerization 254 | 255 | The container, docker, image is available at Docker Hub and Github Container Registry. 256 | 257 | - Docker Hub: [jqtype/rpxy-l4](https://hub.docker.com/r/jqtype/rpxy-l4) 258 | - Github Container Registry: [ghcr.io/junkurihara/rust-rpxy-l4](https://ghcr.io/junkurihara/rust-rpxy-l4) 259 | 260 | The detailed configuration of the container can be found at [./docker](./docker) directory. 261 | 262 | ## Caveats 263 | 264 | ### `UDP` pseudo connection management 265 | 266 | 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. 267 | 268 | ### Encrypted Client Hello (ECH) proxy 269 | 270 | #### Reduced functionality 271 | 272 | *Currently we do not fully implement the function of client facing server described in the [IETF draft](https://www.ietf.org/archive/id/draft-ietf-tls-esni-24.html#section-7.1).* It works as the following *simplified and reduced* manner, which is different from the draft: 273 | 274 | - If no matching configuration with the given ECH is found, it just forwards the client hello to the backend server as it is. 275 | - `rpxy-l4` does 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 the `HelloRetryRequest` message to the client. 276 | 277 | #### No ECH over QUIC 278 | 279 | ECH proxy function is limited only to the TLS protocol, and ECH over QUIC is not supported yet. 280 | 281 | ### Others 282 | 283 | TBD! 284 | 285 | ## Credits 286 | 287 | `rpxy-4` cannot be built without the following projects and inspirations: 288 | 289 | - [`sslh`](https://github.com/yrutschle/sslh): `rpxy-l4` is strongly inspired by `sslh` for its protocol multiplexer feature. 290 | - [`tokio`](https://github.com/tokio-rs/tokio): Great async runtime for Rust. 291 | - [`RustCrypto`](https://github.com/RustCrypto): Pure Rust implementations of various cryptographic algorithms, used in `rpxy-l4` for TLS/QUIC cryptographic operations. 292 | 293 | ## License 294 | 295 | `rpxy-l4` is free, open-source software licensed under MIT License. 296 | 297 | You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. 298 | 299 | Contributors are more than welcome! 300 | -------------------------------------------------------------------------------- /proxy-l4-lib/src/udp_conn.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | access_log::{AccessLogProtocolType, access_log_finish, access_log_start}, 3 | constants::{UDP_BUFFER_SIZE, UDP_CHANNEL_CAPACITY}, 4 | error::ProxyError, 5 | proto::UdpProtocolType, 6 | socket::bind_udp_socket, 7 | time_util::get_since_the_epoch, 8 | trace::*, 9 | udp_proxy::UdpDestinationInner, 10 | }; 11 | use std::{ 12 | net::SocketAddr, 13 | sync::{ 14 | Arc, OnceLock, 15 | atomic::{AtomicU64, Ordering}, 16 | }, 17 | }; 18 | use tokio::{net::UdpSocket, runtime::Handle, sync::mpsc}; 19 | use tokio_util::sync::CancellationToken; 20 | 21 | /// Any socket address for IPv4 for auto-binding 22 | pub static BASE_ANY_SOCKET_V4: OnceLock = OnceLock::new(); 23 | /// Any socket address for IPv6 for auto-binding 24 | pub static BASE_ANY_SOCKET_V6: OnceLock = OnceLock::new(); 25 | 26 | /// Initialize once lock values 27 | fn init_once_lock() { 28 | let _ = BASE_ANY_SOCKET_V4.get_or_init(|| "0.0.0.0:0".parse().unwrap()); 29 | let _ = BASE_ANY_SOCKET_V6.get_or_init(|| "[::]:0".parse().unwrap()); 30 | } 31 | 32 | /// DashMap type alias, uses ahash::RandomState as hashbuilder 33 | type DashMap = dashmap::DashMap; 34 | 35 | /* ---------------------------------------------------------- */ 36 | #[derive(Clone)] 37 | /// Udp connection pool 38 | pub(crate) struct UdpConnectionPool { 39 | /// inner hashmap 40 | inner: DashMap, 41 | /// parent cancel token to cancel all connections 42 | parent_cancel_token: CancellationToken, 43 | /// runtime handle 44 | runtime_handle: Handle, 45 | } 46 | 47 | impl UdpConnectionPool { 48 | /// Create a new UdpConnectionManager 49 | pub(crate) fn new(runtime_handle: Handle, parent_cancel_token: CancellationToken) -> Self { 50 | init_once_lock(); 51 | 52 | let inner: DashMap = DashMap::default(); 53 | Self { 54 | inner, 55 | runtime_handle, 56 | parent_cancel_token, 57 | } 58 | } 59 | 60 | /// Get Arc by the source address + port 61 | pub(crate) fn get(&self, src_addr: &SocketAddr) -> Option { 62 | self.inner.get(src_addr).map(|arc| arc.value().clone()) 63 | } 64 | 65 | /// Get current connection count for this pool 66 | pub(crate) fn local_pool_size(&self) -> usize { 67 | self.inner.len() 68 | } 69 | 70 | /// Create and insert a new UdpConnection, and return the 71 | /// If the source address + port already exists, update the value. 72 | pub(crate) async fn create_new_connection( 73 | &self, 74 | src_addr: &SocketAddr, 75 | udp_dst: &UdpDestinationInner, 76 | protocol: &UdpProtocolType, 77 | udp_socket_to_downstream: Arc, 78 | ) -> Result { 79 | // Connection limit is handled by the caller 80 | 81 | let conn = Arc::new( 82 | UdpConnectionInner::try_new( 83 | protocol, 84 | src_addr, 85 | udp_dst, 86 | udp_socket_to_downstream, 87 | self.parent_cancel_token.child_token(), 88 | ) 89 | .await?, 90 | ); 91 | let (tx, rx) = mpsc::channel::>(UDP_CHANNEL_CAPACITY); 92 | let new_conn = UdpConnection { tx, inner: conn.clone() }; 93 | 94 | if let Some(old_conn) = self.inner.insert(*src_addr, new_conn.clone()) { 95 | warn!("UdpConnection was already existed but overwritten. Should not call create_new_connection() for existing keys."); 96 | old_conn.inner.cancel_token.cancel(); // cancel the old connection 97 | } 98 | // spawn the connection service 99 | let self_clone = self.clone(); 100 | let src_addr = *src_addr; 101 | self.runtime_handle.spawn(async move { 102 | // Here we are establishing a udp connection. Logging info for the connection as an access log. 103 | udp_access_log_start(&conn); 104 | conn.serve(rx, self_clone.runtime_handle.clone()).await; 105 | // clean up if the connection service is closed, here the connection service was already closed 106 | self_clone.remove(&src_addr); 107 | // finish log 108 | udp_access_log_finish(&conn); 109 | }); 110 | 111 | // Ok(udp_connection) 112 | Ok(new_conn) 113 | } 114 | 115 | /// Remove the entry by the source address + port 116 | fn remove(&self, src_addr: &SocketAddr) { 117 | self.inner.remove(src_addr); 118 | } 119 | 120 | /// Prune inactive connections 121 | /// This must be called when a new UDP datagram is received. 122 | pub(crate) fn prune_inactive_connections(&self) { 123 | self.inner.retain(|_, conn| { 124 | let last_active = conn.inner.last_active.load(Ordering::Acquire); 125 | let current = get_since_the_epoch(); 126 | let elapsed = current - last_active; 127 | debug!( 128 | "UdpConnection from {} to {} is active for {} seconds", 129 | conn.inner.src_addr, conn.inner.dst_addr, elapsed 130 | ); 131 | if elapsed < conn.inner.idle_lifetime { 132 | return true; 133 | } 134 | debug!("UdpConnection from {} is pruned due to inactivity", conn.inner.src_addr); 135 | conn.inner.cancel_token.cancel(); 136 | false 137 | }); 138 | } 139 | } 140 | 141 | /* ---------------------------------------------------------- */ 142 | #[derive(Clone, Debug)] 143 | /// Connection pool value 144 | pub(crate) struct UdpConnection { 145 | /// Sender to the UdpConnection 146 | tx: mpsc::Sender>, 147 | /// UdpConnection 148 | inner: Arc, 149 | } 150 | 151 | impl UdpConnection { 152 | /// Send a datagram to the UdpConnection 153 | pub(crate) async fn send(&self, datagram: &[u8]) -> Result<(), ProxyError> { 154 | self.tx.send(datagram.to_owned()).await.map_err(|e| { 155 | error!("Error sending datagram to UdpConnection: {e}"); 156 | error!( 157 | "Stopping UdpConnection from {} to {}", 158 | self.inner.src_addr, self.inner.dst_addr 159 | ); 160 | self.inner.cancel_token.cancel(); // cancellation will remove the connection from the pool 161 | ProxyError::BrokenUdpConnection(String::new()) 162 | }) 163 | } 164 | /// Send multiple datagrams to the UdpConnection 165 | pub(crate) async fn send_many(&self, datagrams: &[Vec]) -> Result<(), ProxyError> { 166 | for dg in datagrams.iter() { 167 | self.send(dg).await?; 168 | } 169 | Ok(()) 170 | } 171 | } 172 | /* ---------------------------------------------------------- */ 173 | #[derive(Clone, Debug)] 174 | /// Udp connection 175 | struct UdpConnectionInner { 176 | /// Udp protocol type 177 | protocol: UdpProtocolType, 178 | 179 | /// Remote socket address of the client 180 | src_addr: SocketAddr, 181 | 182 | /// Remote socket address of the upstream server 183 | dst_addr: SocketAddr, 184 | 185 | /// Local UdpSocket for the upstream server 186 | udp_socket_to_upstream: Arc, 187 | 188 | /// Local UdpSocket to send data back to the downstream client 189 | udp_socket_to_downstream: Arc, 190 | 191 | /// Cancel token to cancel the connection service 192 | cancel_token: CancellationToken, 193 | 194 | /// Connection idle lifetime 195 | /// If set to 0, no limit is applied. 196 | idle_lifetime: u64, 197 | 198 | /// Last active time 199 | last_active: Arc, 200 | } 201 | 202 | impl UdpConnectionInner { 203 | /// Create a new UdpConnection 204 | async fn try_new( 205 | protocol: &UdpProtocolType, 206 | src_addr: &SocketAddr, 207 | udp_dst: &UdpDestinationInner, 208 | udp_socket_to_downstream: Arc, 209 | cancel_token: CancellationToken, 210 | ) -> Result { 211 | let dst_addr = udp_dst.get_destination(src_addr).await?; 212 | let idle_lifetime = udp_dst.get_connection_idle_lifetime() as u64; 213 | let udp_socket_to_upstream = match dst_addr { 214 | SocketAddr::V4(_) => UdpSocket::from_std(bind_udp_socket(BASE_ANY_SOCKET_V4.get().unwrap())?), 215 | SocketAddr::V6(_) => UdpSocket::from_std(bind_udp_socket(BASE_ANY_SOCKET_V6.get().unwrap())?), 216 | } 217 | .map(Arc::new)?; 218 | 219 | udp_socket_to_upstream.connect(dst_addr).await?; 220 | debug!("Connected to the upstream server: {dst_addr}"); 221 | 222 | let last_active = Arc::new(AtomicU64::new(get_since_the_epoch())); 223 | 224 | Ok(Self { 225 | protocol: protocol.clone(), 226 | src_addr: *src_addr, 227 | dst_addr, 228 | udp_socket_to_upstream, 229 | udp_socket_to_downstream, 230 | cancel_token, 231 | idle_lifetime, 232 | last_active, 233 | }) 234 | } 235 | 236 | /// Update the last active time 237 | fn update_last_active(&self) { 238 | self.last_active.store(get_since_the_epoch(), Ordering::Release); 239 | } 240 | 241 | /// Serve the UdpConnection 242 | async fn serve(&self, channel_rx: mpsc::Receiver>, runtime_handle: Handle) { 243 | debug!("UdpConnection from {} to {} started", self.src_addr, self.dst_addr); 244 | let udp_socket_to_upstream_tx = self.udp_socket_to_upstream.clone(); 245 | let udp_socket_to_upstream_rx = self.udp_socket_to_upstream.clone(); 246 | 247 | /* ---------------------------------------------------------- */ 248 | let downstream_jh = runtime_handle.clone().spawn({ 249 | let self_clone = self.clone(); 250 | async move { self_clone.service_forward_downstream(udp_socket_to_upstream_rx).await } 251 | }); 252 | 253 | /* ---------------------------------------------------------- */ 254 | let upstream_jh = runtime_handle.clone().spawn({ 255 | let self_clone = self.clone(); 256 | async move { 257 | self_clone 258 | .service_forward_upstream(channel_rx, udp_socket_to_upstream_tx) 259 | .await 260 | } 261 | }); 262 | 263 | /* ---------------------------------------------------------- */ 264 | match tokio::join!(downstream_jh, upstream_jh) { 265 | (Err(e), _) | (_, Err(e)) => { 266 | error!("Error serving UdpConnection: {e}"); 267 | } 268 | _ => {} 269 | } 270 | } 271 | 272 | /// Service to forward datagrams to the upstream 273 | async fn service_forward_upstream( 274 | &self, 275 | mut channel_rx: mpsc::Receiver>, 276 | udp_socket_to_upstream_tx: Arc, 277 | ) -> Result<(), ProxyError> { 278 | let service = async move { 279 | // Handle multiple datagrams from the same source 280 | loop { 281 | let Some(datagram) = channel_rx.recv().await else { 282 | error!("Error receiving datagram from channel"); 283 | return Err(ProxyError::BrokenUdpConnection(String::new())); 284 | }; 285 | trace!( 286 | "[{} -> {}] received {} bytes from downstream", 287 | self.src_addr, 288 | self.dst_addr, 289 | datagram.len() 290 | ); 291 | self.update_last_active(); 292 | 293 | if let Err(e) = udp_socket_to_upstream_tx.send(datagram.as_slice()).await { 294 | error!("Error sending datagram to upstream: {e}"); 295 | return Err(ProxyError::BrokenUdpConnection(String::new())); 296 | }; 297 | } 298 | }; 299 | 300 | tokio::select! { 301 | res = service => res, 302 | _ = self.cancel_token.cancelled() => { 303 | debug!("UdpConnection cancelled [{} -> {}]", self.src_addr, self.dst_addr); 304 | Ok(()) 305 | } 306 | } 307 | } 308 | 309 | /// Service to forward datagrams to the downstream 310 | async fn service_forward_downstream(&self, udp_socket_to_upstream_rx: Arc) -> Result<(), ProxyError> { 311 | // Handle multiple datagrams sent back from the upstream as responses 312 | let service = async { 313 | loop { 314 | let mut udp_buf = vec![0u8; UDP_BUFFER_SIZE]; 315 | let buf_size = match udp_socket_to_upstream_rx.recv(&mut udp_buf).await { 316 | Err(e) => { 317 | error!("Error in UDP listener for upstream: {e}"); 318 | return Err(ProxyError::BrokenUdpConnection(String::new())); 319 | } 320 | Ok(res) => res, 321 | }; 322 | 323 | trace!( 324 | "[{} <- {}] received {} bytes from upstream", 325 | self.src_addr, self.dst_addr, buf_size 326 | ); 327 | self.update_last_active(); 328 | 329 | let response = udp_buf[..buf_size].to_vec(); 330 | 331 | if let Err(e) = self 332 | .udp_socket_to_downstream 333 | .send_to(response.as_slice(), self.src_addr) 334 | .await 335 | { 336 | error!("Error sending datagram to downstream: {e}"); 337 | return Err(ProxyError::BrokenUdpConnection(String::new())); 338 | }; 339 | } 340 | }; 341 | 342 | tokio::select! { 343 | res = service => res, 344 | _ = self.cancel_token.cancelled() => { 345 | debug!("UdpConnection cancelled: [{} <- {}]", self.src_addr, self.dst_addr); 346 | Ok(()) 347 | } 348 | } 349 | } 350 | 351 | /// Get the source address of the UdpConnection 352 | fn src_addr(&self) -> &SocketAddr { 353 | &self.src_addr 354 | } 355 | /// Get the destination address of the UdpConnection 356 | fn dst_addr(&self) -> &SocketAddr { 357 | &self.dst_addr 358 | } 359 | /// Get the protocol of the UdpConnection 360 | fn protocol(&self) -> &UdpProtocolType { 361 | &self.protocol 362 | } 363 | } 364 | 365 | /* ---------------------------------------------------------- */ 366 | /// Handle UDP access log when establishing a new connection 367 | fn udp_access_log_start(conn: &UdpConnectionInner) { 368 | let protocol = AccessLogProtocolType::Udp(conn.protocol().to_owned()); 369 | access_log_start(&protocol, conn.src_addr(), conn.dst_addr()); 370 | } 371 | /// Handle UDP access log when closing a connection 372 | fn udp_access_log_finish(conn: &UdpConnectionInner) { 373 | let protocol = AccessLogProtocolType::Udp(conn.protocol().to_owned()); 374 | access_log_finish(&protocol, conn.src_addr(), conn.dst_addr()); 375 | } 376 | 377 | /* ---------------------------------------------------------- */ 378 | #[cfg(test)] 379 | mod tests { 380 | use crate::target::{DnsCache, TargetAddr}; 381 | 382 | use super::*; 383 | use std::str::FromStr; 384 | use tracing_subscriber::{fmt, prelude::*}; 385 | 386 | fn init_logger() { 387 | let level = tracing::Level::from_str("debug").unwrap(); 388 | let passed_pkg_names = [env!("CARGO_PKG_NAME").replace('-', "_")]; 389 | let stdio_layer = fmt::layer() 390 | .with_line_number(true) 391 | .with_filter(tracing_subscriber::filter::filter_fn(move |metadata| { 392 | (passed_pkg_names 393 | .iter() 394 | .any(|pkg_name| metadata.target().starts_with(pkg_name)) 395 | && metadata.level() <= &level) 396 | || metadata.level() <= &tracing::Level::INFO.min(level) 397 | })); 398 | 399 | tracing_subscriber::registry().with(stdio_layer).init(); 400 | } 401 | 402 | #[tokio::test] 403 | async fn test_udp_connection_pool() { 404 | init_logger(); 405 | let runtime_handle = tokio::runtime::Handle::current(); 406 | 407 | let cancel_token = CancellationToken::new(); 408 | let udp_connection_pool = UdpConnectionPool::new(runtime_handle.clone(), cancel_token.clone()); 409 | 410 | let src_addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); 411 | let dns_cache = Arc::new(DnsCache::default()); 412 | let udp_dst = UdpDestinationInner::try_from(( 413 | ["127.0.0.1:54321".parse::().unwrap()].as_slice(), 414 | None, 415 | &dns_cache, 416 | Some(10), 417 | )) 418 | .unwrap(); 419 | 420 | let socket: SocketAddr = "127.0.0.1:55555".parse().unwrap(); 421 | let udp_socket_to_downstream = Arc::new(UdpSocket::from_std(bind_udp_socket(&socket).unwrap()).unwrap()); 422 | let protocol = UdpProtocolType::Any; 423 | 424 | let _udp_connection = udp_connection_pool 425 | .create_new_connection(&src_addr, &udp_dst, &protocol, udp_socket_to_downstream) 426 | .await 427 | .unwrap(); 428 | 429 | tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; 430 | 431 | udp_connection_pool.prune_inactive_connections(); 432 | tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; 433 | } 434 | } 435 | --------------------------------------------------------------------------------