Towonel Tunnel
External traffic reaches the cluster through towonel, a
self-hosted tunnel that replaced Cloudflare Tunnel (cloudflared) in July 2026. An agent in the
cluster dials out to a hub on a VPS, so the router still needs no inbound port forwards and the
home IP stays unpublished.
The tunnel has two halves:
| Half | Runs on | Managed by |
|---|---|---|
| Hub + edge | ovh-vps (tunnel.wibrow.dev) | Ansible role towonel-hub; see the runbook in ansible/README.md |
| Agent | pitower cluster | kubernetes/apps/pitower/networking/towonel-agent/ |
Architecture
flowchart LR
User((User))
subgraph VPS["ovh-vps: tunnel.wibrow.dev"]
Caddy[Caddy L4<br/>SNI demux :443]
Edge[towonel edge]
Hub[towonel hub<br/>control API]
end
subgraph Cluster["pitower"]
Agent[towonel-agent<br/>2-4 replicas]
EE[envoy-external]
Apps[Applications]
end
User -->|"HTTPS *.wibrow.dev<br/>(DNS: unproxied CNAME)"| Caddy
Caddy -->|"tenant SNI"| Edge
Caddy -->|"SNI = tunnel.wibrow.dev"| Hub
Edge <-.->|"outbound tunnel"| Agent
Agent -->|"HTTPS :443"| EE
EE --> Apps
classDef vps fill:#f59e0b,stroke:#d97706,color:#000
classDef gw fill:#7c3aed,stroke:#5b21b6,color:#fff
class Caddy,Edge,Hub vps
class EE gw
Traffic flow
- User requests
https://myapp.wibrow.dev. - Cloudflare DNS returns the unproxied (grey-cloud) CNAME to
tunnel.wibrow.dev, which resolves to the VPS. Cloudflare is authoritative DNS only; it is not in the data path. - Caddy on the VPS peeks the TLS ClientHello SNI on
:443without decrypting it, and routes tenant hostnames to the towonel edge. - The edge forwards the connection over the already-established outbound tunnel to
towonel-agentin the cluster. - towonel-agent proxies to
envoy-externalon:443. - envoy-external matches the HTTPRoute hostname and routes to the application.
TLS is terminated by envoy-external inside the cluster; the VPS does SNI passthrough and never
sees plaintext.
Caddy hands tenant connections to the edge with PROXY protocol v2, and envoy-external accepts
PROXY protocol (its ClientTrafficPolicy sets proxyProtocol.optional: true), so Envoy logs and
CrowdSec see the real client address. See Envoy Gateway.
Agent configuration
The agent is stateless. It is given an invite token and a list of hostname → origin mappings:
env:
TOWONEL_AGENT_HEALTH_LISTEN_ADDR: 0.0.0.0:9090
TOWONEL_AGENT_SERVICES: |
[
{"hostname":"*.wibrow.dev","origin":"envoy-external.networking.svc.cluster.local:443"},
{"hostname":"propagit.dev","origin":"envoy-external.networking.svc.cluster.local:443"},
{"hostname":"*.propagit.dev","origin":"envoy-external.networking.svc.cluster.local:443"},
{"hostname":"*.cloudsnacks.dev","origin":"envoy-external.networking.svc.cluster.local:443"},
{"hostname":"*.apps.cloudsnacks.dev","origin":"envoy-external.networking.svc.cluster.local:443"},
{"hostname":"api.pantry.cloudsnacks.dev","origin":"envoy-external.networking.svc.cluster.local:443"}
]
TOWONEL_INVITE_TOKEN:
valueFrom:
secretKeyRef:
name: towonel-agent-secret
key: TOWONEL_INVITE_TOKENEvery origin is the same (envoy-external), so this list exists only to tell the edge which SNI
values belong to this tenant. Each zone's first-level wildcard is listed, so adding an app under
*.wibrow.dev, *.propagit.dev, or *.cloudsnacks.dev needs no tunnel change, just an HTTPRoute
with parentRefs to envoy-external. Anything deeper than one label needs its own entry.
The deployment runs 2 replicas with an HPA to 4 on 75% CPU (hpa.yaml), non-root with a read-only
root filesystem and all capabilities dropped.
DNS
Subdomains are unproxied CNAMEs to the hub: Cloudflare proxying would break the SNI passthrough the edge depends on:
- dnsName: "external.wibrow.dev"
recordType: CNAME
targets: ["tunnel.wibrow.dev"]
providerSpecific:
- name: external-dns.alpha.kubernetes.io/cloudflare-proxied
value: "false"
- dnsName: "*.wibrow.dev"
recordType: CNAME
targets: ["tunnel.wibrow.dev"]
providerSpecific:
- name: external-dns.alpha.kubernetes.io/cloudflare-proxied
value: "false"The wildcard means an unmatched subdomain still reaches the edge and hits the envoy-external
fallback 404 route rather than returning NXDOMAIN.
The other zones on the tunnel get their records the same way, from three places:
| Name | Record | Source |
|---|---|---|
propagit.dev, *.propagit.dev | CNAME → tunnel.wibrow.dev | towonel-agent/dnsendpoint.yaml |
*.apps.cloudsnacks.dev, api.pantry.cloudsnacks.dev | CNAME → tunnel.wibrow.dev | pantry-system/pantry/dnsendpoint.yaml |
Anything else with an HTTPRoute on envoy-external | CNAME → external.wibrow.dev | external-dns, from the gateway's external-dns.alpha.kubernetes.io/target |
The last row is why a route-only hostname such as pitwall.cloudsnacks.dev resolves without any
DNSEndpoint: it chains through external.wibrow.dev to tunnel.wibrow.dev. It resolving proves
nothing about the tunnel; the agent still has to advertise the SNI.
Health and troubleshooting
The agent serves /healthz on :9090, used for all three probes.
# Agent status
kubectl get pods -n networking -l app.kubernetes.io/name=towonel-agent
kubectl logs -n networking -l app.kubernetes.io/name=towonel-agent --tail=50
# Hub status (on the VPS)
systemctl status towonel-hub
docker logs -f towonel
# Is the hub reachable and presenting a valid cert? (run off-VPS)
curl -v https://tunnel.wibrow.dev/v1/health
# Confirm DNS is unproxied: the answer must be the VPS IP, not a Cloudflare IP
dig +short external.wibrow.dev
# Is a hostname actually advertised to the edge? A 404 means yes, a TLS error means no
curl -sS -o /dev/null -w '%{http_code}\n' https://myapp.wibrow.dev/Everything public returns 5xx
Check the agent's tunnel is established (kubectl logs), then that the hub is up on the VPS.
Because the agent dials out, a hub restart drops every route until the agent reconnects.
A hostname returns 404 from Envoy
The tunnel is fine: the wildcard delivered the request and envoy-external had no matching
HTTPRoute. Check the app's HTTPRoute hostnames and parentRefs.
TLS handshake error, no HTTP status at all
The edge has no SNI mapping for that hostname. Two causes, in order of likelihood:
-
Nothing in
TOWONEL_AGENT_SERVICEScovers it; remember a wildcard matches one label only. -
It is listed, but the hub rejected it as
hostname_not_owned. The agent logs the 403 at WARN and carries on, so the deployment looks healthy:shkubectl logs -n networking -l app.kubernetes.io/name=towonel-agent | grep publish_tlsCompare the accepted count in the edge's
dynamic route update appliedline against the number of entries inTOWONEL_AGENT_SERVICES; a mismatch means a pattern was refused.
Cloudflare error page instead of the app
The DNS record got proxied. external-dns runs without --cloudflare-proxied, so records
default to unproxied and only a cloudflare-proxied: "true" providerSpecific turns it on; the
explicit "false" on the towonel DNSEndpoints is belt-and-braces against that flag ever being
added. The one record that must stay proxied is the apex.