End to end encrypted secure tunnel to local servers
To expose port 8080:
tunwg -p 8080or
tunwg --forward=http://localhost:8080You can run tunwg in docker too:
docker run -it --rm --network=host -v tunwg_keys:/data ghcr.io/ntnj/tunwg tunwg --forward=http://localhost:8080--network=host is needed to access the port 8080 on host.
You can download a pre-compiled binary from Github releases for Windows, Linux and Mac (Arm/Intel)
To install from source:
go install github.com/ntnj/tunwg/tunwg@latestTunwg provides end to end SSL encryption and forwards TCP stream to the tunwg instance running on your local machine. The local instance running on your machine is responsible for generating an HTTPS certificate for you and forwards the decrypted traffic to your local server. This means that your traffic is completely private to you.
You can also self-host your own server.
To use your own domain name instead of a subdomain on tunwg.com, add a CNAME record in your DNS provider to the encoded domain on tunwg.com e.g. for test.example.com, add a CNAME entry for test to xxxxxxxx.l.tunwg.com
If you're writing your HTTP server in golang, you can use tunwg directly without running a separate binary.
import "github.com/ntnj/tunwg"
listener, err := tunwg.NewListener("<name>")
http.Serve(listener, httpHandler)Since the generated subdomain is derived from your wireguard key and the forwarded address, it'll remain constant across process restarts. The wireguard key is stored in .config/tunwg/ (os.UserConfigDir/tunwg) or /data/ in docker. It can be customized with TUNWG_PATH environment variable.
Automatic SSL certificate are issued through LetsEncrypt and automatically renewed. Fallback to ZeroSSL is supported in case of LetsEncrypt rate limits.
In case your firewall blocks UDP packets, you can relay the traffic over HTTPS. To use, just add TUNWG_RELAY=true to client environment variables.
This will effectively be TCP over UDP over TCP, so performance will suffer in case of packet drops. Use this option only if needed.
You can forward any ports on local network which the machine running tunwg has access to, without installing tunwg on the forwarded host. e.g.
tunwg --forward=http://10.0.0.2:8000,http://10.0.0.10:9000This is especially useful when running tunwg with docker compose to expose the ports on other containers without making any modifications to those images. e.g. docker-compose.yml
tunwg:
image: ghcr.io/ntnj/tunwg
command: tunwg --forward=http://whoami
whoami:
image: traefik/whoamiYou can then run docker compose logs tunwg to view the generated URL.
To expose private servers using tunwg, you can use the inbuilt basic auth support.
tunwg --forward=... --limit=$(htpasswd -nbB <user> <password>)PROXY protocol enables local server to receive the remote user's IP address.
Even though the subdomain generated by tunwg seems random, they're not private. Since SSL certificates are issued for them by tunwg client, the encoded subdomain is added to certificate transparency logs. Many crawlers and attackers monitor those transparency logs, so you'll get some automated traffic to your servers after you first issue the certificate. Do not foward any local servers which may be vulnerable or expose private data without auth, and if you need to do that, use the inbuilt tunwg basic auth.
Since anyone can run a server on l.tunwg.com domain, be careful when using cookies received from the browser.
The instance at l.tunwg.com runs on a VPS with very limited resources and may be bandwidth limited. For critical use cases, you can self-host your own tunwg server.
go install github.com/ntnj/tunwg/tunwg@latest
TUNWG_RUN_SERVER=true TUNWG_API=example.com TUNWG_IP=<ip-of-server> TUNWG_PORT=<wireguard-port> tunwgWith docker:
tunwgs:
image: ghcr.io/ntnj/tunwg
network_mode: host # or ports, 80,443,443/udp
environment:
TUNWG_RUN_SERVER: true
TUNWG_PORT: 443 # udp port that is used for wireguard connections.
TUNWG_IP: "a.b.c.d" # ip of server
TUNWG_API: example.com # all subdomains should resolve to serverClients will connect to your hosted instance if you set the same TUNWG_API environment variable there.
You can also set the TUNWG_AUTH environment variable to limit which clients can use your server. In that case, clients would need to set the same TUNWG_AUTH; changing it invalidates active and persisted peers authorized by the previous value.
For individually revocable credentials, set TUNWG_AUTH_SECRET on the server and request a key with POST https://<TUNWG_API>/issue. Clients pass the returned key through their existing TUNWG_AUTH environment variable. Put one issued key ID per line in $TUNWG_PATH/server/revoked_keys to revoke it. Rotating TUNWG_AUTH_SECRET invalidates previously issued credentials and requires peers to register again. Each issued key may have at most three active WireGuard peers. The issue endpoint allows 20 keys per source IP in each 24-hour window and returns HTTP 429 with Retry-After when exhausted.
TUNWG_QUOTA_BYTES enables a daily traffic allowance, counting received and transmitted bytes together. Usage is aggregated across every peer registered with the same issued or shared auth key; without authentication, usage remains per WireGuard peer. When a key exceeds quota, all of its peers are removed and that key cannot register another peer until the next local calendar day.
The server listens on port 443 (for HTTPS traffic) and on port 80 (to redirect to HTTPS and for http-01 SSL challenges). It also listens on UDP port TUNWG_PORT for wireguard UDP traffic. The public instance listens on UDP 443, since it's less likely to be blocked by firewalls.
The server stores its WireGuard key and access-control state under TUNWG_PATH (the default user config directory, or /data in the container). $TUNWG_PATH/server/access_state.json is the single durable source for peer credentials, quota usage, blocks, recent endpoints, and /issue rate limits. Persist this directory across restarts when using authentication or quotas. The tunwg client will register itself again if a peer cannot be safely restored.
Access control intentionally fails closed: unreadable state, failed durable writes, or WireGuard snapshot errors stop the server instead of resetting authorization or quota data. Revocation-file I/O errors deny new issued-key registrations and remove active issued-key peers. Monitor fatal access-state/controller logs, container restart count, free disk space, and writable state volume to catch restart loops.
If access_state.json is lost, restore it together with access_state.initialized from the same backup. To intentionally discard all peer authorization, quota, block, and issue-limit history, stop the server, remove both files, and start it again. Removing only the initialization marker while retaining an unknown state file is not a supported recovery procedure.
If you're running it behind a reverse proxy like caddy/nginx, you should make sure that the reverse proxy passes through TLS instead of decrypting HTTPS traffic.
The server can share TCP 443 with other TLS services on the same machine or
network. Configure one exact domain=host:port mapping per line:
environment:
TUNWG_SNI_ROUTES: "push.hapi.run=127.0.0.1:8443"
TUNWG_SNI_PROXY_PROTOCOL: "true"For multiple backends, use a YAML literal block to preserve newlines:
TUNWG_SNI_ROUTES: |
push.hapi.run=127.0.0.1:8443
status.hapi.run=[::1]:9443Blank lines and surrounding whitespace are ignored. Domains are matched
case-insensitively, with a trailing dot ignored. Backends accept DNS names,
IPv4, or bracketed IPv6 addresses with a numeric port. Invalid mappings,
duplicate domains, wildcards, and the API domain or any of its subdomains
are rejected at startup. Configuration is read once; restart to apply changes.
An unset or empty TUNWG_SNI_ROUTES keeps the original tunnel routing.
TUNWG_SNI_PROXY_PROTOCOL defaults to false. Setting it to true sends a
PROXY protocol v1 header to every static backend, carrying the original
client address. Each backend must support that header before TLS; there is
no automatic downgrade. The API and WireGuard routes keep their existing
source-address handling.
Routing order is the API, then static domains, then the existing encoded-domain
and CNAME tunnel lookup. Static routes use ordinary TCP with a 5-second connect
timeout. A failed backend closes that connection without falling through to
the tunnel lookup. These connections do not create WireGuard peers, require
tunnel credentials, or count toward TUNWG_QUOTA_BYTES.
TLS, including ALPN, passes through to the backend. The backend owns its certificate; use TLS-ALPN-01 through public TCP 443, DNS-01, or an existing certificate. TCP 80 keeps its tunnel challenge/redirect handling: static backends do not receive HTTP-01 challenges. Point each static domain directly at the server's public IP (DNS only when using Cloudflare).
See Push relay with Caddy for a complete same-host Compose example, including automatic certificates and real client IPs.
One of the primary goals for tunwg was to securely allow new clients to join without requiring any configuration or database on server, and to allow end to end SSL.
The tunwg binary runs a user-space TCP/IP stack using gVisor netstack. It generates a wireguard private key, and derives the IP address of wireguard connection based on a hash of the public key. On startup, it sends the public key to the tunwg server which replies with its own public key, establishing a wireguard connection between client and server.
The generated domain name is an encoding of the internal wireguard IP address and the port. When tunwg server receives a request, it parses the TLS SNI to get the domain and decodes it to an IP:port pair, which it then forwards the connection to over the internal wireguard network.
Run server: TUNWG_TEST_LOCALHOST=true TUNWG_RUN_SERVER=true TUNWG_KEY=tunwgs TUNWG_PORT=443 TUNWG_IP=127.0.0.1 go run ./tunwg
Run client: TUNWG_TEST_LOCALHOST=true go run ./tunwg --forward=http://localhost:8000
Test: curl -k -Li --connect-to ::127.0.0.1: https://abcd.l.tunwg.com
Run the Go checks with go test -race ./... and go vet ./.... The optional
Caddy integration test needs Linux and a local Docker daemon:
TUNWG_TEST_CADDY=1 go test -race ./tunwg -run TestSNIProxyCaddy -count=1It uses the official caddy:2.11.4 image, temporary ports and a local test
certificate; it does not contact a certificate authority or APNs.
- Allow distributed servers