A self-hosted tunnel for exposing local services to the internet.
- Ed25519 mutual authentication with timestamp anti-replay (WireGuard-style)
- gRPC bidirectional streaming — works behind any HTTP/2-capable ingress, no TCP passthrough needed
- Auto-reconnect with exponential backoff
- Optional basic auth on forwarded requests
- Header manipulation — inject or strip headers on proxied requests and responses
- Inspector dashboard — live request viewer embedded in the client binary, no extra install
- Docker multi-arch images (amd64/arm64) on ghcr.io
- Cross-platform binaries for Linux, macOS, and Windows
flowchart TB
I[Internet]
subgraph SRV[Server]
N[Reverse Proxy Ingress, Gateway API TLS termination on :443]
S[shitty-tunnel server\nHTTP :8080 — gRPC :50051]
end
subgraph DEV[Developer machine]
C[shitty-tunnel client]
L[Local application\n127.0.0.1:3000]
end
I --> N
N -->|HTTP traffic *.example.com| S
C -->|gRPC tunnel via tunnel.example.com:443| N
N -->|gRPC to :50051| S
S -->|proxied requests through tunnel| C
C -->|forwards to local service| L
The server exposes two ports: one for public HTTP traffic (proxied by your reverse proxy) and one for gRPC tunnel connections from clients. The client opens a persistent gRPC stream to the server, receives incoming HTTP requests through it, forwards them to a local service, and sends responses back.
This project was born out of personal frustration with paid tunnel services. It was written almost entirely by an LLM, with human guidance on architecture and prompts. After several iterations, It seems to work. That said, you may find no a enterprise-grade, production-ready code here. Use at your own risk, and please contribute improvements if you can!
Each side (server and client) needs its own Ed25519 keypair. Generate them with:
shitty-tunnel keygen # run twice: once for server, once for clientOutput:
Private key: axjhCqieuY3cU6qpRA48FSjKlojaH5+Q5kjm5aLwdfc=
Public key: P1j5jRykDgudgNJNnrJVXHx85W3koAapuyCnCKcq8XM=
Exchange public keys out-of-band. Private keys never leave their machine.
Create /etc/shittyTunnel/server.toml:
[server]
public_port = 8080
tunnel_port = 50051
private_key = "SERVER_PRIVATE_KEY"
# Environment variable expansion is supported:
# private_key = "${SERVER_PRIVATE_KEY}"
[[peers]]
public_key = "CLIENT_PUBLIC_KEY"
domain = "dev1.example.com"Create ~/.config/shittyTunnel.toml:
[client]
server_host = "https://tunnel.example.com"
private_key = "CLIENT_PRIVATE_KEY"
server_public_key = "SERVER_PUBLIC_KEY"
[local]
host = "127.0.0.1"
port = 3000
# Optional: protect the tunnel with basic auth
# basic_auth = "user:password"
# Optional: inject headers on every proxied request and response
# [local.add_headers]
# "X-Forwarded-By" = "shittyTunnel"
# Optional: strip headers from every proxied request and response
# [local.remove_headers]
# names = ["Authorization", "Cookie"]
[reconnect]
enabled = true
initial_delay_ms = 1000
max_delay_ms = 30000# Server
shitty-tunnel server --config /etc/shittyTunnel/server.toml
# Client
shitty-tunnel client --config ~/.config/shittyTunnel.tomlcurl -H "Host: dev1.example.com" http://localhost:8080/While the client is running, open http://localhost:3001 in your browser.
The inspector shows every proxied request in real-time: status, method, path, duration, response size, a waterfall bar for relative timing, and a collapsible detail panel with headers and body (JSON pretty-printed).
To configure the dashboard add a [dashboard] section to your client config:
[dashboard]
enabled = true
port = 3001 # port to listen on (localhost)
max_events = 500 # circular buffer sizeThe section is optional — when omitted the dashboard starts on port 3001 with a 500-event buffer. Set enabled = false to disable it entirely (e.g. in headless/CI environments).
Download from GitHub Releases.
docker pull ghcr.io/attiliogreco/shitty-tunnel:latest
docker run -d \
-p 8080:8080 -p 50051:50051 \
-v ./server.toml:/etc/shittyTunnel/server.toml:ro \
ghcr.io/attiliogreco/shitty-tunnel:latest \
server --config /etc/shittyTunnel/server.tomlRequires Rust 1.85+ and protobuf compiler (protoc).
git clone https://github.com/AttilioGreco/shitty-tunnel
cd shittyTunnel
# Build and install (requires just: cargo install just)
just installOr manually:
cargo build --release
sudo install -m 755 target/release/shitty-tunnel /usr/local/bin/shitty-tunnelCreate /etc/systemd/system/shitty-tunnel-server.service:
[Unit]
Description=shittyTunnel Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/shitty-tunnel server --config /etc/shittyTunnel/server.toml
Restart=on-failure
RestartSec=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/etc/shittyTunnel
[Install]
WantedBy=multi-user.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now shitty-tunnel-server
sudo journalctl -u shitty-tunnel-server -fThe client runs as a regular user with systemd --user.
Create ~/.config/systemd/user/shitty-tunnel-client.service:
[Unit]
Description=shittyTunnel Client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/shitty-tunnel client --config %h/.config/shittyTunnel.toml
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info
[Install]
WantedBy=default.targetEnable and start:
systemctl --user daemon-reload
systemctl --user enable --now shitty-tunnel-client
journalctl --user -u shitty-tunnel-client -fTo keep the user service running after logout:
loginctl enable-linger $USERYou can also use just shortcuts:
just install-systemd-server # install server unit (requires sudo)
just install-systemd-client # install client user unitThis project uses just as a task runner. Run just to see all available tasks.
| Command | Description |
|---|---|
just build |
Build debug binary |
just release |
Build release binary (always forcing fresh frontend embed assets) |
just test |
Run all workspace tests |
just install |
Build release with fresh frontend assets and install to /usr/local/bin |
just install-systemd-server |
Install server systemd unit |
just install-systemd-client |
Install client systemd user unit |
just up / just down |
Start/stop Docker Compose dev environment |
just logs |
Follow Docker Compose logs |
just release-create VERSION |
Create a new release (tag + push) |
The client can inject or strip HTTP headers on every proxied request (sent to the local service) and on every response (returned to the original caller). Rules are applied in this order:
- Headers listed in
remove_headersare stripped - Headers listed in
add_headersare injected — overwriting any existing header with the same name
Both sections are optional. If omitted, headers are forwarded as-is (hop-by-hop headers are always stripped regardless).
[local]
host = "127.0.0.1"
port = 3000
# Inject (or overwrite) these headers on every request and response
[local.add_headers]
"X-Forwarded-By" = "shittyTunnel"
"X-Environment" = "production"
# Strip these headers from every request and response (case-insensitive)
[local.remove_headers]
names = ["Authorization", "Cookie", "X-Internal-Secret"]Use cases:
| Goal | Config |
|---|---|
| Tag requests with a custom header | [local.add_headers] |
| Prevent credentials from reaching the local service | [local.remove_headers] |
| Override a response header before it reaches the caller | [local.add_headers] |
Strip sensitive response headers (e.g. Server, X-Powered-By) |
[local.remove_headers] |
The client binary embeds a web-based request inspector (similar to ngrok's inspector). No Node.js or separate process needed — it starts automatically alongside the tunnel.
# ~/.config/shittyTunnel.toml
[dashboard]
enabled = true # set false to disable (e.g. in CI)
port = 3001 # http://localhost:<port>
max_events = 500 # number of requests kept in the circular bufferOpen http://localhost:3001 while the client is running. The UI connects via WebSocket and receives two-phase events:
request_started— request appears immediately (status shown as pending)request_completed— status, headers, body and duration update in place
Features:
| Feature | Details |
|---|---|
| Live request table | Newest-first, updates in real-time via WebSocket |
| Status badge | Colour-coded: green 2xx, blue 3xx, yellow 4xx, red 5xx |
| Waterfall bar | Shows offset and duration relative to the current buffer window |
| Detail panel | Click any row — expands request/response headers and body (JSON pretty-printed) |
| Filters | Status group checkboxes, method dropdown, path substring search |
| Clear | Empties buffer on both client and dashboard simultaneously |
The dashboard listens on
0.0.0.0so it is reachable from the host when running inside Docker. Bind it to127.0.0.1at the network level (firewall/compose ports) if you don't want it exposed.
- Ed25519 signatures for mutual authentication
- Timestamp-based anti-replay (30-second window)
- Trivy vulnerability scanning in CI
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Contributions are welcome! Feel free to submit a Pull Request.