proxies requests with user supplied configuration or defaults

This commit is contained in:
geoffsee
2025-08-15 12:25:26 -04:00
commit b479d5900e
8 changed files with 3849 additions and 0 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
PROXY_TARGET=https://machine.127.0.0.1.sslip.io
PROXY_BIND_ADDR=127.0.0.1:3030

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target/
/.idea/

1698
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

5
Cargo.toml Normal file
View File

@@ -0,0 +1,5 @@
[workspace]
members = [
"crates/*"
]
resolver = "2"

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
# Simple Proxy Server
This implementation addresses a challenge of TLS termination within development Kubernetes environments that leverage self-signed certificates for secure communication.
It is a lightweight HTTP proxy server built with Axum that forwards all requests to a configurable target URL.
## Security Considerations
⚠️ **Important Security Warnings**:
- **TLS Certificate Validation Disabled**: The proxy accepts invalid/self-signed certificates, making it vulnerable to man-in-the-middle attacks
- **Development/Testing Only**: This proxy is not intended for production use
- **No Authentication**: No authentication or authorization mechanisms
- **Permissive CORS**: Allows cross-origin requests from any domain
- **Unfiltered Forwarding**: All requests are forwarded without validation or sanitization
> As a safety precaution, this crate is not published to crates.io. You must build from source.
## Features
- **Flexible Configuration**: Environment variable and .env file support for easy setup
- **Full HTTP Support**: Forwards all HTTP methods (GET, POST, PUT, DELETE, etc.)
- **Header Preservation**: Maintains request and response headers (filtering out hop-by-hop headers)
- **Body Forwarding**: Preserves request and response bodies
- **Self-Signed Certificate Support**: Accepts invalid/self-signed TLS certificates
- **Error Handling**: Comprehensive error handling with proper HTTP status codes
- **Logging**: Built-in tracing for request monitoring
- **CORS Support**: Includes permissive CORS headers
## Configuration
The proxy server supports flexible configuration through environment variables and a `.env` file.
### Environment Variables
- `PROXY_TARGET`: The target URL to proxy requests to (default: `https://machine.127.0.0.1.sslip.io`)
- `PROXY_BIND_ADDR`: The address and port to bind the server to (default: `127.0.0.1:3030`)
### .env File Support
The application automatically loads configuration from a `.env` file in the project root:
```env
PROXY_TARGET=https://your-target-server.com
PROXY_BIND_ADDR=127.0.0.1:3030
```
To change the configuration:
1. Create or edit the `.env` file in the project root
2. Set the desired values for `PROXY_TARGET` and/or `PROXY_BIND_ADDR`
3. Restart the application
You can also set these environment variables directly in your shell or deployment environment.
## Building
```bash
# Check compilation
cargo check
# Build release version
cargo build --release
# Run in development mode
cargo run
```
## Running
The server listens on `127.0.0.1:3030` by default (configurable via `PROXY_BIND_ADDR`).
```bash
# Start the proxy server (uses .env file if present)
cargo run
# Or with environment variables
PROXY_TARGET=https://your-target.com PROXY_BIND_ADDR=0.0.0.0:8080 cargo run
# The server will output:
# Simple proxy server starting on http://127.0.0.1:3030
# Proxying requests to: https://machine.127.0.0.1.sslip.io
```
### Configuration Priority
Environment variables are loaded in the following order (later sources override earlier ones):
1. Default values (hardcoded in the application)
2. `.env` file (if present)
3. System environment variables
## Usage Examples
Once running, you can send requests to the proxy server:
```bash
# GET request
curl http://127.0.0.1:3030/
# POST request with JSON data
curl -X POST http://127.0.0.1:3030/ \
-H "Content-Type: application/json" \
-d '{"key": "value"}'
# Any path will be forwarded
curl http://127.0.0.1:3030/api/health
curl http://127.0.0.1:3030/some/path
```
All requests will be forwarded to the configured target URL while preserving:
- HTTP method
- Request path and query parameters
- Headers (except hop-by-hop headers)
- Request body
## Logging
The server uses structured logging via `tracing`. Set the `RUST_LOG` environment variable to control log levels:
```bash
# Debug level logging
RUST_LOG=debug cargo run
# Info level (default)
RUST_LOG=info cargo run
# Only errors
RUST_LOG=error cargo run
```
## TLS/Certificate Handling
The proxy is configured to accept self-signed and invalid TLS certificates from the target server. This is accomplished using the `danger_accept_invalid_certs(true)` setting in the reqwest client configuration.
This feature allows the proxy to connect to:
- Servers with self-signed certificates
- Servers with expired certificates
- Servers with certificates that don't match the hostname
- Development/testing environments with invalid certificates
**⚠️ Security Warning**: This setting disables certificate validation, which makes the connection vulnerable to man-in-the-middle attacks. Only use this for development, testing, or trusted network environments.
## Architecture
- **Framework**: Axum web framework
- **HTTP Client**: reqwest with rustls-tls
- **Async Runtime**: Tokio
- **Logging**: tracing with tracing-subscriber
## Testing
The project includes unit tests for proxy functionality.
### Running Tests
```bash
# Run all tests
cargo test
```

1690
crates/simple-proxy/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
[package]
name = "simple-proxy"
version = "0.1.0"
edition = "2021"
authors = ["Geoff Seemueller <28698553+geoffsee@users.noreply.github.com>"]
[[bin]]
name = "simple-proxy"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7.9", features = ["macros", "json", "query", "tracing"] }
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread", "net"] }
reqwest = { version = "0.11.27", features = ["json", "rustls-tls"], default-features = false }
tower = { version = "0.5.2", features = ["tokio", "tracing"] }
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
http = "1.3.1"
bytes = "1.9.0"
thiserror = "1.0"
anyhow = "1.0"
hyper = "0.14.32"
dotenv = "0.15.0"

View File

@@ -0,0 +1,267 @@
use axum::{
body::Body,
extract::Request,
http::StatusCode,
response::{IntoResponse, Response},
routing::any,
Router,
};
use reqwest::Client;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::{error, info, instrument};
use dotenv::dotenv;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const DEFAULT_PROXY_TARGET: &str = "https://machine.127.0.0.1.sslip.io";
const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3030";
#[derive(Debug, Clone)]
struct Config {
proxy_target: String,
bind_addr: String,
}
impl Default for Config {
fn default() -> Self {
Self {
proxy_target: DEFAULT_PROXY_TARGET.to_string(),
bind_addr: DEFAULT_BIND_ADDR.to_string(),
}
}
}
impl Config {
fn from_env() -> Self {
Self {
proxy_target: std::env::var("PROXY_TARGET")
.unwrap_or_else(|_| DEFAULT_PROXY_TARGET.to_string()),
bind_addr: std::env::var("PROXY_BIND_ADDR")
.unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string()),
}
}
}
#[derive(Clone)]
struct AppState {
client: Client,
target_url: String,
}
#[derive(Debug, thiserror::Error)]
enum ProxyError {
#[error("Request error: {0}")]
RequestError(#[from] reqwest::Error),
#[error("Invalid header value: {0}")]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
#[error("Invalid header name: {0}")]
InvalidHeaderName(#[from] http::header::InvalidHeaderName),
#[error("URI parse error: {0}")]
UriError(#[from] http::uri::InvalidUri),
#[error("HTTP error: {0}")]
HttpError(#[from] http::Error),
#[error("Axum error: {0}")]
AxumError(String),
#[error("Method conversion error")]
MethodError,
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let status = StatusCode::BAD_GATEWAY;
let body = format!("Proxy error: {}", self);
error!("Proxy error: {}", self);
(status, body).into_response()
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv().ok();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "simple_proxy=debug,tower_http=debug,axum::rejection=trace".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env();
let client = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.danger_accept_invalid_certs(true) // Accept self-signed certificates
.build()?;
let state = AppState {
client,
target_url: config.proxy_target.clone(),
};
let app = Router::new()
.route("/*path", any(proxy_handler))
.route("/", any(proxy_handler))
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
info!("Simple proxy server starting on http://{}", config.bind_addr);
info!("Proxying requests to: {}", config.proxy_target);
axum::serve(listener, app).await?;
Ok(())
}
#[instrument(skip(state, request))]
async fn proxy_handler(
axum::extract::State(state): axum::extract::State<AppState>,
request: Request,
) -> Result<Response, ProxyError> {
let method = request.method().clone();
let uri = request.uri().clone();
let headers = request.headers().clone();
let body = axum::body::to_bytes(request.into_body(), usize::MAX).await
.map_err(|e| ProxyError::AxumError(e.to_string()))?;
let path_and_query = uri.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let target_url = format!("{}{}", state.target_url, path_and_query);
info!("Proxying {} {} to {}", method, uri, target_url);
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())
.map_err(|_| ProxyError::MethodError)?;
let mut req_builder = state.client.request(reqwest_method, &target_url);
for (name, value) in headers.iter() {
let name_str = name.as_str();
// Skip hop-by-hop headers and host header
if !should_skip_header(name_str) {
req_builder = req_builder.header(name.as_str(), value.as_bytes());
}
}
if !body.is_empty() {
req_builder = req_builder.body(body.to_vec());
}
let response = req_builder.send().await?;
let status = response.status();
let response_headers = response.headers().clone();
let response_body = response.bytes().await?;
let mut builder = Response::builder().status(status.as_u16());
for (name, value) in response_headers.iter() {
if !should_skip_response_header(name.as_str()) {
builder = builder.header(name.as_str(), value.as_bytes());
}
}
let response = builder
.body(Body::from(response_body))?;
Ok(response)
}
fn should_skip_header(name: &str) -> bool {
matches!(
name.to_lowercase().as_str(),
"connection" | "host" | "transfer-encoding" | "upgrade" | "proxy-connection"
)
}
fn should_skip_response_header(name: &str) -> bool {
matches!(
name.to_lowercase().as_str(),
"connection" | "transfer-encoding" | "upgrade" | "proxy-connection"
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{header, Method};
use hyper::body::to_bytes;
use std::convert::TryFrom;
use tower::ServiceExt;
#[tokio::test]
async fn test_proxy_handler_get() {
let client = Client::new();
let state = AppState {
client,
target_url: "http://httpbin.org".to_string(),
};
let app = Router::new()
.route("/*path", any(proxy_handler))
.with_state(state);
let request = Request::builder()
.method(Method::GET)
.uri("/get")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_proxy_handler_post() {
let client = Client::new();
let state = AppState {
client,
target_url: "http://httpbin.org".to_string(),
};
let app = Router::new()
.route("/*path", any(proxy_handler))
.with_state(state);
let request = Request::builder()
.method(Method::POST)
.uri("/post")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"test": "data"}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_should_skip_header() {
assert!(should_skip_header("connection"));
assert!(should_skip_header("host"));
assert!(!should_skip_header("content-type"));
}
#[tokio::test]
async fn test_should_skip_response_header() {
assert!(should_skip_response_header("connection"));
assert!(should_skip_response_header("transfer-encoding"));
assert!(!should_skip_response_header("content-type"));
}
}