Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ jobs:
- name: Run cargo test
run: cargo test

lint:
name: Clippy
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: lint-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: lint-cargo-

- name: cargo clippy
run: cargo clippy --all-targets

build-linux:
name: Build – ${{ matrix.target }}
runs-on: ubuntu-latest
Expand Down Expand Up @@ -74,9 +93,9 @@ jobs:
run: |
set -e
rustup update stable
rustup target add ${{ matrix.target }}
npm ci --ignore-scripts
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
rustup target add aarch64-unknown-linux-musl
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc
export CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
fi
Expand Down
3 changes: 3 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.1"
components = ["clippy", "rustfmt"]
1 change: 1 addition & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hard_tabs = true
32 changes: 16 additions & 16 deletions src/http_listener.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/// Plaintext HTTP/1.1 listener for ACME HTTP-01 challenges and HTTP→HTTPS redirects.
///
/// Behaviour, per connection:
/// * Read the request headers.
/// * If the request target begins with `/.well-known/acme-challenge/`, look up
/// the route table by the `Host` header value (using the same wildcard rules
/// as SNI matching) and proxy the raw HTTP bytes to that route's first
/// upstream. This lets the `letsencrypt-cert-generator-fabric` Harper
/// component answer the challenge on its existing HTTP port.
/// * Anything else returns `301 Moved Permanently` with
/// `Location: https://<host><request-target>`.
///
/// This is intended to replace the standalone nginx :80 on Fabric hosts so
/// symphony alone can bind to both ports.
//! Plaintext HTTP/1.1 listener for ACME HTTP-01 challenges and HTTP→HTTPS redirects.
//!
//! Behaviour, per connection:
//! * Read the request headers.
//! * If the request target begins with `/.well-known/acme-challenge/`, look up
//! the route table by the `Host` header value (using the same wildcard rules
//! as SNI matching) and proxy the raw HTTP bytes to that route's first
//! upstream. This lets the `letsencrypt-cert-generator-fabric` Harper
//! component answer the challenge on its existing HTTP port.
//! * Anything else returns `301 Moved Permanently` with
//! `Location: https://<host><request-target>`.
//!
//! This is intended to replace the standalone nginx :80 on Fabric hosts so
//! symphony alone can bind to both ports.

use crate::http_proxy::{
host_header, read_http_headers, request_target, strip_body_framing, with_connection_close,
Expand Down Expand Up @@ -55,7 +55,7 @@ pub async fn spawn_http_listeners(

for _ in 0..workers {
let socket = make_reuseport_socket(addr)?;
let listener = TcpListener::from_std(socket.into())?;
let listener = TcpListener::from_std(socket)?;
let ctx2 = ctx.clone();
let max_conn = max_connections;
let srx = shutdown_rx.resubscribe();
Expand Down Expand Up @@ -197,7 +197,7 @@ async fn proxy_acme(
let upstream =
upstream::connect(&route.destination, Some(peer_addr.ip()), UPSTREAM_CONNECT_TIMEOUT)
.await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;

// ACME HTTP-01 challenges are GET requests with no body. Strip any
// Content-Length / Transfer-Encoding headers so a client that lies about a
Expand Down
18 changes: 9 additions & 9 deletions src/http_proxy.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/// HTTP/1.1 header parsing and response rewriting utilities for the UDS proxy loop.
///
/// Harper's UDS sockets send `Connection: close` per response even for HTTP/1.1
/// clients. The TLS-terminating proxy loop in proxy_conn.rs uses these helpers
/// to:
/// 1. Frame individual request / response messages (find \r\n\r\n).
/// 2. Rewrite `Connection: close` → `Connection: keep-alive` in upstream
/// responses so downstream TLS connections are reused.
/// 3. Copy body bytes for Content-Length or read-until-close semantics.
//! HTTP/1.1 header parsing and response rewriting utilities for the UDS proxy loop.
//!
//! Harper's UDS sockets send `Connection: close` per response even for HTTP/1.1
//! clients. The TLS-terminating proxy loop in proxy_conn.rs uses these helpers
//! to:
//! 1. Frame individual request / response messages (find \r\n\r\n).
//! 2. Rewrite `Connection: close` → `Connection: keep-alive` in upstream
//! responses so downstream TLS connections are reused.
//! 3. Copy body bytes for Content-Length or read-until-close semantics.

use std::io;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
Expand Down
2 changes: 1 addition & 1 deletion src/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub async fn spawn_listeners(

for _ in 0..workers {
let socket = make_reuseport_socket(addr)?;
let listener = TcpListener::from_std(socket.into())?;
let listener = TcpListener::from_std(socket)?;
let ctx2 = ctx.clone();
let max_conn = max_connections;
let srx = shutdown_rx.resubscribe();
Expand Down
9 changes: 5 additions & 4 deletions src/protection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,11 @@ impl ProtectionState {
}

// 3b. JA4 blocklist
if !cfg.ja4_blocklist.is_empty() && !peek_info.ja4.is_empty() {
if cfg.ja4_blocklist.contains(peek_info.ja4.as_str()) {
return Decision::Block(BlockReason::Ja4Blocked);
}
if !cfg.ja4_blocklist.is_empty()
&& !peek_info.ja4.is_empty()
&& cfg.ja4_blocklist.contains(peek_info.ja4.as_str())
{
return Decision::Block(BlockReason::Ja4Blocked);
}

// 4. Require SNI
Expand Down
17 changes: 6 additions & 11 deletions src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,14 +628,15 @@ fn parse_resolve_spec(r: &JsResolveRoute) -> Result<ResolveSpec> {
})
}

fn parse_protection_config(
prot: &JsProtectionConfig,
) -> Result<(
/// Parsed protection config plus the derived allowlist, blocklist, and raw blocklist strings.
type ParsedProtectionConfig = (
crate::protection::ProtectionConfig,
Vec<IpNetwork>,
Vec<IpNetwork>,
Vec<String>,
)> {
);

fn parse_protection_config(prot: &JsProtectionConfig) -> Result<ParsedProtectionConfig> {
let mut cfg = crate::protection::ProtectionConfig::default();

if let Some(rl) = &prot.rate_limit {
Expand Down Expand Up @@ -673,13 +674,7 @@ fn parse_protection_config(
})
.collect::<Result<Vec<_>>>()?;

let blocklist_strings: Vec<String> = prot
.blocklist
.as_deref()
.unwrap_or(&[])
.iter()
.cloned()
.collect();
let blocklist_strings: Vec<String> = prot.blocklist.as_deref().unwrap_or(&[]).to_vec();

let blocklist: Vec<IpNetwork> = blocklist_strings
.iter()
Expand Down
4 changes: 2 additions & 2 deletions src/proxy_conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ async fn proxy_via_tls(
) -> std::io::Result<()> {
let mut upstream = upstream::connect(dest, Some(sf.peer_addr.ip()), ctx.upstream_connect_timeout)
.await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;

// HTTP-header injection is only valid for a plaintext HTTP/1 upstream. An h2-negotiated
// upstream receives binary frames, so text header insertion would corrupt them.
Expand All @@ -253,7 +253,7 @@ async fn proxy_raw(
) -> std::io::Result<()> {
let mut upstream = upstream::connect(dest, Some(sf.peer_addr.ip()), ctx.upstream_connect_timeout)
.await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
.map_err(|e| std::io::Error::other(e.to_string()))?;

// Passthrough forwards raw TLS bytes — never a plaintext HTTP/1 stream, so header injection
// is disabled (only PROXY protocol carriers apply here).
Expand Down
2 changes: 1 addition & 1 deletion src/sni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ fn compute_ja4(legacy_version: u16, cipher_suites: &[u8], extensions: &[u8]) ->
let list = &ext_data[2..2 + list_len.min(ext_data.len().saturating_sub(2))];
if !list.is_empty() {
let proto_len = list[0] as usize;
if proto_len > 0 && 1 + proto_len <= list.len() {
if proto_len > 0 && proto_len < list.len() {
alpn_first = Some(list[1..1 + proto_len].to_vec());
}
}
Expand Down
Loading