35 min read

Building Micro Proxies in Rust and Go

Design secure HTTP and TCP micro proxies with bounded resources, graceful shutdown, observability, and safe exposure through Localtonet.

Developer Tools · Rust and Go Proxies · Localtonet · 2026

Build four small proxies with explicit traffic limits and a deliberate shutdown policy

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, check their boundaries, and publish a selected listener through Localtonet. The examples use fixed upstream destinations, concurrency limits, network timeouts, and bounded shutdown drains. They are learning foundations, not replacements for a mature edge proxy, authentication gateway, or service mesh.

🔒 Fixed upstreams and forwarding-header controls 🌐 HTTP URLs and TCP host-port endpoints ⚡ Four implementations with executable checks

What these micro proxies build

A reverse proxy accepts a client connection, connects to an upstream service, forwards traffic, and returns the result. “Micro” describes the deliberately narrow scope here: one operator-configured upstream per listener, environment-variable configuration, and no dynamic route administration interface. A fixed destination prevents clients from selecting arbitrary private-network targets, but does not replace application authentication.

The HTTP examples interpret methods, paths, headers, bodies, and status codes. Both buffer request bodies before contacting the upstream, so an ordinary oversized request receives a 413 response without partially reaching the application. The TCP examples instead relay opaque bytes. They limit concurrent connections, time out dialing, apply an absolute relay lifetime, and allow half-close behavior so one direction can finish while the other drains.

Rust and Go micro proxies carrying HTTP and TCP traffic through an outbound tunnel.
A micro proxy applies local traffic policy; an outbound tunnel provides public reachability for its listener.
🌐HTTP boundaryUse request-aware handling for web applications, APIs, and callback receivers.
🔌TCP boundaryUse an opaque byte relay when the application protocol must remain uninterpreted.
🧱Fixed destinationTrusted operator configuration selects the upstream, never an incoming URL parameter.
⏱️Explicit limitsBody size, concurrent work, dialing, and relay lifetime have separate controls.
🔒Header policyInbound forwarding identity is discarded and rebuilt from the immediate peer.
🛑Drain deadlineOn a handled shutdown signal, admission stops and active work gets a limited completion window.

HTTP is not just text copied over TCP

HTTP intermediaries must handle message framing, connection options, request targets, and response semantics consistently. These examples use maintained HTTP libraries rather than writing a parser. The protocol baseline includes HTTP Semantics, RFC 9110 and HTTP/1.1, RFC 9112. The RFC Editor identifies RFC 9112 as updated by RFC 9931. That update must be included in a current protocol review; the supplied evidence does not contain its normative text, so this tutorial does not claim conformance to its changes.

A TCP relay has no concept of HTTP status codes, message boundaries, or user authentication. The carried protocol must supply authentication and, where required, encryption. Neither proxying nor exposing a listener through a tunnel makes an unauthenticated application safe.

Comparison of HTTP message handling and a bidirectional TCP byte stream.
HTTP exposes request semantics; TCP presents an opaque bidirectional stream.
ImplementationRequest or connection policyTimeout and response policy
Rust HTTPBuffered request limit and active-request permitsBody-read deadline, Reqwest request timeout, streamed response cap
Go HTTPBuffered request limit and active-handler permitsServer deadlines, upstream response-header timeout; no response byte cap
Rust TCPSemaphore-limited connectionsDial timeout, absolute relay lifetime, task drain deadline
Go TCPChannel-limited connectionsDial timeout, socket deadlines, process drain deadline

Prerequisites, compatibility, and project layout

You need Cargo and Rust, Go, Python 3, and curl. The supplied shell commands target a POSIX environment, such as Linux, macOS, or a Linux environment under WSL. Heredocs, inline environment assignments, and /tmp paths are not PowerShell syntax. Windows-native commands and signal behavior are outside this tutorial’s verified scope.

Install the toolchains using the Rust documentation and Go installation guide. Use a currently supported release. Rust’s 2021 edition and the Go module’s go 1.22 directive are language settings, not claims that those historical toolchains are appropriate for current deployment.

Validation status and reproducibility

No execution environment or tested-version record was supplied for this revision. The examples and checks below have not been rerun here, and no Rust, crate, or Go version is represented as tested. Before publication or deployment, run all four programs, retain the actual toolchain output and Cargo.lock, and record the pass/fail results. Compatibility ranges are not indefinite compatibility guarantees.

rustc --version
cargo --version
go version
python3 --version
curl --version

mkdir micro-proxies
cd micro-proxies
cargo new rust-proxies
rm rust-proxies/src/main.rs
mkdir -p rust-proxies/src/bin
mkdir -p go-proxies/cmd/http-proxy go-proxies/cmd/tcp-proxy
mkdir -p tests
export MICRO_PROXIES_ROOT="$PWD"

Keep the two Go programs in separate command packages. Two files containing main and envInt in one directory would redeclare those functions and break package-wide builds and vetting.

micro-proxies/
├── rust-proxies/
│   ├── Cargo.toml
│   ├── Cargo.lock                 # generated; retain after validation
│   └── src/bin/
│       ├── http_proxy.rs
│       └── tcp_proxy.rs
├── go-proxies/
│   ├── go.mod
│   └── cmd/
│       ├── http-proxy/main.go
│       └── tcp-proxy/main.go
└── tests/
    ├── http_upstream.py
    └── http_checks.py

In each new terminal, set MICRO_PROXIES_ROOT to the absolute path of your workspace. All listeners default to loopback: HTTP on 127.0.0.1:8080 and TCP on 127.0.0.1:7000. Upstreams default to ports 9000 and 9001 respectively. Never point a proxy back to its own listener, which creates a forwarding loop.

Run only one language implementation on each default port. If Localtonet runs on the same device, loopback is normally the appropriate local target. If the client runs elsewhere, deliberately choose a reachable LAN address and enforce host and application access policies.

Build the Rust HTTP and TCP proxies

Tokio supplies sockets, timers, signals, and task coordination. Axum serves inbound HTTP; Reqwest handles outbound HTTP. Add http-body-util explicitly so the body-limit error can be identified by type instead of incorrectly turning every read failure into 400.

Save this as rust-proxies/Cargo.toml:

[package]
name = "rust-proxies"
version = "0.1.0"
edition = "2021"

[dependencies]
async-stream = "0.3"
axum = "0.8"
futures-util = "0.3"
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
tokio = { version = "1", features = ["full"] }

Rust HTTP reverse proxy

Save the following complete program as rust-proxies/src/bin/http_proxy.rs. The request limit is passed directly to to_bytes. A LengthLimitError becomes 413; an unrelated body-read error becomes 400. The helper also processes every Connection field value, removing all nominated headers on requests and responses. Invalid connection-option syntax fails closed.

use async_stream::try_stream;
use axum::{
    body::{to_bytes, Body, Bytes},
    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 http_body_util::LengthLimitError;
use reqwest::redirect::Policy;
use std::{env, error::Error, future::IntoFuture, io, net::SocketAddr,
          sync::Arc, time::Duration};
use tokio::{net::TcpListener, signal, sync::{oneshot, Semaphore}, time::timeout};

#[derive(Clone)]
struct AppState {
    upstream: String,
    client: reqwest::Client,
    permits: Arc<Semaphore>,
    max_request: usize,
    max_response: usize,
}

fn setting(name: &str, fallback: usize) -> usize {
    env::var(name).ok().and_then(|s| s.parse().ok())
        .filter(|n| *n > 0).unwrap_or(fallback)
}

fn reply(status: StatusCode, text: &'static str) -> Response<Body> {
    (status, text).into_response()
}

fn limit_error(error: &(dyn Error + 'static)) -> bool {
    if error.is::<LengthLimitError>() {
        return true;
    }
    error.source().is_some_and(limit_error)
}

async fn read_body(body: Body, max: usize) -> Result<Bytes, StatusCode> {
    to_bytes(body, max).await.map_err(|error| {
        if limit_error(&error) {
            StatusCode::PAYLOAD_TOO_LARGE
        } else {
            StatusCode::BAD_REQUEST
        }
    })
}

fn strip_hop_headers(headers: &mut HeaderMap) -> Result<(), ()> {
    let mut nominated = Vec::new();
    for value in headers.get_all(CONNECTION).iter() {
        let value = value.to_str().map_err(|_| ())?;
        for token in value.split(',') {
            let token = token.trim_matches([' ', '\t']);
            if !token.is_empty() {
                nominated.push(
                    HeaderName::from_bytes(token.as_bytes()).map_err(|_| ())?
                );
            }
        }
    }
    for name in nominated {
        headers.remove(name);
    }
    for name in [
        "connection", "keep-alive", "proxy-connection",
        "proxy-authenticate", "proxy-authorization",
        "te", "trailer", "transfer-encoding", "upgrade",
    ] {
        headers.remove(name);
    }
    Ok(())
}

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(p) => p,
        Err(_) => return reply(StatusCode::SERVICE_UNAVAILABLE, "busy\n"),
    };
    let (mut parts, body) = request.into_parts();
    if parts.method == axum::http::Method::CONNECT {
        return reply(StatusCode::METHOD_NOT_ALLOWED, "CONNECT unsupported\n");
    }
    if strip_hop_headers(&mut parts.headers).is_err() {
        return reply(StatusCode::BAD_REQUEST, "invalid Connection header\n");
    }
    let bytes = match timeout(
        Duration::from_secs(30), read_body(body, state.max_request)
    ).await {
        Ok(Ok(bytes)) => bytes,
        Ok(Err(status)) => return reply(status, "request body rejected\n"),
        Err(_) => return reply(StatusCode::REQUEST_TIMEOUT, "body read timed out\n"),
    };
    parts.headers.remove(HOST);
    parts.headers.remove(CONTENT_LENGTH);
    for name in [
        "forwarded", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"
    ] {
        parts.headers.remove(name);
    }
    parts.headers.insert(
        HeaderName::from_static("x-forwarded-for"),
        peer.ip().to_string().parse().unwrap(),
    );
    parts.headers.insert(
        HeaderName::from_static("x-forwarded-proto"), "http".parse().unwrap()
    );
    let path = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
    let target = format!("{}{}", state.upstream, path);
    let upstream = match state.client.request(parts.method, target)
        .headers(parts.headers).body(bytes).send().await {
        Ok(response) => response,
        Err(_) => return reply(StatusCode::BAD_GATEWAY, "upstream failed\n"),
    };
    let status = upstream.status();
    if status == StatusCode::SWITCHING_PROTOCOLS {
        return reply(StatusCode::BAD_GATEWAY, "upgrades unsupported\n");
    }
    let mut headers = upstream.headers().clone();
    if strip_hop_headers(&mut headers).is_err() {
        return reply(StatusCode::BAD_GATEWAY, "invalid upstream Connection\n");
    }
    headers.remove(CONTENT_LENGTH);
    let max = state.max_response;
    let source = upstream.bytes_stream();
    let limited = try_stream! {
        // Keep the concurrency permit through response-body streaming.
        let _permit = permit;
        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, "response limit exceeded"
                ))?;
            }
            yield chunk;
        }
    };
    let mut response = Response::new(Body::from_stream(limited));
    *response.status_mut() = status;
    *response.headers_mut() = headers;
    response
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn 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 url = reqwest::Url::parse(&upstream)?;
    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
        || !url.username().is_empty() || url.password().is_some()
        || url.path() != "/" || url.query().is_some() || url.fragment().is_some()
    {
        return Err("UPSTREAM_URL must be an HTTP(S) origin without credentials".into());
    }
    let state = AppState {
        upstream: url.as_str().trim_end_matches('/').to_owned(),
        client: reqwest::Client::builder()
            .no_proxy()
            .timeout(Duration::from_secs(setting("UPSTREAM_TIMEOUT_SECS", 30) as u64))
            .redirect(Policy::none()).build()?,
        permits: Arc::new(Semaphore::new(setting("MAX_CONCURRENT", 128))),
        max_request: setting("MAX_REQUEST_BYTES", 1_048_576),
        max_response: setting("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?;
    let drain = Duration::from_secs(setting("DRAIN_TIMEOUT_SECS", 15) as u64);
    let (notice_tx, notice_rx) = oneshot::channel();
    let server = axum::serve(
        listener, app.into_make_service_with_connect_info::<SocketAddr>()
    ).with_graceful_shutdown(async move {
        let _ = signal::ctrl_c().await;
        let _ = notice_tx.send(());
    }).into_future();
    tokio::pin!(server);
    println!("Rust HTTP listening on {listen}");
    tokio::select! {
        result = &mut server => result?,
        _ = notice_rx => {
            eprintln!("stopping admission; draining");
            match timeout(drain, &mut server).await {
                Ok(result) => result?,
                Err(_) => eprintln!("drain expired; exiting runtime"),
            }
        }
    }
    // Returning from main shuts down this executable's Tokio runtime.
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn body_boundaries() {
        for size in [7, 8] {
            assert_eq!(read_body(Body::from(vec![0; size]), 8)
                .await.unwrap().len(), size);
        }
        for size in [9, 100] {
            assert_eq!(read_body(Body::from(vec![0; size]), 8)
                .await.unwrap_err(), StatusCode::PAYLOAD_TOO_LARGE);
        }
    }

    #[test]
    fn all_connection_fields_are_removed() {
        let mut headers = HeaderMap::new();
        headers.append(CONNECTION, "keep-alive, X-First".parse().unwrap());
        headers.append(CONNECTION, "X-Second, x-THIRD".parse().unwrap());
        for name in ["x-first", "x-second", "x-third", "keep-alive"] {
            headers.insert(HeaderName::from_bytes(name.as_bytes()).unwrap(),
                           "secret".parse().unwrap());
        }
        headers.insert("x-end-to-end", "preserved".parse().unwrap());
        strip_hop_headers(&mut headers).unwrap();
        for name in ["connection", "keep-alive", "x-first", "x-second", "x-third"] {
            assert!(!headers.contains_key(name));
        }
        assert_eq!(headers["x-end-to-end"], "preserved");
    }

    #[test]
    fn invalid_connection_option_is_rejected() {
        let mut headers = HeaderMap::new();
        headers.insert(CONNECTION, "bad option".parse().unwrap());
        assert!(strip_hop_headers(&mut headers).is_err());
    }
}

Redirects are returned to the caller rather than followed. The upstream must be an HTTP(S) origin without a base path, credentials, query, or fragment. Environment proxy discovery is disabled so an unexpected machine-level proxy setting does not change the outbound route.

The response cap is enforced while streaming. If it is exceeded after headers have been sent, the client sees an interrupted response, not a replacement 413 or 502. The request permit remains held until the response stream ends or is dropped. This matters: releasing it immediately after receiving upstream headers would not bound concurrent streamed responses.

Axum’s graceful shutdown alone has no drain deadline. Here, a separate notification starts a timer after Ctrl+C, and the executable returns after that interval even if requests remain. Runtime shutdown cancels remaining asynchronous work. This pattern depends on owning the process and runtime; merely dropping a server future inside a larger application is not an equivalent connection cleanup strategy.

Rust TCP reverse proxy

Save this as rust-proxies/src/bin/tcp_proxy.rs. Completed tasks are reaped during normal operation so their completion records do not accumulate indefinitely.

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 setting(name: &str, fallback: u64) -> u64 {
    env::var(name).ok().and_then(|v| v.parse().ok())
        .filter(|n| *n > 0).unwrap_or(fallback)
}

async fn relay(mut client: TcpStream, upstream: SocketAddr,
               dial: Duration, lifetime: Duration) {
    let started = Instant::now();
    let mut server = match timeout(dial, TcpStream::connect(upstream)).await {
        Ok(Ok(stream)) => stream,
        Ok(Err(_)) => { eprintln!("upstream dial failed"); return; }
        Err(_) => { eprintln!("upstream dial timeout"); return; }
    };
    match timeout(lifetime, copy_bidirectional(&mut client, &mut server)).await {
        Ok(Ok((sent, received))) =>
            println!("sent={sent} received={received} elapsed={:?}", started.elapsed()),
        Ok(Err(_)) => eprintln!("relay I/O error"),
        Err(_) => eprintln!("relay lifetime expired"),
    }
}

#[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 = Duration::from_secs(setting("DIAL_TIMEOUT_SECS", 5));
    let lifetime = Duration::from_secs(setting("CONNECTION_LIFETIME_SECS", 300));
    let drain = Duration::from_secs(setting("DRAIN_TIMEOUT_SECS", 15));
    let permits = Arc::new(Semaphore::new(setting("MAX_CONNECTIONS", 128) as usize));
    let listener = TcpListener::bind(listen).await?;
    let mut tasks = JoinSet::new();
    let shutdown = signal::ctrl_c();
    tokio::pin!(shutdown);
    println!("Rust TCP listening on {listen}");
    loop {
        tokio::select! {
            _ = &mut shutdown => break,
            _ = tasks.join_next(), if !tasks.is_empty() => {}
            result = listener.accept() => {
                let (client, _) = result?;
                let permit = match permits.clone().try_acquire_owned() {
                    Ok(p) => p,
                    Err(_) => { drop(client); continue; }
                };
                tasks.spawn(async move {
                    let _permit = permit;
                    relay(client, upstream, dial, lifetime).await;
                });
            }
        }
    }
    drop(listener);
    if timeout(drain, async {
        while tasks.join_next().await.is_some() {}
    }).await.is_err() {
        tasks.abort_all();
        while tasks.join_next().await.is_some() {}
    }
    Ok(())
}

The relay lifetime begins after dialing, so total retention can include the dial timeout plus the relay lifetime. It is not an idle timeout: ongoing traffic does not reset it. Tokio’s bidirectional copy handles one direction reaching EOF while the opposite direction continues.

Both Rust programs handle Ctrl+C. They do not install a Unix SIGTERM handler. Configure a supervisor to send SIGINT for these examples, or add and test SIGTERM handling before deployment. Do not assume every termination signal takes the graceful path.

Build the Go HTTP and TCP proxies

These examples use the standard library. Save this module file as go-proxies/go.mod:

module example.com/micro-proxies

go 1.22

Go HTTP reverse proxy

Save this as go-proxies/cmd/http-proxy/main.go. Unlike a streamed MaxBytesReader attached directly to ReverseProxy, this version finishes the bounded read before calling the upstream. A limit error therefore produces a deliberate 413 instead of allowing a partial oversized request upstream and then possibly producing 502 or a connection error.

package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"
)

func envInt(name string, fallback int) int {
	n, err := strconv.Atoi(os.Getenv(name))
	if err != nil || n <= 0 {
		return fallback
	}
	return n
}

func main() {
	listen := os.Getenv("LISTEN_ADDR")
	if listen == "" {
		listen = "127.0.0.1:8080"
	}
	target := os.Getenv("UPSTREAM_URL")
	if target == "" {
		target = "http://127.0.0.1:9000"
	}
	upstream, err := url.Parse(target)
	if err != nil {
		log.Fatal("invalid UPSTREAM_URL")
	}
	if (upstream.Scheme != "http" && upstream.Scheme != "https") ||
		upstream.Host == "" || upstream.User != nil ||
		(upstream.Path != "" && upstream.Path != "/") ||
		upstream.RawQuery != "" || upstream.ForceQuery || upstream.Fragment != "" {
		log.Fatal("UPSTREAM_URL must be an HTTP(S) origin without credentials")
	}
	upstream.Path = ""
	maxBody := int64(envInt("MAX_REQUEST_BYTES", 1_048_576))
	permits := make(chan struct{}, envInt("MAX_CONCURRENT", 128))
	headerTimeout := time.Duration(envInt("UPSTREAM_HEADER_TIMEOUT_SECS", 30)) * time.Second
	drainTimeout := time.Duration(envInt("DRAIN_TIMEOUT_SECS", 15)) * time.Second

	transport := &http.Transport{
		// Nil Proxy deliberately disables environment proxy discovery.
		DialContext:           (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          128,
		MaxIdleConnsPerHost:   32,
		IdleConnTimeout:       60 * time.Second,
		TLSHandshakeTimeout:   5 * time.Second,
		ResponseHeaderTimeout: headerTimeout,
		ExpectContinueTimeout: time.Second,
	}
	proxy := &httputil.ReverseProxy{
		Rewrite: func(r *httputil.ProxyRequest) {
			r.SetURL(upstream)
			r.SetXForwarded()
			r.Out.Host = upstream.Host
		},
		Transport: transport,
		ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
			log.Print("upstream request failed")
			http.Error(w, "upstream request failed", http.StatusBadGateway)
		},
	}
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodConnect || r.Header.Get("Upgrade") != "" {
			http.Error(w, "CONNECT and upgrades unsupported", http.StatusMethodNotAllowed)
			return
		}
		select {
		case permits <- struct{}{}:
			defer func() { <-permits }()
		default:
			http.Error(w, "proxy busy", http.StatusServiceUnavailable)
			return
		}
		limited := http.MaxBytesReader(w, r.Body, maxBody)
		data, err := io.ReadAll(limited)
		_ = limited.Close()
		if err != nil {
			var limit *http.MaxBytesError
			if errors.As(err, &limit) {
				http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
			} else {
				http.Error(w, "unable to read body", http.StatusBadRequest)
			}
			return
		}
		r.Body = io.NopCloser(bytes.NewReader(data))
		r.ContentLength = int64(len(data))
		r.TransferEncoding = nil
		r.Trailer = nil
		r.Header.Del("Content-Length")
		proxy.ServeHTTP(w, r)
	})
	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,
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	shutdownDone := make(chan struct{})
	go func() {
		defer close(shutdownDone)
		<-ctx.Done()
		drain, cancel := context.WithTimeout(context.Background(), drainTimeout)
		defer cancel()
		if err := server.Shutdown(drain); err != nil {
			log.Print("drain expired; closing active connections")
			_ = server.Close()
		}
	}()
	log.Printf("Go HTTP listening on %s", listen)
	err = server.ListenAndServe()
	if err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
	// ListenAndServe returns as soon as shutdown starts, not when it finishes.
	<-shutdownDone
	transport.CloseIdleConnections()
}

UPSTREAM_HEADER_TIMEOUT_SECS configures ResponseHeaderTimeout. It limits the wait for upstream response headers after the request has been written. It is not a total upstream request deadline and does not limit response-body duration. The five-second dial and TLS-handshake deadlines cover other phases; server write deadlines constrain downstream writes but are not a substitute for an end-to-end request-context deadline.

The Go proxy has no response-body byte cap. Its server write timeout can interrupt legitimate long streaming responses. Choose different policies for streaming workloads and test cancellation explicitly rather than increasing every timeout indiscriminately.

Rewrite removes the standard inbound forwarding headers before the callback; SetXForwarded rebuilds them from the immediate request context. Application-specific identity headers still need a separate trust policy. Request trailers are intentionally not forwarded in either buffered implementation.

Go TCP reverse proxy

Save this as go-proxies/cmd/tcp-proxy/main.go:

package main

import (
	"context"
	"io"
	"log"
	"net"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"syscall"
	"time"
)

func envInt(name string, fallback int) int {
	n, err := strconv.Atoi(os.Getenv(name))
	if err != nil || n <= 0 {
		return fallback
	}
	return n
}

func copyHalfClose(dst, src net.Conn, done chan<- error) {
	_, err := io.Copy(dst, src)
	if tcp, ok := dst.(*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.Print("upstream dial failed")
		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, second := <-done, <-done
	if first != nil || second != nil {
		log.Print("relay ended with I/O error or deadline")
	}
}

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"
	}
	dial := time.Duration(envInt("DIAL_TIMEOUT_SECS", 5)) * time.Second
	lifetime := time.Duration(envInt("CONNECTION_LIFETIME_SECS", 300)) * time.Second
	drain := time.Duration(envInt("DRAIN_TIMEOUT_SECS", 15)) * time.Second
	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{}, envInt("MAX_CONNECTIONS", 128))
	var active sync.WaitGroup
	go func() {
		<-ctx.Done()
		_ = listener.Close()
	}()
	log.Printf("Go TCP listening on %s", listen)
	for {
		client, err := listener.Accept()
		if err != nil {
			if ctx.Err() != nil {
				break
			}
			log.Print("accept failed")
			continue
		}
		select {
		case permits <- struct{}{}:
			active.Add(1)
			go func() {
				defer active.Done()
				defer func() { <-permits }()
				relay(client, upstream, dial, lifetime)
			}()
		default:
			_ = client.Close()
		}
	}
	drained := make(chan struct{})
	go func() {
		active.Wait()
		close(drained)
	}()
	select {
	case <-drained:
		log.Print("connections drained")
	case <-time.After(drain):
		log.Print("drain expired; process exit closes remaining sockets")
	}
}

The two copy goroutines finish independently. Socket deadlines are absolute and do not reset on traffic. If one direction fails while the other remains blocked, the lifetime deadline eventually releases it. A production relay may also cancel the opposite direction on selected fatal errors. After the drain deadline, this standalone program exits, closing remaining operating-system sockets.

Build, run, and verify all four implementations

Test upstreams directly before testing proxies, then test public exposure last. Keep test services bound to loopback and do not use their deliberately unauthenticated handlers for real application data.

Record the actual build environment

cd "$MICRO_PROXIES_ROOT/rust-proxies"
rustc --version > validation-versions.txt
cargo --version >> validation-versions.txt
cargo build --bins
cargo tree >> validation-versions.txt
cargo test --all-targets
cargo clippy --all-targets --all-features
cargo build --locked --bins

cd "$MICRO_PROXIES_ROOT/go-proxies"
go version > validation-versions.txt
go env GOOS GOARCH >> validation-versions.txt
gofmt -w cmd/http-proxy/main.go cmd/tcp-proxy/main.go
go build ./...
go test ./...
go vet ./...
mkdir -p bin
go build -o bin/http-proxy ./cmd/http-proxy
go build -o bin/tcp-proxy ./cmd/tcp-proxy

Retain Cargo.lock with the successful run record. The Go examples contain no Go unit-test files, so go test ./... validates package compilation but does not replace the integration checks below. Record the OS, tool versions, resolved crate versions, and results for each of the four binaries rather than recording only a successful build.

Start a request-aware HTTP upstream

A static Python server is useful for a GET smoke test, but does not implement the POST behavior needed for request-boundary tests. Save this as tests/http_upstream.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import time

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        if self.path == "/slow":
            time.sleep(10)
        body = b"micro proxy upstream is working\n"
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Connection", "X-Response-One")
        self.send_header("Connection", "X-Response-Two")
        self.send_header("X-Response-One", "must-not-pass")
        self.send_header("X-Response-Two", "must-not-pass")
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        size = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(size)
        result = json.dumps({
            "bytes": len(body),
            "path": self.path,
            "first": self.headers.get("X-First"),
            "second": self.headers.get("X-Second"),
            "forwarded": self.headers.get("Forwarded"),
            "xff": self.headers.get("X-Forwarded-For"),
        }).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(result)))
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(result)

ThreadingHTTPServer(("127.0.0.1", 9000), Handler).serve_forever()
python3 "$MICRO_PROXIES_ROOT/tests/http_upstream.py"

In another terminal:

curl -i http://127.0.0.1:9000/

Expect 200 and micro proxy upstream is working.

Exercise both HTTP proxies, including exact body boundaries

Save this as tests/http_checks.py. It checks below, exactly at, and above the default 1,048,576-byte limit, plus the original 1.1-million-byte regression case. It also covers chunked bodies without a Content-Length, multiple Connection fields on requests and responses, path/query preservation, and removal of spoofed forwarding metadata.

import http.client
import json

LIMIT = 1_048_576

def post(size, chunked=False):
    conn = http.client.HTTPConnection("127.0.0.1", 8080, timeout=10)
    body = b"x" * size
    if chunked:
        conn.request("POST", "/inspect?mode=test", body=iter([body]),
                     encode_chunked=True)
    else:
        conn.request("POST", "/inspect?mode=test", body=body)
    response = conn.getresponse()
    status, data = response.status, response.read()
    conn.close()
    return status, data

for chunked in (False, True):
    for size in (LIMIT - 1, LIMIT, LIMIT + 1, 1_100_000):
        status, body = post(size, chunked)
        expected = 200 if size <= LIMIT else 413
        assert status == expected, (size, chunked, status, body)
        if status == 200:
            assert json.loads(body)["bytes"] == size
        print("body boundary passed:", size, "chunked:", chunked)

conn = http.client.HTTPConnection("127.0.0.1", 8080, timeout=10)
conn.putrequest("POST", "/inspect?mode=test")
conn.putheader("Content-Length", "0")
conn.putheader("Connection", "X-First")
conn.putheader("Connection", "X-Second")
conn.putheader("X-First", "must-not-pass")
conn.putheader("X-Second", "must-not-pass")
conn.putheader("Forwarded", "for=untrusted")
conn.putheader("X-Forwarded-For", "untrusted")
conn.endheaders()
response = conn.getresponse()
assert response.status == 200
data = json.loads(response.read())
assert data["first"] is None and data["second"] is None, data
assert data["forwarded"] is None and data["xff"] != "untrusted", data
assert data["path"] == "/inspect?mode=test", data
conn.close()

conn = http.client.HTTPConnection("127.0.0.1", 8080, timeout=10)
conn.request("GET", "/")
response = conn.getresponse()
assert response.status == 200
assert response.getheader("X-Response-One") is None
assert response.getheader("X-Response-Two") is None
assert response.read() == b"micro proxy upstream is working\n"
conn.close()
print("request and response header checks passed")

Start Rust HTTP in one terminal:

cd "$MICRO_PROXIES_ROOT/rust-proxies"
cargo run --bin http_proxy

Run the checks in another:

curl -i http://127.0.0.1:8080/
python3 "$MICRO_PROXIES_ROOT/tests/http_checks.py"

Stop Rust with Ctrl+C. Start Go HTTP and repeat the same checks:

cd "$MICRO_PROXIES_ROOT/go-proxies"
go run ./cmd/http-proxy

Ordinary oversized requests should receive 413 from both programs. Truncated, malformed, timed-out, or disconnected clients are different failure cases and cannot be promised a delivered status response. Inspect the upstream terminal: rejected oversized requests should not produce an upstream POST entry because buffering precedes forwarding.

Start a TCP echo upstream

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 on 127.0.0.1:9001")
    while True:
        conn, _ = server.accept()
        threading.Thread(target=echo, args=(conn,), daemon=True).start()
PY

Start Rust TCP:

cd "$MICRO_PROXIES_ROOT/rust-proxies"
cargo run --bin tcp_proxy

Run this client first against port 9001 to verify the upstream, then port 7000 to verify the proxy. It reads until EOF instead of assuming one receive call returns the entire message.

python3 - <<'PY'
import socket

message = b"hello through tcp\n" * 1000
for port in (9001, 7000):
    with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
        sock.sendall(message)
        sock.shutdown(socket.SHUT_WR)
        received = bytearray()
        while True:
            chunk = sock.recv(65536)
            if not chunk:
                break
            received.extend(chunk)
        assert bytes(received) == message
        print("echo and half-close passed on", port)
PY

Stop Rust TCP, start Go TCP, and repeat the client:

cd "$MICRO_PROXIES_ROOT/go-proxies"
go run ./cmd/tcp-proxy

Check failure, concurrency, and shutdown behavior

Stop the HTTP upstream and request the proxy again. Expect 502 for a failed upstream connection. Restart the upstream before continuing. Send a malformed request directly to the HTTP listener:

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

The HTTP library should reject it, not forward it. A single malformed-input check is not a request-smuggling audit. Test conflicting framing, duplicate headers, chunking, and connection reuse against an isolated upstream before treating the proxy as an operational security boundary.

For HTTP concurrency, restart with MAX_CONCURRENT=1, request /slow, then issue a second request while the first is active. Expect the second request to receive 503. For TCP, use MAX_CONNECTIONS=1, hold one connection open, and verify a second is closed. Use a short CONNECTION_LIFETIME_SECS to confirm an active connection is eventually interrupted even when bytes continue flowing.

For shutdown checks, use built binaries rather than a compiler or go run wrapper. Start each proxy with DRAIN_TIMEOUT_SECS=2, begin a slow HTTP request or leave a TCP connection open, and press Ctrl+C. New admissions should stop and the executable should exit around the configured drain interval, allowing for scheduling. A request completing within the interval should finish normally; one exceeding it may be interrupted.

Security, resource controls, and observability

Bounded proxy design with limits, timeouts, fixed buffers, and graceful shutdown stages.
Limits constrain particular resources; they are not a blanket guarantee against overload.

Account for the resources that remain unbounded

A request concurrency limit is not a TCP connection limit. The Rust HTTP example does not explicitly configure a header-read timeout or a cap on accepted idle HTTP connections. Its body-read timer starts only after request headers have been parsed. A client holding incomplete headers can therefore consume resources outside the request semaphore. Put a reviewed edge server in front of public deployments and configure connection, header, and idle policies there.

Buffering costs memory proportional to concurrent bodies, with additional allocation and protocol overhead. At the defaults, 128 simultaneous one-megabyte uploads can require roughly 128 MiB just for payload storage, not total process memory. Measure resident memory, socket counts, and rejection behavior under representative load. “Small source code” does not establish a low memory budget.

Review HTTP framing at every intermediary

Request smuggling arises when HTTP recipients disagree about message boundaries. Header filtering cannot repair an ambiguous message already parsed differently by two hops. Keep the inbound server, outbound client, and upstream current; review their security advisories and the HTTP/1.1 requirements including applicable updates to RFC 9112.

This revision fixes a specific intermediary defect: all Connection field values are inspected, not just the first one, and nominated fields are removed in both directions. The unit and integration checks cover that behavior. They do not certify full RFC compliance or demonstrate that RFC 9931’s requirements have been implemented by a particular dependency version. That review remains necessary because the update text and an executed compatibility matrix were not supplied.

These examples do not provide general CONNECT tunneling, a reviewed WebSocket upgrade policy, cache semantics, retries, circuit breakers, distributed tracing, certificate management, or authenticated route policy. Rust rejects CONNECT and upstream protocol switching; Go rejects CONNECT and requested upgrades. Do not advertise either HTTP example as a general-purpose transparent gateway.

Forwarding metadata is not identity

Forwarded and X-Forwarded-For can be forged by a direct client. Here, known forwarding fields are discarded and reconstructed from the immediate peer. With Localtonet or another relay in front, that peer may not be the original public user. The Rust example reports the local HTTP hop as HTTP, not the original public scheme.

Define the trusted-hop boundary separately and test it in your deployment. Never authorize administrative actions solely from a claimed address. Use application authentication, least privilege, explicit route permissions, and private listeners for metrics, debugging, and administration.

Log outcomes, not secrets

Useful telemetry includes rejected admissions, active requests, dial failures, timeout phases, broad status groups, bytes transferred, and drain duration. Avoid authorization headers, cookies, tokens, payloads, and full sensitive URLs. Sanitize user-controlled strings. Use bounded metric labels rather than arbitrary paths, request IDs, hostnames, or error text.

Public reachability changes the threat model

Before publication, add the application’s authentication, confirm the upstream is operator-controlled, review connection-level defenses, and test the exact dependency versions. An outbound tunnel provides connectivity, not authorization or permission to evade network policy.

Rust and Go implementation tradeoffs

Both languages can support efficient concurrent proxies. Rust makes ownership of tasks, streams, and permits explicit. Go expresses similar lifecycles with goroutines, channels, contexts, and server APIs. Neither language automatically fixes missing deadlines or incorrect trust boundaries.

ConcernRust exampleGo example
ConcurrencyTokio tasks and semaphoresGoroutines and buffered channels
HTTP stackAxum inbound, Reqwest outboundnet/http and httputil.ReverseProxy
TCP copyingcopy_bidirectionalTwo io.Copy operations with half-close
HTTP shutdownGraceful server plus process-owned drain deadlineShutdown deadline, Close fallback, completion wait
Dependency surfaceRuntime and HTTP ecosystem cratesStandard library
Maintenance focusAsync cancellation and stream ownershipGoroutine lifecycle and deadline scope

Choose based on team expertise, dependency policy, deployment constraints, and representative measurements. Generic language benchmarks cannot establish performance for your body sizes, connection churn, upstream latency, or failure conditions.

Publish a tested proxy with Localtonet

Diagram separating reverse-proxy processing from the outbound Localtonet tunnel.
The proxy owns local traffic handling; our tunnel supplies the public path.

Your proxy selects the upstream and applies its HTTP or TCP policy. With Localtonet, our client establishes an outbound connection to a relay server and supplies a public URL or host and port without inbound router port forwarding, firewall changes, VPN setup, or a public IP address. Standard HTTP and TCP tunnels are not a VPN; VPN Manager is our separate private mesh VPN feature.

The sequence below follows our supplied common tunnel workflow. A live dashboard and documentation recheck was not available for this revision, so no fresh verification of interface labels is claimed. Consult the HTTP tunnel documentation and TCP tunnel documentation for current interface details. Available servers, options, and plan capabilities can vary.

Localtonet exposure path from a localhost proxy to a remote client.
The selected client must be able to reach the proxy listener, and the tunnel must be running.
1

Install and run our client

Run the Localtonet application on the proxy device or another device that can reach its listener. Complete direct local verification first.

2

Authenticate or select the device

Use the device-specific token for the client that will run the tunnel. Keep it out of source code, logs, commands shared with others, and screenshots.

3

Select an available relay

Choose a server or region currently offered by the dashboard. Do not copy an assumed server code from an unrelated example.

4

Create the appropriate configuration

For HTTP, target the proxy’s local IP and port, normally 127.0.0.1 and 8080 on the same device. For TCP, target 127.0.0.1 and 7000. HTTP Process Type can use a generated subdomain, a supported custom subdomain, or a custom domain; check current DNS requirements before configuring a custom domain.

5

Start and verify the tunnel

Press Start. Use the assigned HTTP URL or TCP host and port, and test from a separate network. Creating a configuration does not start it.

6

Stop or delete unused exposure

Stop temporary tunnels after testing and delete obsolete configurations. Availability requires the selected client to remain connected and the tunnel to remain running.

Verify the public HTTP endpoint

PUBLIC_URL="https://your-assigned-public-url.example"
curl -i "$PUBLIC_URL/"

The example domain is a placeholder, not a Localtonet address. Substitute the actual dashboard URL. Expect the same body as the local test. Also check authentication, oversized bodies, unknown routes, and upstream failure. An intermediary can reject a request before it reaches your proxy, so public error behavior may differ from direct local tests.

Verify the public TCP endpoint

PUBLIC_HOST="assigned-host-from-dashboard"
PUBLIC_PORT="assigned-port-from-dashboard"

python3 - "$PUBLIC_HOST" "$PUBLIC_PORT" <<'PY'
import socket
import sys

message = b"hello through public tcp tunnel\n"
with socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=10) as sock:
    sock.sendall(message)
    received = bytearray()
    while len(received) < len(message):
        chunk = sock.recv(len(message) - len(received))
        if not chunk:
            raise RuntimeError("connection ended before the complete echo")
        received.extend(chunk)
    assert bytes(received) == message
    print(received.decode(), end="")
PY

Endpoint coordinates are not device tokens. Never paste a token into either public test. Stop the unauthenticated echo demonstration promptly after verification.

Troubleshooting and routine operation

The listener address is already in use

Stop the previous implementation or select another port. Run these alternatives from their respective project directories:

LISTEN_ADDR=127.0.0.1:8081 cargo run --bin http_proxy
LISTEN_ADDR=127.0.0.1:8081 go run ./cmd/http-proxy

Update the Localtonet target to match. Do not point an HTTP tunnel at the raw TCP echo listener.

The proxy returns 502 or a connection closes

Check the upstream directly, then verify its address and scheme. Rust TCP expects a numeric socket address; the Go dialer can resolve hostnames. Confirm the upstream is not the proxy itself. TCP has no HTTP error status to return, so a failed dial normally appears as a closed connection.

The request-limit test returns an unexpected status

Use the request-aware Python upstream, not the static file server, which does not implement POST. Check for environment overrides of MAX_REQUEST_BYTES. Test directly against port 8080 before testing a public tunnel. In Rust, run the body-boundary unit test; in Go, confirm that the bounded read occurs before proxy.ServeHTTP.

Requests time out or streams are interrupted

Identify the phase. Rust’s UPSTREAM_TIMEOUT_SECS is a Reqwest request timeout including upstream body consumption. Go’s UPSTREAM_HEADER_TIMEOUT_SECS covers response headers only. Go server write deadlines and Rust’s response byte cap can terminate streams for different reasons. TCP lifetime deadlines apply even to healthy active traffic.

The public endpoint fails but localhost works

Confirm the selected client is connected, the device token selects the intended device, the local target is reachable from that device, and the tunnel was started. In containers, loopback refers to that container or network namespace, not automatically the host. Resolve local reachability without unnecessarily widening the listener to every interface.

Shutdown ends too quickly or never reaches the drain path

Use built binaries for process tests. Go HTTP must wait for its shutdown goroutine after ListenAndServe returns; this example does. Rust handles Ctrl+C, not SIGTERM. A forced kill cannot drain. Set the supervisor’s termination grace period longer than the proxy’s drain interval and verify its signal choice.

For routine restart, stop the Localtonet tunnel first, signal the proxy, wait for exit, restart upstream dependencies if necessary, start the proxy, verify locally, and then restart public exposure.

Final validation checklist

  • Record actual Rust, Cargo, resolved crate, Go, Python, OS, and architecture versions.
  • Build both Rust binaries and both Go command packages together.
  • Run the HTTP checks against Rust and Go separately, including chunked boundary cases.
  • Verify multiple Connection fields and nominated headers in both directions.
  • Run TCP echo, half-close, concurrency, dial-failure, lifetime, and drain tests for both languages.
  • Test malformed framing in isolation and review current HTTP library advisories and applicable RFC updates.
  • Confirm listeners, fixed upstreams, authentication, and administrative endpoint separation.
  • Measure memory and connections under slow-client and slow-upstream conditions.
  • Check that logs omit credentials, payloads, and sensitive URLs.
  • Recheck the current Localtonet setup interface, then verify the assigned public endpoint.
  • Stop temporary exposure and retain the validation results with the reviewed source.

Frequently asked questions

Is a reverse proxy the same as a Localtonet tunnel?

No. The proxy handles local forwarding and protocol policy. Our tunnel provides a public path through an outbound connection from the selected client device.

Should I choose an HTTP or TCP tunnel?

Choose HTTP when clients need a public web URL. Choose TCP when clients need a raw host and port. Match the tunnel to the externally expected protocol.

Can an oversized request partly reach the HTTP upstream?

Not through the normal request-body path in these revised examples: both finish the bounded read before forwarding. A streamed MaxBytesReader directly attached to a reverse proxy would have different behavior and could send part of the body before discovering the limit violation.

Do these HTTP examples support WebSockets?

No reviewed upgrade workflow is included. WebSockets require dedicated upgrade, timeout, shutdown, and access-policy testing. A raw TCP boundary is an alternative only when it fits the complete protocol and security model.

Why use a TCP lifetime rather than an idle timeout?

An absolute lifetime is simpler and bounds retention. A true idle timeout must reset on activity in either direction. Long-lived interactive or database sessions need a protocol-appropriate activity-aware policy.

Can forwarding headers replace authentication?

No. They describe routing context and can be forged outside a controlled trust boundary. Use application authentication and authorization for protected operations.

Does creating a tunnel immediately publish the proxy?

No. Press Start after configuration. The endpoint is available only while the selected Localtonet client is connected and the tunnel is running.

Are these ready to replace a production gateway?

No. They require executed validation, dependency and protocol review, connection-level defenses, authentication, monitoring, and workload-specific engineering before operational use. No tested-version matrix or completed execution results are claimed here.

Publish only the listener you have verified

Build locally, run the boundary checks, review authentication and shutdown, then connect the required HTTP or TCP listener with Localtonet. Keep the upstream fixed and stop temporary exposure when testing is complete.

Get Started Free →

Corrections & updates

Substantive changes approved by the Localtonet editorial team are listed transparently below.

Remove the outer article wrapper while retaining the existing lt-* body structure. Reorganize the Go examples into separate commands, such as cmd/http-proxy/main.go and cmd/tcp-proxy/main.go, then update the directory tree and every go run, go build, go test, and go vet command so the module builds as a whole. Correct the Rust request-limit handling so oversized bodies consistently receive the documented status, and add executable checks for requests below, at, and above the limit. Implement a bounded Rust HTTP drain deadline or revis

Remove the outer article wrapper and move the hero to the beginning. Add the required clickable guide navigation card immediately after the hero, assign unique lowercase IDs to all linked h2 sections, and move the opening figure to a relevant section after navigation. Add explicit prerequisites for Rust and Go, supported toolchain assumptions, dependency choices, project creation, configuration, and listener or upstream safety constraints. Include complete runnable examples for at least one clearly defined HTTP proxy and one clearly d

Localtonet is a secure multi-protocol tunneling and proxy platform designed to expose localhost, devices, private services, and AI agents to the public internet supporting HTTP/HTTPS tunnels, TCP/UDP forwarding, mobile proxy infrastructure, file server publishing, latency-optimized game connectivity, and developer-ready AI agent endpoint exposure from a single unified control plane.

support