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:

HalfRuns onManaged by
Hub + edgeovh-vps (tunnel.wibrow.dev)Ansible role towonel-hub; see the runbook in ansible/README.md
Agentpitower clusterkubernetes/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

  1. User requests https://myapp.wibrow.dev.
  2. 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.
  3. Caddy on the VPS peeks the TLS ClientHello SNI on :443 without decrypting it, and routes tenant hostnames to the towonel edge.
  4. The edge forwards the connection over the already-established outbound tunnel to towonel-agent in the cluster.
  5. towonel-agent proxies to envoy-external on :443.
  6. 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:

kubernetes/apps/pitower/networking/towonel-agent/values.yaml
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_TOKEN

Every 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:

kubernetes/apps/pitower/networking/towonel-agent/dnsendpoint.yaml
- 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:

NameRecordSource
propagit.dev, *.propagit.devCNAME → tunnel.wibrow.devtowonel-agent/dnsendpoint.yaml
*.apps.cloudsnacks.dev, api.pantry.cloudsnacks.devCNAME → tunnel.wibrow.devpantry-system/pantry/dnsendpoint.yaml
Anything else with an HTTPRoute on envoy-externalCNAME → external.wibrow.devexternal-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.

sh
# 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:

  1. Nothing in TOWONEL_AGENT_SERVICES covers it; remember a wildcard matches one label only.

  2. 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:

    sh
    kubectl logs -n networking -l app.kubernetes.io/name=towonel-agent | grep publish_tls

    Compare the accepted count in the edge's dynamic route update applied line against the number of entries in TOWONEL_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.