Self-hosted tunnel for exposing HTTP(S) services behind NAT, CGNAT, or dynamic IPs without opening inbound ports.
Status: alpha. Functional and covered by integration tests. Wire format and APIs may change between
0.0.xreleases.
You need:
- A public VPS for the hub (ports
80,443,8443open). - A machine that can reach your service for the agent (homelab, k8s, VM).
- A DNS record pointing your hostname at the VPS.
On the VPS:
docker pull git.erwanleboucher.dev/eleboucher/towonel-node:latest
docker run -d --name towonel \
-p 80:80 -p 443:443 -p 8443:8443 \
-v towonel-data:/var/lib/towonel \
-e TOWONEL_IDENTITY_KEY_PATH=/var/lib/towonel/node.key \
-e TOWONEL_HUB_OPERATOR_API_KEY_PATH=/var/lib/towonel/operator.key \
-e TOWONEL_HUB_DB_DSN=/var/lib/towonel/hub.db \
git.erwanleboucher.dev/eleboucher/towonel-node:latestnode.key and operator.key are generated on first boot. Keep
operator.key — it authenticates all admin commands.
docker exec towonel towonel invite create \
--name alice \
--hostnames 'app.alice.example.eu,*.alice.example.eu'
# tt_inv_2_<token>The token carries the tenant identity and is the only secret the agent
needs. Default expiry is never; pass --expires 48h for a short-lived
credential.
On the machine that can reach your service:
docker run -d --name towonel-agent \
--network host \
-e TOWONEL_INVITE_TOKEN=tt_inv_2_... \
-e TOWONEL_AGENT_SERVICES='[
{"hostname":"app.alice.example.eu","origin":"127.0.0.1:8443"}
]' \
git.erwanleboucher.dev/eleboucher/towonel-agent:latestdig +short app.alice.example.eu # should resolve to the VPS IP
curl https://app.alice.example.euAdd more services by extending TOWONEL_AGENT_SERVICES. Add replicas by
running the agent container N times. Add regions by inviting another VPS
as an edge node (see Edge nodes).
Mode is chosen per hostname.
Passthrough (default). The edge reads SNI and forwards raw TLS to the origin. The origin holds the cert. The edge sees neither keys nor plaintext. ACME challenges work through the tunnel.
Terminate. The edge issues an on-demand Let's Encrypt cert for the hostname and forwards plaintext to the agent.
- HTTP-01 issuance is triggered on first request, cached, renewed lazily.
- Requires inbound
:80on the edge for challenges. - Wildcards issue per exact subdomain. Subject to Let's Encrypt rate limits (50 certs/week/registered domain).
- Failures back off exponentially, then enter a 5-minute cooldown per hostname.
Pin a mode in the service entry:
{
"hostname": "app.alice.example.eu",
"origin": "127.0.0.1:8080",
"tls_mode": { "mode": "terminate" }
}Heads up. In passthrough mode the agent prepends a PROXY protocol v2 header to every connection so the origin can recover the real client IP. Envoy will reject the connection (or treat the header bytes as request bytes) unless you tell it to accept PROXY protocol.
For Envoy Gateway, attach a ClientTrafficPolicy to the listener:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
name: envoy
spec:
proxyProtocol:
optional: trueFor raw Envoy, enable the envoy.filters.listener.proxy_protocol listener
filter on the inbound listener.
If you don't want PROXY protocol at all (e.g. the origin doesn't speak it and you don't care about client IP), set it explicitly on the service:
{ "hostname": "app.example.eu", "origin": "127.0.0.1:8443", "proxy_protocol": "none" }Forward arbitrary TCP ports — SSH, Prometheus remote-write, databases, anything that isn't TLS-with-SNI — alongside the regular HTTPS routes.
The agent declares the listen port; the edge picks it up automatically. The VPS admin doesn't add anything per service.
TOWONEL_AGENT_TCP_SERVICES='[
{"name":"forgejo-ssh", "origin":"forgejo:22", "listen_port":2222},
{"name":"prom-write", "origin":"victoriametrics:8428", "listen_port":9090}
]'Each agent boot reconciles the hub against the agent's env: added
entries are upserted, removed entries are deleted, and stale agent IDs
for the tenant are revoked. The env is the source of truth — run at
most one agent per tenant. towonel admin tenant leave fully
decommissions a tenant in one shot.
Each port is unique per tenant and across tenants: claiming a port
already bound to a different service — yours or somebody else's — is
rejected at submission time. Privileged ports (< 1024) are blocked by
default — set TOWONEL_HUB_ALLOW_PRIVILEGED_PORTS=true on the hub to
allow them.
The same model works for UDP — DNS, WireGuard, QUIC-over-UDP origins,
game traffic. Datagrams are framed onto the agent↔edge QUIC tunnel
(length-prefixed, up to 64 KiB each) and dispatched to a per-client
session on the agent side. TCP and UDP live in independent port
namespaces, so 2222/tcp and 2222/udp can coexist.
TOWONEL_AGENT_UDP_SERVICES='[
{"name":"dns", "origin":"127.0.0.1:5353", "listen_port":5353},
{"name":"wireguard", "origin":"10.0.0.1:51820", "listen_port":51820}
]'Bindings publish, retire, and respect the privileged-port gate exactly like TCP services. Sessions are reaped after 60 s of inactivity on either side.
Each invite is a tenant. Revoking an invite removes the tenant.
towonel invite create --name bob --hostnames '*.bob.example.eu'
towonel invite list
towonel invite revoke --id <invite-id>
towonel tenant remove --tenant-id <hex>
towonel tenant leave --key-path tenant.key --hub-url https://node.example.eu:8443Tenants can manage their own hostnames without operator intervention:
towonel entry submit --op upsert-hostname --hostname new.alice.example.eu
towonel entry submit --op delete-hostname --hostname old.alice.example.eu
towonel entry listOn the hub host, --hub-url and --api-key default to the local listen
address and operator.key. Pass them explicitly when running the CLI
from another machine.
Grow edge capacity by inviting additional VPS operators:
# on the hub -- non-expiring token by default, re-usable across restarts
towonel edge-invite create --name charlie-fra1
# on the new edge -- only this env var is required
TOWONEL_EDGE_INVITE_TOKEN=tt_edge_2_... towonelThe edge token deterministically derives the edge's iroh identity, so
the new edge starts immediately with no redemption step and no persistent
key file. Revoke via towonel edge-invite revoke --id <invite-id>.
docker compose up -dSee docker-compose.yml for the full stack and
environment surface.
All settings come from TOWONEL_* environment variables (flat names,
single underscore). Lists may be passed as CSV or JSON; structured
lists (tenants, services) require JSON.
Full examples live in examples/agent.env.example
and examples/node.env.example.
| Variable | Default | Description |
|---|---|---|
TOWONEL_IDENTITY_KEY_PATH |
node.key |
Node identity key |
TOWONEL_HUB_ENABLED |
true |
Enable the hub API |
TOWONEL_INVITE_HASH_KEY |
Key for hashing invite secrets (must be set for security) | |
TOWONEL_HUB_LISTEN_ADDR |
0.0.0.0:8443 |
Hub API bind address |
TOWONEL_HUB_PUBLIC_URL |
derived | URL embedded in invite tokens |
TOWONEL_HUB_OPERATOR_API_KEY_PATH |
operator.key |
Operator API key file |
TOWONEL_HUB_DB_DRIVER |
sqlite |
sqlite or postgres |
TOWONEL_HUB_DB_DSN |
hub.db |
Connection string |
TOWONEL_HUB_DB_MAX_OPEN_CONNS |
4 / 25 |
Pool size |
TOWONEL_HUB_ALLOW_PRIVILEGED_PORTS |
false |
Allow tenants to claim TCP/UDP ports below 1024 |
| Variable | Default | Description |
|---|---|---|
TOWONEL_EDGE_ENABLED |
true |
Enable the edge listener |
TOWONEL_EDGE_LISTEN_ADDR |
0.0.0.0:443 |
TLS bind address |
TOWONEL_EDGE_HEALTH_LISTEN_ADDR |
0.0.0.0:9090 |
Health + metrics |
TOWONEL_EDGE_HUB_URL |
Remote hub (edge-only mode); TOWONEL_EDGE_HUB_URLS accepted as deprecated alias |
|
TOWONEL_EDGE_PUBLIC_ADDRESSES |
Addresses advertised to agents | |
TOWONEL_EDGE_TLS_ACME_EMAIL |
Enables Let's Encrypt issuance | |
TOWONEL_EDGE_TLS_CERT_DIR |
/data/certs |
Cert cache directory |
TOWONEL_EDGE_TLS_ACME_STAGING |
false |
Use Let's Encrypt staging |
TOWONEL_EDGE_TLS_HTTP_LISTEN_ADDR |
0.0.0.0:80 |
HTTP-01 responder |
| Variable | Description |
|---|---|
TOWONEL_INVITE_TOKEN |
Required. tt_inv_2_... token from the hub |
TOWONEL_AGENT_SERVICES |
JSON array of HTTPS services |
TOWONEL_AGENT_TCP_SERVICES |
JSON array of raw TCP services (see above) |
TOWONEL_AGENT_UDP_SERVICES |
JSON array of raw UDP services (see above) |
TOWONEL_AGENT_TRUSTED_EDGES |
Optional override for trusted edge IDs |
Service shape:
{
"hostname": "app.alice.example.eu",
"origin": "127.0.0.1:8080",
"origin_server_name": "optional SNI for the origin dial",
"tls_mode": { "mode": "passthrough" },
"proxy_protocol": "v2"
}proxy_protocol defaults to v2 for passthrough services and none
for terminated services.
| Variable | Description |
|---|---|
TOWONEL_HUB_URL |
Default --hub-url |
TOWONEL_OPERATOR_KEY |
Default --api-key for operator commands |
cargo build --release -p towonel-node -p towonel-agentmake check # fmt + clippy + unit tests
make e2e # full docker compose integration testCI runs on Forgejo Actions (.forgejo/workflows/). Tagging v*
triggers a release.
MIT — see LICENSE.