Build four small, runnable proxies with explicit limits and predictable shutdown
A micro proxy can be compact without being careless. In this tutorial, you will build an HTTP reverse proxy and a raw TCP proxy in both Rust and Go, run them against local test services, verify their behavior, and publish the selected listener through Localtonet. The examples use fixed upstream destinations, bounded request or connection handling, upstream timeouts, forwarding-header controls, and graceful shutdown. They are intentionally small foundations rather than replacements for a mature edge proxy, authentication gateway, or service mesh.
π What's in this guide
What these micro proxies build
A reverse proxy accepts a client connection, opens or selects a connection to an upstream service, forwards traffic, and returns the upstream result. βMicroβ describes the deliberately narrow scope of the programs in this guide. Each listener has one fixed upstream, configuration comes from a small set of environment variables, and there is no dynamic route administration interface.
The HTTP examples understand requests and responses. They remove hop-by-hop headers, replace untrusted forwarding metadata, apply a request-body limit, use an upstream timeout, and preserve the requested path and query string. The TCP examples treat traffic as an opaque bidirectional byte stream. They limit concurrent connections, time out upstream dialing, enforce a maximum connection lifetime, support TCP half-close where the platform permits it, and stop accepting new connections during shutdown.
HTTP and TCP are different implementation boundaries
HTTP is not simply text copied over TCP. HTTP intermediaries must follow message framing, connection-header, method, target, and response semantics. The examples rely on maintained HTTP libraries rather than implementing a parser. The relevant protocol behavior is standardized in HTTP Semantics, RFC 9110 and HTTP/1.1, RFC 9112.
A TCP relay does not know whether it carries HTTP, an echo protocol, a database connection, or a custom binary exchange. It copies bytes in both directions and must handle partial reads, disconnects, blocked peers, and one direction finishing before the other. Application authentication and protocol validation remain the responsibility of the service carried through the relay.
| Implementation | Public interface | What it understands | Main tutorial limits |
|---|---|---|---|
| Rust HTTP | HTTP listener | Methods, paths, headers, bodies, and status codes | Body size, concurrent requests, upstream timeout, streamed response cap |
| Rust TCP | TCP listener | Opaque bytes | Concurrent connections, dial timeout, connection lifetime, drain timeout |
| Go HTTP | HTTP listener | Methods, paths, headers, bodies, and status codes | Body size, HTTP server timeouts, upstream timeout, response-header timeout |
| Go TCP | TCP listener | Opaque bytes | Concurrent connections, dial timeout, connection lifetime, drain timeout |
The upstream destination is operator-controlled configuration. Never let an unauthenticated request provide an arbitrary upstream host or port. That design can expose private network services, enable abuse, and turn the process into an SSRF or open-proxy endpoint.
Prerequisites, layout, and toolchain assumptions
You need a Rust toolchain with Cargo, a Go toolchain, a terminal, and curl. The TCP checks also use a short Python 3 echo server so the verification process works without relying on a platform-specific Netcat variant. Consult the official Rust documentation for toolchain installation and the official Go installation guide for supported Go installation paths.
The Rust manifest below targets the Rust 2021 edition and uses Tokio 1, Axum 0.8, Reqwest 0.12, Futures Util 0.3, and Async Stream 0.3 compatibility lines. The Go examples use standard-library APIs available in modern supported Go releases, including httputil.ReverseProxy.Rewrite. Dependency declarations are not timeless compatibility guarantees. Keep Cargo.lock for executable projects, review updates, and run the complete tests whenever a toolchain or dependency changes.
Verify the tools currently selected by your shell:
rustc --version
cargo --version
go version
python3 --version
curl --version
Create the following workspace:
mkdir micro-proxies
cd micro-proxies
cargo new rust-proxies
mkdir -p rust-proxies/src/bin
mkdir go-proxies
micro-proxies/
βββ rust-proxies/
β βββ Cargo.toml
β βββ src/
β βββ bin/
β βββ http_proxy.rs
β βββ tcp_proxy.rs
βββ go-proxies/
βββ go.mod
βββ http_proxy.go
βββ tcp_proxy.go
Listener and upstream safety constraints
The examples default to listeners on 127.0.0.1. This is the safest default when Localtonet runs on the same device because the listener is not automatically offered to the entire LAN. If the Localtonet client runs on another device, use a deliberately selected LAN address and enforce host firewall and application access policies.
The default HTTP upstream is http://127.0.0.1:9000. The default TCP upstream is 127.0.0.1:9001. Do not point either proxy back to its own listener. Doing so creates a forwarding loop that consumes connections until a limit is reached.
The Rust and Go HTTP examples both default to port 8080, while both TCP examples default to port 7000. Stop one language implementation before starting the other, or override its listener through the documented environment variable.
Build the HTTP and TCP proxies in Rust
Tokio supplies asynchronous sockets, timers, signals, task coordination, and bidirectional copying. Axum provides the inbound HTTP server, while Reqwest supplies the outbound HTTP client. The implementation does not hand-parse HTTP. Tokio cancellation is cooperative, so the code connects shutdown signals to listener termination and applies deadlines to network operations rather than assuming that dropping an unrelated value will stop all work.
Replace rust-proxies/Cargo.toml with:
[package]
name = "rust-proxies"
version = "0.1.0"
edition = "2021"
[dependencies]
async-stream = "0.3"
axum = "0.8"
futures-util = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
tokio = { version = "1", features = ["full"] }
Rust HTTP reverse proxy
Save this as rust-proxies/src/bin/http_proxy.rs. It accepts HTTP, rejects requests above the configured limit, removes hop-by-hop and client-supplied forwarding headers, sends the request to a fixed upstream, and streams the response through a byte counter. The response stream is terminated if it exceeds MAX_RESPONSE_BYTES.
use async_stream::try_stream;
use axum::{
body::{to_bytes, Body},
extract::{ConnectInfo, State},
http::{
header::{CONNECTION, CONTENT_LENGTH, HOST},
HeaderMap, HeaderName, Request, Response, StatusCode,
},
response::IntoResponse,
routing::any,
Router,
};
use futures_util::{pin_mut, StreamExt};
use reqwest::redirect::Policy;
use std::{
env,
io,
net::SocketAddr,
sync::Arc,
time::Duration,
};
use tokio::{net::TcpListener, signal, sync::Semaphore};
#[derive(Clone)]
struct AppState {
upstream: String,
client: reqwest::Client,
permits: Arc<Semaphore>,
max_request_bytes: usize,
max_response_bytes: usize,
}
fn env_usize(name: &str, default: usize) -> usize {
env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn is_hop_by_hop(name: &HeaderName) -> bool {
matches!(
name.as_str().to_ascii_lowercase().as_str(),
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
)
}
fn connection_tokens(headers: &HeaderMap) -> Vec<String> {
headers
.get(CONNECTION)
.and_then(|v| v.to_str().ok())
.map(|v| {
v.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
async fn proxy(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
request: Request<Body>,
) -> Response<Body> {
let _permit = match state.permits.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"proxy concurrency limit reached\n",
)
.into_response();
}
};
let (parts, body) = request.into_parts();
let request_bytes = match to_bytes(body, state.max_request_bytes + 1).await {
Ok(bytes) if bytes.len() <= state.max_request_bytes => bytes,
Ok(_) => {
return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large\n")
.into_response();
}
Err(_) => {
return (StatusCode::BAD_REQUEST, "unable to read request body\n")
.into_response();
}
};
let path = parts
.uri
.path_and_query()
.map(|v| v.as_str())
.unwrap_or("/");
let target = format!("{}{}", state.upstream.trim_end_matches('/'), path);
let named_by_connection = connection_tokens(&parts.headers);
let mut outbound_headers = HeaderMap::new();
for (name, value) in &parts.headers {
let lower = name.as_str().to_ascii_lowercase();
if *name == HOST
|| *name == CONTENT_LENGTH
|| is_hop_by_hop(name)
|| named_by_connection.iter().any(|token| token == &lower)
|| lower == "forwarded"
|| lower == "x-forwarded-for"
|| lower == "x-forwarded-host"
|| lower == "x-forwarded-proto"
{
continue;
}
outbound_headers.append(name.clone(), value.clone());
}
outbound_headers.insert(
HeaderName::from_static("x-forwarded-for"),
peer.ip().to_string().parse().unwrap(),
);
outbound_headers.insert(
HeaderName::from_static("x-forwarded-proto"),
"http".parse().unwrap(),
);
let upstream = match state
.client
.request(parts.method, target)
.headers(outbound_headers)
.body(request_bytes)
.send()
.await
{
Ok(response) => response,
Err(error) => {
eprintln!("upstream request failed: {error}");
return (
StatusCode::BAD_GATEWAY,
"upstream request failed\n",
)
.into_response();
}
};
let status = upstream.status();
let named_by_connection = connection_tokens(upstream.headers());
let mut builder = Response::builder().status(status);
for (name, value) in upstream.headers() {
let lower = name.as_str().to_ascii_lowercase();
if *name == CONTENT_LENGTH
|| is_hop_by_hop(name)
|| named_by_connection.iter().any(|token| token == &lower)
{
continue;
}
builder = builder.header(name, value);
}
let max = state.max_response_bytes;
let source = upstream.bytes_stream();
let limited = try_stream! {
pin_mut!(source);
let mut total = 0usize;
while let Some(item) = source.next().await {
let chunk = item.map_err(io::Error::other)?;
total = total.saturating_add(chunk.len());
if total > max {
Err(io::Error::new(
io::ErrorKind::InvalidData,
"upstream response exceeded configured limit",
))?;
}
yield chunk;
}
};
builder
.body(Body::from_stream(limited))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"unable to build response\n",
)
.into_response()
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listen: SocketAddr = env::var("LISTEN_ADDR")
.unwrap_or_else(|_| "127.0.0.1:8080".into())
.parse()?;
let upstream =
env::var("UPSTREAM_URL").unwrap_or_else(|_| "http://127.0.0.1:9000".into());
let timeout_secs = env_usize("UPSTREAM_TIMEOUT_SECS", 30) as u64;
let state = AppState {
upstream,
client: reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(Policy::none())
.build()?,
permits: Arc::new(Semaphore::new(env_usize("MAX_CONCURRENT", 128))),
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 1_048_576),
max_response_bytes: env_usize("MAX_RESPONSE_BYTES", 8_388_608),
};
let app = Router::new()
.route("/", any(proxy))
.route("/{*path}", any(proxy))
.with_state(state);
let listener = TcpListener::bind(listen).await?;
println!("Rust HTTP proxy listening on http://{listen}");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let _ = signal::ctrl_c().await;
eprintln!("shutdown signal received");
})
.await?;
Ok(())
}
This implementation intentionally disables automatic redirects so an upstream redirect is returned to the caller rather than followed by the proxy. It buffers each request body up to the configured limit, which keeps the example simple but is not suitable for large streaming uploads. The response is streamed, but a response that crosses the cap can end after headers have already reached the client. A production design may instead reject responses from an upstream with trusted length metadata or use an application-specific streaming policy.
Rust TCP reverse proxy
Save this as rust-proxies/src/bin/tcp_proxy.rs. Tokioβs copy_bidirectional drives both directions until EOF or error. A semaphore bounds active connections, an upstream connection attempt has a deadline, and the entire relay receives a maximum lifetime. During shutdown, the listener stops admitting work and active tasks receive a drain period.
use std::{
env,
net::SocketAddr,
sync::Arc,
time::Duration,
};
use tokio::{
io::copy_bidirectional,
net::{TcpListener, TcpStream},
signal,
sync::Semaphore,
task::JoinSet,
time::{timeout, Instant},
};
fn env_u64(name: &str, default: u64) -> u64 {
env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
async fn relay(
mut client: TcpStream,
upstream: SocketAddr,
dial_timeout: Duration,
lifetime: Duration,
) {
let peer = client.peer_addr().ok();
let started = Instant::now();
let mut server = match timeout(dial_timeout, TcpStream::connect(upstream)).await {
Ok(Ok(stream)) => stream,
Ok(Err(error)) => {
eprintln!("upstream refused for {peer:?}: {error}");
return;
}
Err(_) => {
eprintln!("upstream dial timed out for {peer:?}");
return;
}
};
match timeout(lifetime, copy_bidirectional(&mut client, &mut server)).await {
Ok(Ok((client_to_upstream, upstream_to_client))) => {
println!(
"closed {peer:?}: sent={client_to_upstream} received={upstream_to_client} elapsed={:?}",
started.elapsed()
);
}
Ok(Err(error)) => {
eprintln!("relay error for {peer:?}: {error}");
}
Err(_) => {
eprintln!("connection lifetime exceeded for {peer:?}");
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listen: SocketAddr = env::var("LISTEN_ADDR")
.unwrap_or_else(|_| "127.0.0.1:7000".into())
.parse()?;
let upstream: SocketAddr = env::var("UPSTREAM_ADDR")
.unwrap_or_else(|_| "127.0.0.1:9001".into())
.parse()?;
let dial_timeout = Duration::from_secs(env_u64("DIAL_TIMEOUT_SECS", 5));
let lifetime = Duration::from_secs(env_u64("CONNECTION_LIFETIME_SECS", 300));
let drain_timeout = Duration::from_secs(env_u64("DRAIN_TIMEOUT_SECS", 15));
let max_connections = env_u64("MAX_CONNECTIONS", 128) as usize;
let listener = TcpListener::bind(listen).await?;
let permits = Arc::new(Semaphore::new(max_connections));
let mut tasks = JoinSet::new();
println!("Rust TCP proxy listening on {listen}, upstream {upstream}");
loop {
tokio::select! {
result = listener.accept() => {
let (client, peer) = result?;
let permit = match permits.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
eprintln!("connection rejected at limit: {peer}");
drop(client);
continue;
}
};
tasks.spawn(async move {
let _permit = permit;
relay(client, upstream, dial_timeout, lifetime).await;
});
}
_ = signal::ctrl_c() => {
eprintln!("shutdown signal received; stopping admission");
break;
}
}
}
let drained = timeout(drain_timeout, async {
while tasks.join_next().await.is_some() {}
}).await;
if drained.is_err() {
eprintln!("drain deadline reached; aborting remaining connections");
tasks.abort_all();
while tasks.join_next().await.is_some() {}
}
Ok(())
}
The connection-lifetime timer is an absolute cap, not a true idle timer. A true idle timeout must reset whenever bytes move in either direction, which requires a more specialized copy loop or protocol-aware framing. Use the absolute cap only when it matches the expected sessions. Long-lived database, terminal, or subscription connections may need a different policy.
Build the HTTP and TCP proxies in Go
The Go HTTP implementation uses net/http and httputil.ReverseProxy. Its Rewrite callback selects the fixed upstream and rebuilds forwarding metadata after Go removes inbound forwarding headers. The TCP implementation uses two io.Copy operations, half-closes each destination after its source reaches EOF, and waits for both directions.
Create go-proxies/go.mod:
module example.com/micro-proxies
go 1.22
The moduleβs go directive records the intended language compatibility level for this tutorial. Use a currently supported Go release in maintained deployments and rerun all tests when changing it.
Go HTTP reverse proxy
Save this as go-proxies/http_proxy.go:
package main
import (
"context"
"errors"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"strconv"
"syscall"
"time"
)
func envInt(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil || value <= 0 {
return fallback
}
return value
}
func main() {
listen := os.Getenv("LISTEN_ADDR")
if listen == "" {
listen = "127.0.0.1:8080"
}
upstreamText := os.Getenv("UPSTREAM_URL")
if upstreamText == "" {
upstreamText = "http://127.0.0.1:9000"
}
upstream, err := url.Parse(upstreamText)
if err != nil || upstream.Scheme == "" || upstream.Host == "" {
log.Fatalf("invalid UPSTREAM_URL: %q", upstreamText)
}
maxBody := int64(envInt("MAX_REQUEST_BYTES", 1_048_576))
maxConcurrent := envInt("MAX_CONCURRENT", 128)
upstreamTimeout := time.Duration(envInt("UPSTREAM_TIMEOUT_SECS", 30)) * time.Second
drainTimeout := time.Duration(envInt("DRAIN_TIMEOUT_SECS", 15)) * time.Second
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 128,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: upstreamTimeout,
ExpectContinueTimeout: 1 * time.Second,
}
proxy := &httputil.ReverseProxy{
Rewrite: func(request *httputil.ProxyRequest) {
request.SetURL(upstream)
request.SetXForwarded()
request.Out.Host = upstream.Host
},
Transport: transport,
ErrorHandler: func(writer http.ResponseWriter, request *http.Request, err error) {
log.Printf("upstream request failed: %v", err)
http.Error(writer, "upstream request failed", http.StatusBadGateway)
},
}
permits := make(chan struct{}, maxConcurrent)
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
select {
case permits <- struct{}{}:
defer func() { <-permits }()
default:
http.Error(writer, "proxy concurrency limit reached", http.StatusServiceUnavailable)
return
}
request.Body = http.MaxBytesReader(writer, request.Body, maxBody)
proxy.ServeHTTP(writer, request)
})
server := &http.Server{
Addr: listen,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 45 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
go func() {
<-signals
log.Print("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), drainTimeout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
_ = server.Close()
}
}()
log.Printf("Go HTTP proxy listening on http://%s", listen)
err = server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
transport.CloseIdleConnections()
}
Goβs reverse-proxy implementation removes hop-by-hop headers and supports cancellation through the request context. The Rewrite API removes inbound Forwarded and X-Forwarded-* headers before the callback and SetXForwarded constructs new values. This is safer than appending to arbitrary client claims, but the direct peer might itself be a trusted relay. Define the identity boundary for your deployment rather than interpreting an address header as authentication.
The example limits request bodies but does not impose a universal response-body byte cap. It uses a response-header timeout and server write timeout. If your application requires a strict response size, add a protocol-aware response policy and test streaming behavior before publication.
Go TCP reverse proxy
Save this as go-proxies/tcp_proxy.go:
package main
import (
"context"
"io"
"log"
"net"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
)
func envInt(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil || value <= 0 {
return fallback
}
return value
}
func copyHalfClose(destination net.Conn, source net.Conn, done chan<- error) {
_, err := io.Copy(destination, source)
if tcp, ok := destination.(*net.TCPConn); ok {
_ = tcp.CloseWrite()
}
done <- err
}
func relay(client net.Conn, upstream string, dialTimeout, lifetime time.Duration) {
defer client.Close()
dialer := net.Dialer{Timeout: dialTimeout}
server, err := dialer.Dial("tcp", upstream)
if err != nil {
log.Printf("upstream dial failed for %s: %v", client.RemoteAddr(), err)
return
}
defer server.Close()
deadline := time.Now().Add(lifetime)
_ = client.SetDeadline(deadline)
_ = server.SetDeadline(deadline)
done := make(chan error, 2)
go copyHalfClose(server, client, done)
go copyHalfClose(client, server, done)
first := <-done
second := <-done
if first != nil {
log.Printf("relay direction ended with error: %v", first)
}
if second != nil {
log.Printf("relay direction ended with error: %v", second)
}
}
func main() {
listen := os.Getenv("LISTEN_ADDR")
if listen == "" {
listen = "127.0.0.1:7000"
}
upstream := os.Getenv("UPSTREAM_ADDR")
if upstream == "" {
upstream = "127.0.0.1:9001"
}
dialTimeout := time.Duration(envInt("DIAL_TIMEOUT_SECS", 5)) * time.Second
lifetime := time.Duration(envInt("CONNECTION_LIFETIME_SECS", 300)) * time.Second
drainTimeout := time.Duration(envInt("DRAIN_TIMEOUT_SECS", 15)) * time.Second
maxConnections := envInt("MAX_CONNECTIONS", 128)
listener, err := net.Listen("tcp", listen)
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
permits := make(chan struct{}, maxConnections)
var active sync.WaitGroup
go func() {
<-ctx.Done()
log.Print("shutdown signal received; stopping admission")
_ = listener.Close()
}()
log.Printf("Go TCP proxy listening on %s, upstream %s", listen, upstream)
for {
client, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
break
}
log.Printf("accept failed: %v", err)
continue
}
select {
case permits <- struct{}{}:
active.Add(1)
go func() {
defer active.Done()
defer func() { <-permits }()
relay(client, upstream, dialTimeout, lifetime)
}()
default:
log.Printf("connection rejected at limit: %s", client.RemoteAddr())
_ = client.Close()
}
}
drained := make(chan struct{})
go func() {
active.Wait()
close(drained)
}()
select {
case <-drained:
log.Print("active TCP connections drained")
case <-time.After(drainTimeout):
log.Print("drain deadline reached; active connections will end with process exit")
}
}
As in the Rust TCP example, SetDeadline applies an absolute lifetime. It does not reset when traffic moves. The two copy goroutines complete independently, allowing one side to finish sending while the opposite direction drains. Process exit after the drain deadline closes any remaining operating-system sockets.
Start the test services and verify every proxy locally
Local verification separates proxy defects from tunnel configuration problems. Start the upstream first, then one proxy implementation, then issue the client request. Expected results are included below.
Start the HTTP upstream
From an empty temporary directory, start Pythonβs static HTTP server:
mkdir -p /tmp/micro-proxy-http
printf 'micro proxy upstream is working\n' > /tmp/micro-proxy-http/index.html
cd /tmp/micro-proxy-http
python3 -m http.server 9000 --bind 127.0.0.1
In another terminal, verify the upstream directly:
curl -i http://127.0.0.1:9000/
Expect HTTP/1.0 200 OK or HTTP/1.1 200 OK, depending on the Python version, followed by micro proxy upstream is working.
Run and test the Rust HTTP proxy
cd micro-proxies/rust-proxies
cargo run --bin http_proxy
In another terminal:
curl -i http://127.0.0.1:8080/
Expect a successful status and the same upstream body. To inspect the resolved dependency graph and preserve it for review, use:
cargo tree
cargo test
cargo clippy --all-targets --all-features
Run and test the Go HTTP proxy
Stop the Rust HTTP proxy with Ctrl+C, then run:
cd micro-proxies/go-proxies
go run http_proxy.go
Test the same listener:
curl -i http://127.0.0.1:8080/
go vet ./...
The response should match the direct upstream result. If it returns 502 Bad Gateway, confirm that the Python service is still running on port 9000.
Start a TCP echo upstream
Stop any process using port 9001, then run this small standard-library echo server:
python3 - <<'PY'
import socket
import threading
def echo(conn):
with conn:
while True:
data = conn.recv(65536)
if not data:
return
conn.sendall(data)
with socket.socket() as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 9001))
server.listen()
print("TCP echo upstream listening on 127.0.0.1:9001")
while True:
conn, _ = server.accept()
threading.Thread(target=echo, args=(conn,), daemon=True).start()
PY
Run and test the Rust TCP proxy
cd micro-proxies/rust-proxies
cargo run --bin tcp_proxy
Use Python as the client:
python3 - <<'PY'
import socket
with socket.create_connection(("127.0.0.1", 7000), timeout=5) as sock:
sock.sendall(b"hello through rust\n")
print(sock.recv(4096).decode(), end="")
PY
Expect hello through rust. The proxy should also log transferred byte counts when the connection closes.
Run and test the Go TCP proxy
Stop the Rust TCP proxy, then run:
cd micro-proxies/go-proxies
go run tcp_proxy.go
Test it:
python3 - <<'PY'
import socket
with socket.create_connection(("127.0.0.1", 7000), timeout=5) as sock:
sock.sendall(b"hello through go\n")
print(sock.recv(4096).decode(), end="")
PY
Expect hello through go.
Verify limits, malformed input, and shutdown
Test the one-megabyte HTTP request limit with a body slightly above the default:
python3 - <<'PY' | curl -i --data-binary @- http://127.0.0.1:8080/
import sys
sys.stdout.buffer.write(b"x" * 1100000)
PY
The Rust implementation returns 413 Payload Too Large. The Go server also rejects an oversized body when the reverse proxy attempts to read it, although the exact connection presentation can vary with client and transport behavior.
Send malformed HTTP directly over a socket:
python3 - <<'PY'
import socket
with socket.create_connection(("127.0.0.1", 8080), timeout=5) as sock:
sock.sendall(b"GET / HTTP/1.1\r\nBroken Header\r\n\r\n")
print(sock.recv(4096).decode(errors="replace"))
PY
Expect the HTTP server library to reject the malformed request rather than forward it. The exact error body is library-specific and should not be used as an application contract.
For graceful shutdown, begin a request or TCP session, press Ctrl+C in the proxy terminal, and observe that the listener stops accepting new work. HTTP shutdown waits for active requests according to the server behavior and configured drain period. TCP shutdown waits for active tasks until its drain deadline. Test this again with an intentionally slow upstream before relying on it operationally.
Security, resource controls, and observability
Do not treat forwarding headers as identity
X-Forwarded-For and Forwarded are metadata, not authentication credentials. A direct internet client can submit either header. The examples discard the inbound values and reconstruct forwarding information from the connected peer. If another trusted proxy sits in front of the micro proxy, define exactly which hop is trusted and how its metadata is validated.
Avoid authorizing administrative operations from a claimed client IP alone. Use application authentication, least privilege, explicit route policy, and separate private listeners for metrics, debugging, profiling, or administration.
Understand HTTP limitations
These examples are suitable for learning, controlled development exposure, and narrow internal tools after review. They do not implement dynamic routing, retries, circuit breaking, WebSocket-specific policy, cache behavior, rate limiting by authenticated identity, distributed tracing, access-control rules, certificate management, or response transformation.
The Rust HTTP proxy buffers request bodies, so it is not intended for large uploads. The Go proxy relies on the standard reverse proxy for streaming and connection management, but a general server write timeout can conflict with legitimately long streaming responses. Neither example should be assumed to support every upgrade or long-lived streaming workflow without dedicated tests.
Understand TCP limitations
A raw TCP relay cannot inspect application messages or enforce HTTP methods, paths, body limits, or status-code policy. The maximum connection lifetime prevents indefinite retention, but it can interrupt valid long-running sessions. A strict idle timeout needs activity-aware deadline renewal. TLS, authentication, and application authorization must be supplied by the carried protocol or another deliberately configured layer.
Log outcomes, not secrets
Useful telemetry includes accepted and rejected connections, active requests, upstream dial failures, timeout counts, broad response-status groups, transferred byte totals, and graceful-shutdown duration. Do not log authorization headers, cookies, authentication tokens, full sensitive URLs, request or response payloads, or arbitrary user-controlled strings without sanitization.
Metric labels should have bounded cardinality. A raw URL, client-supplied request identifier, arbitrary hostname, or complete error string can generate an unbounded number of label values. Record stable route categories and sanitized error classes instead.
Before publication, add the authentication required by the application, confirm that the upstream cannot be selected by a client, test concurrency limits, and keep administrative listeners private. A small executable is not automatically a small attack surface.
Rust and Go implementation tradeoffs
Both languages can implement efficient concurrent proxies. The examples differ mainly in how they express ownership, cancellation, and network lifecycle. Rust makes task ownership and shared permits explicit, while Go uses goroutines, channels, contexts, and server shutdown APIs. Neither language automatically prevents unlimited concurrency, missing deadlines, forwarding-header mistakes, or application-level denial of service.
| Concern | Rust example | Go example |
|---|---|---|
| Concurrency | Tokio tasks and semaphores | Goroutines and buffered channels |
| HTTP stack | Axum inbound and Reqwest outbound | net/http and httputil.ReverseProxy |
| TCP copying | Tokio copy_bidirectional | Two io.Copy goroutines with half-close |
| Shutdown | Signal future, server shutdown, and JoinSet drain | Signal context, http.Server shutdown, and WaitGroup drain |
| Dependency surface | Async runtime and HTTP ecosystem crates | Proxy examples use the standard library |
| Primary maintenance risk | Understanding async cancellation and dependency behavior | Preventing goroutine retention and connecting deadlines to sockets |
Choose based on team expertise, deployment constraints, dependency policy, and measurements from representative traffic. Rustβs ownership model can make resource relationships explicit. Goβs standard networking APIs can keep a small service concise. Generic language benchmarks do not replace testing with realistic body sizes, connection churn, upstream latency, failure rates, and shutdown conditions.
Publish a tested proxy with Localtonet
A reverse proxy and an outbound tunnel solve separate problems. Your Rust or Go process owns upstream selection, HTTP handling, connection limits, timeouts, logging, and shutdown. With Localtonet, our client application establishes an outbound connection to a Localtonet relay server. The resulting tunnel supplies a public URL or a public host and port without inbound router port forwarding, firewall changes, VPN setup, or a public IP address.
Complete local verification before creating public reachability. For current interface details, consult the Localtonet HTTP tunnel documentation or Localtonet TCP tunnel documentation alongside the workflow below.
Install and run the Localtonet client
Install the Localtonet application on the device running the proxy or on a device that can reach its listener. Confirm that the selected Rust or Go process is already listening and passes its local tests.
Authenticate or select the device
Use the device-specific authentication token for the client that will run the tunnel. Treat the token as a secret. Do not place it in proxy source code, example commands, logs, or screenshots.
Select an available relay server
Choose a relay server or region from the values currently offered in the Localtonet dashboard. Available values can vary, so this tutorial does not hardcode a server code.
Create the appropriate tunnel configuration
For the HTTP proxy, create an HTTP tunnel targeting the proxyβs local IP address and port, such as 127.0.0.1 and 8080 when the client runs on the same device. For the TCP proxy, create a TCP tunnel targeting its local IP address and port, such as 127.0.0.1 and 7000.
Start the tunnel and test the assigned endpoint
Press Start. An HTTP tunnel provides a public URL, while a TCP tunnel provides a public host and port. Verify the endpoint from a separate network or remote system rather than relying only on the machine running the proxy.
Stop or delete exposure when it is no longer needed
Stop the tunnel after temporary testing or review. Delete obsolete tunnel configurations. The public endpoint is available only while the selected Localtonet client is connected and the tunnel is running.
Tunnel configuration and tunnel execution are separate lifecycle states. Use the Start button after creation. If you restart a proxy, verify its local listener first, then confirm that the Localtonet client is connected and the tunnel is running.
Verify an HTTP tunnel publicly
Substitute the public HTTPS URL displayed by the dashboard:
curl -i https://your-assigned-public-url.example/
The placeholder is not a real Localtonet address. Use only the assigned URL shown for your running tunnel. Expect the same successful body returned during local testing. Also test an unknown path, an oversized request, an upstream outage, and any application authentication requirement.
Verify a TCP tunnel publicly
Substitute the assigned host and port:
PUBLIC_HOST="assigned-host-from-dashboard"
PUBLIC_PORT="assigned-port-from-dashboard"
python3 - "$PUBLIC_HOST" "$PUBLIC_PORT" <<'PY'
import socket
import sys
host = sys.argv[1]
port = int(sys.argv[2])
with socket.create_connection((host, port), timeout=10) as sock:
sock.sendall(b"hello through public tcp tunnel\n")
print(sock.recv(4096).decode(), end="")
PY
Expect the echo message to return through the TCP proxy. Do not paste a token into these commands. The public host and port are endpoint coordinates, while the device token is a secret used to identify the Localtonet client.
Troubleshooting and routine operation
The listener reports that the address is already in use
Another process already owns the configured port, or the previous proxy is still running. Stop that process or select another listener. For example:
LISTEN_ADDR=127.0.0.1:8081 cargo run --bin http_proxy
LISTEN_ADDR=127.0.0.1:8081 go run http_proxy.go
If the Localtonet tunnel already exists, update its local target to the listener you actually selected. Do not accidentally point an HTTP tunnel at the TCP listener or vice versa.
The proxy returns a refused connection or 502 response
Test the upstream directly. For HTTP, run curl -i http://127.0.0.1:9000/. For TCP, connect directly to port 9001 with the Python client. Confirm that UPSTREAM_URL includes the correct HTTP scheme and that UPSTREAM_ADDR contains a valid host and port.
Check for a forwarding loop. The upstream must not equal the proxy listener. A proxy listening on port 8080 cannot safely use http://127.0.0.1:8080 as its own upstream.
Requests or connections time out
Determine which phase timed out: client-to-proxy, upstream dialing, response headers, body transfer, or total TCP lifetime. A slow HTTP upstream may exceed UPSTREAM_TIMEOUT_SECS. A legitimate long-lived TCP session may exceed CONNECTION_LIFETIME_SECS. Increase limits only after confirming that the traffic is expected and that concurrency remains bounded.
Connections appear leaked or stalled
Inspect whether the client or upstream is waiting indefinitely for the other side to send or close. The TCP examples impose an absolute deadline, which eventually releases the connection. For a maintained service, add active-connection metrics and activity-aware idle deadlines. Verify that termination reaches the drain path and that repeated restarts are not leaving old processes behind.
Forwarding headers show an unexpected address
The directly connected peer may be the Localtonet client path or another local relay rather than the original public client. Do not use the header as proof of identity. If the application needs verified user identity, use application authentication. Header provenance and client-address behavior should be tested in the exact deployment rather than assumed.
The public endpoint does not connect
Test the proxy locally first. Then confirm that the Localtonet client is running on the selected device, the correct device token was used, the local target address is reachable from that device, and the tunnel was started. A created but stopped tunnel is unavailable. A running tunnel also becomes unavailable if its selected client or device disconnects.
How to stop and restart safely
For temporary exposure, stop the Localtonet tunnel first so new public traffic no longer arrives. Send Ctrl+C or a normal termination signal to the proxy and allow the drain deadline to complete. Restart the upstream if necessary, start the proxy, repeat the direct local test, then start the tunnel and repeat public verification.
Final validation checklist
Before treating either implementation as an operational service, verify all of the following:
- The listener binds only to the intended local or LAN interface.
- The upstream is fixed by trusted operator configuration and cannot be replaced by client input.
- The upstream does not point back to the proxy listener.
- HTTP request-body, header, concurrency, and timeout behavior has been tested.
- TCP concurrency, dial failure, half-close, lifetime, and drain behavior has been tested.
- Malformed HTTP is rejected by the HTTP library rather than forwarded as an ambiguous message.
- Client-supplied forwarding headers are not accepted as authenticated identity.
- Authentication and authorization are enforced by the appropriate application or proxy layer.
- Logs omit tokens, cookies, authorization headers, payloads, and other secrets.
- The proxy drains active work when it receives a normal termination signal.
- The Localtonet tunnel targets the proxy listener rather than a metrics or administrative endpoint.
- HTTP publication is tested with the assigned public URL and TCP publication with the assigned public host and port.
- The tunnel is stopped when temporary public access is no longer required.
Frequently asked questions
Is a reverse proxy the same as a Localtonet tunnel?
No. The reverse proxy applies local forwarding, protocol, timeout, and resource policies. The Localtonet tunnel provides a public path to its local listener through an outbound connection from the selected client device.
Should I publish the HTTP proxy through an HTTP or TCP tunnel?
Use a Localtonet HTTP tunnel when the desired public interface is an HTTP URL. Use a TCP tunnel when clients require a raw host and port. The tunnel boundary should match the interface expected by the external client.
Do these examples support WebSockets?
WebSocket behavior is not part of the maintained scope of these HTTP examples. Connection upgrades need dedicated forwarding, timeout, shutdown, and limit tests. Use the raw TCP proxy only if a raw TCP boundary is appropriate for the entire application protocol and its security model.
Why do the TCP proxies use a maximum lifetime instead of an idle timeout?
A true idle timeout must reset whenever either direction transfers data. The tutorial uses a simpler absolute deadline that bounds total retention. Long-lived or intermittently active protocols need an activity-aware copy loop and a timeout policy designed for that protocol.
Can forwarding headers replace application authentication?
No. Forwarding headers describe routing context and can be forged outside a carefully controlled trust boundary. Use real application authentication and authorization for protected operations.
Does creating a Localtonet tunnel immediately make the proxy public?
No. Creating a tunnel does not start it. Press Start after configuration. The endpoint remains available only while the selected Localtonet client is connected and the tunnel is running.
Are these proxies ready to replace a mature production gateway?
Not without requirements-specific engineering and review. They provide runnable, bounded foundations, but they do not include every capability expected from an authentication gateway, service mesh, load balancer, or full edge proxy.
Publish your tested Rust or Go proxy with Localtonet
Run the implementation locally, verify its limits and shutdown behavior, then connect only the required HTTP or TCP listener to a Localtonet tunnel. Keep the upstream fixed, protect the application with appropriate authentication, and stop temporary exposure when the test is complete.
Get Started Free β