4 Commits

Author SHA1 Message Date
a674895173 Make the build work
All checks were successful
ci/woodpecker/push/build/1 Pipeline was successful
ci/woodpecker/push/build/2 Pipeline was successful
ci/woodpecker/push/build/3 Pipeline was successful
2026-04-09 22:05:08 +02:00
644ca99004 Add Woodpecker build configuration 2026-04-08 22:51:01 +02:00
913e50ff1c Release version 0.1.11
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-05-01 22:04:59 +02:00
aecffa0d14 Wait for the entire TLS header to become available, even if it takes
multiple packets.

Closes: #10
2025-05-01 22:01:54 +02:00
6 changed files with 296 additions and 14 deletions

52
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,52 @@
when:
- event:
- push
- tag
- manual
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
SHORT: amd64-musl
BIN_SUFFIX:
- TARGET: aarch64-unknown-linux-musl
SHORT: arm64-musl
BIN_SUFFIX:
- TARGET: x86_64-pc-windows-gnu
SHORT: windows
BIN_SUFFIX: .exe
steps:
- name: Prepare
image: img.kie.rs/jjkiers/rust-crossbuild:rust1.79.0-zig0.11.0-zig
commands:
- echo Using image img.kie.rs/jjkiers/rust-crossbuild:rust1.79.0-zig0.11.0-zig
- mkdir -p artifacts
- cargo --version
- rustc --version
- set
- name: Build for ${SHORT}
image: img.kie.rs/jjkiers/rust-crossbuild:rust1.79.0-zig0.11.0-zig
commands:
- echo Building ${TARGET} \(${SHORT}\)
- cargo zigbuild --release --target ${TARGET}
- mkdir -p artifacts
- cp target/${TARGET}/release/l4p${BIN_SUFFIX} artifacts/l4p-${TARGET}${BIN_SUFFIX}
- rm -rf target/${TARGET}/release/*
depends_on:
- Prepare
- name: Release
image: img.kie.rs/jjkiers/rust-crossbuild:rust1.79.0-zig0.11.0-zig
when:
- event: tag
commands:
- ls -lah artifacts
- scripts/create_release_artifacts.sh
environment:
GITEA_SERVER_TOKEN:
from_secret: gitea_token
depends_on:
- Build for ${SHORT}

4
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@@ -516,7 +516,7 @@ dependencies = [
[[package]]
name = "l4p"
version = "0.1.9"
version = "0.1.10"
dependencies = [
"async-trait",
"byte_string",

View File

@@ -1,6 +1,6 @@
[package]
name = "l4p"
version = "0.1.10"
version = "0.1.11"
edition = "2021"
authors = ["Jacob Kiers <code@kiers.eu>"]
license = "Apache-2.0"

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# vim: set expandtab shiftwidth=4 softtabstop=4 tabstop=4 :
set -euo pipefail
if [ -z "${CI_COMMIT_TAG:-}" ]; then
echo "No commit tag set"
exit 1
fi
DIR=$(realpath $(dirname "${BASH_SOURCE[0]}") )
echo ${DIR}
${DIR}/install_tea.sh linux-amd64 https://gitea.com/api/v1/repos/gitea/tea/releases/latest
## Log in to Gitea
TEA=$(pwd)/tea
if [ -z "${GITEA_SERVER_URL:-}" ]; then
if [ -z "${CI_FORGE_URL:-}" ]; then
echo "Cannot log in to gitea: GITEA_SERVER_URL or CI_FORGE_URL missing"
exit 1
fi
GITEA_SERVER_URL=${CI_FORGE_URL}
fi
if [ -z "${GITEA_SERVER_TOKEN:-}" ]; then
echo "Cannot log in to gitea: GITEA_SERVER_TOKEN missing"
exit 1
fi
if ! ${TEA} login ls | grep ${GITEA_SERVER_URL} 2>&1 > /dev/null || false; then
${TEA} login add
else
echo "Already logged in to ${GITEA_SERVER_URL}"
fi
## Check and create tag
if ${TEA} release ls -o json | jq -e --arg tag "${CI_COMMIT_TAG}" 'map(.["tag-_name"]) | index($tag) != null' >/dev/null; then
echo "Release ${CI_COMMIT_TAG} exists"
else
echo "Creating release ${CI_COMMIT_TAG}"
${TEA} release create -o json --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --draft
fi
find $(dirname ${DIR})/artifacts -type f -exec ${TEA} releases assets create -o json ${CI_COMMIT_TAG} {} +

39
scripts/install_tea.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -eo pipefail
if [ -x ./tea ]; then
echo "tea already installed in current directory"; exit 0
fi
platform="${1:-linux-amd64}"
src="${2:-release.json}"
# obtain JSON: if src looks like a URL fetch it, otherwise treat as filename (or default file)
if [[ "$src" =~ ^https?:// ]]; then
curl -fsSL "$src" -o /tmp/release.json.$$
json="/tmp/release.json.$$"
trap 'rm -f "$json"' EXIT
elif [ -f "$src" ]; then
json="$src"
else
echo "release JSON not found; provide a filename or URL as second arg" >&2
exit 1
fi
# read tag and find binary URL (exclude archives/checksums/sigs)
tag=$(jq -r '.tag_name' "$json")
url=$(jq -r --arg p "$platform" '.assets[]
| select(.name | test($p))
| select(.name | test("\\.(xz|zip|gz|tar|bz2|7z|sha256|sha256sum|sig|asc)$") | not)
| .browser_download_url' "$json" | head -n1)
[ -n "$url" ] || { echo "binary not found for $platform" >&2; exit 1; }
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL "$url" -o "$tmp"
mv "$tmp" tea
chmod +x tea
echo "Downloaded tag ${tag}: $url -> ./tea"

View File

@@ -1,12 +1,15 @@
use crate::servers::Proxy;
use log::{debug, error, trace, warn};
use std::error::Error;
use std::io; // Import io for ErrorKind
use std::sync::Arc;
use std::time::Duration; // For potential delays
use tls_parser::{
parse_tls_extensions, parse_tls_raw_record, parse_tls_record_with_header, TlsMessage,
TlsMessageHandshake,
};
use tokio::net::TcpStream;
use tokio::time::timeout; // Use timeout for peek operations
fn get_sni(buf: &[u8]) -> Vec<String> {
let mut snis: Vec<String> = Vec::new();
@@ -57,6 +60,9 @@ fn get_sni(buf: &[u8]) -> Vec<String> {
snis
}
// Timeout duration for waiting for TLS Hello data
const TLS_PEEK_TIMEOUT: Duration = Duration::from_secs(5); // Adjust as needed
pub(crate) async fn determine_upstream_name(
inbound: &TcpStream,
proxy: &Arc<Proxy>,
@@ -64,35 +70,170 @@ pub(crate) async fn determine_upstream_name(
let default_upstream = proxy.default_action.clone();
let mut header = [0u8; 9];
inbound.peek(&mut header).await?;
let required_bytes = client_hello_buffer_size(&header)?;
// --- Step 1: Peek the initial header (9 bytes) with timeout ---
match timeout(TLS_PEEK_TIMEOUT, async {
loop {
match inbound.peek(&mut header).await {
Ok(n) if n >= header.len() => return Ok::<usize, io::Error>(n), // Got enough bytes
Ok(0) => {
// Connection closed cleanly before sending enough data
trace!("Connection closed while peeking for TLS header");
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Connection closed while peeking for TLS header",
)
.into()); // Convert to Box<dyn Error>
}
Ok(_) => {
// Not enough bytes yet, yield and loop again
tokio::task::yield_now().await;
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
// Should not happen with await, but yield defensively
tokio::task::yield_now().await;
}
Err(e) => {
// Other I/O error
warn!("Error peeking for TLS header: {}", e);
return Err(e.into()); // Convert to Box<dyn Error>
}
}
}
})
.await
{
Ok(Ok(_)) => { /* Header peeked successfully */ }
Ok(Err(e)) => {
// Inner loop returned an error (e.g., EOF, IO error)
trace!("Failed to peek header (inner error): {}", e);
return Ok(default_upstream); // Fallback on error/EOF
}
Err(_) => {
// Timeout occurred
error!("Timeout waiting for TLS header");
return Ok(default_upstream); // Fallback on timeout
}
}
let mut hello_buf = vec![0; required_bytes];
let read_bytes = inbound.peek(&mut hello_buf).await?;
// --- Step 2: Calculate required size ---
let required_bytes = match client_hello_buffer_size(&header) {
Ok(size) => size,
Err(e) => {
// Header was invalid or not a ClientHello
trace!("Could not determine required buffer size: {}", e);
return Ok(default_upstream);
}
};
if read_bytes < required_bytes.into() {
error!("Could not read enough bytes to determine SNI");
// Basic sanity check on size
if required_bytes > 16384 + 9 {
// TLS max record size + header approx
error!(
"Calculated required TLS buffer size is too large: {}",
required_bytes
);
return Ok(default_upstream);
}
// --- Step 3: Peek the full ClientHello with timeout ---
let mut hello_buf = vec![0; required_bytes];
match timeout(TLS_PEEK_TIMEOUT, async {
let mut total_peeked = 0;
loop {
// Peek into the portion of the buffer that hasn't been filled yet.
match inbound.peek(&mut hello_buf[total_peeked..]).await {
Ok(0) => {
// Connection closed cleanly before sending full ClientHello
trace!(
"Connection closed while peeking for full ClientHello (peeked {}/{} bytes)",
total_peeked,
required_bytes
);
return Err::<usize, io::Error>(
io::Error::new(
io::ErrorKind::UnexpectedEof,
"Connection closed while peeking for full ClientHello",
)
.into(),
);
}
Ok(n) => {
total_peeked += n;
if total_peeked >= required_bytes {
trace!("Successfully peeked {} bytes for ClientHello", total_peeked);
return Ok(total_peeked); // Got enough
} else {
// Not enough bytes yet, yield and loop again
trace!(
"Peeked {}/{} bytes for ClientHello, waiting for more...",
total_peeked,
required_bytes
);
tokio::task::yield_now().await;
}
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
tokio::task::yield_now().await;
}
Err(e) => {
warn!("Error peeking for full ClientHello: {}", e);
return Err(e.into());
}
}
}
})
.await
{
Ok(Ok(_)) => { /* Full hello peeked successfully */ }
Ok(Err(e)) => {
error!("Could not peek full ClientHello (inner error): {}", e);
return Ok(default_upstream); // Fallback on error/EOF
}
Err(_) => {
error!(
"Timeout waiting for full ClientHello (needed {} bytes)",
required_bytes
);
return Ok(default_upstream); // Fallback on timeout
}
}
// --- Step 4: Parse SNI ---
let snis = get_sni(&hello_buf);
// --- Step 5: Determine upstream based on SNI ---
if snis.is_empty() {
debug!("No SNI found in ClientHello, using default upstream.");
return Ok(default_upstream);
} else {
match proxy.sni.clone() {
Some(sni_map) => {
let mut upstream = default_upstream;
let mut upstream = default_upstream.clone(); // Clone here for default case
let mut found_match = false;
for sni in snis {
let m = sni_map.get(&sni);
if m.is_some() {
upstream = m.unwrap().clone();
// snis is already Vec<String>
if let Some(target_upstream) = sni_map.get(&sni) {
debug!(
"Found matching SNI '{}', routing to upstream: {}",
sni, target_upstream
);
upstream = target_upstream.clone();
found_match = true;
break;
} else {
trace!("SNI '{}' not found in map.", sni);
}
}
if !found_match {
debug!("SNI(s) found but none matched configuration, using default upstream.");
}
Ok(upstream)
}
None => return Ok(default_upstream),
None => {
debug!("SNI found but no SNI map configured, using default upstream.");
Ok(default_upstream)
}
}
}
}