mirror of
https://github.com/seemueller-io/simple-proxy.git
synced 2025-09-08 22:56:47 +00:00
proxies requests with user supplied configuration or defaults
This commit is contained in:
2
.env
Normal file
2
.env
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target/
|
||||||
|
/.idea/
|
1698
Cargo.lock
generated
Normal file
1698
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
Cargo.toml
Normal file
5
Cargo.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/*"
|
||||||
|
]
|
||||||
|
resolver = "2"
|
159
README.md
Normal file
159
README.md
Normal 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
1690
crates/simple-proxy/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
crates/simple-proxy/Cargo.toml
Normal file
26
crates/simple-proxy/Cargo.toml
Normal 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"
|
267
crates/simple-proxy/src/main.rs
Normal file
267
crates/simple-proxy/src/main.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user