A self-hosted tunnel server that exposes local services through a public endpoint over QUIC.
cattun routes incoming HTTPS requests to local services running behind NAT or firewalls. The server accepts tunnel client connections over QUIC (TLS 1.3), and proxies HTTP traffic through bidirectional streams. Wildcard subdomains, WebSocket passthrough, and scoped multi-tenant auth are built in.
Think of it as a self-hosted alternative to Cloudflare Tunnel.
Create cattun-server.toml:
[server]
quic_listen = "0.0.0.0:4433"
https_listen = "0.0.0.0:443"
# http_listen = "0.0.0.0:80"
[tls]
cert_file = "/etc/cattun/cert.pem"
key_file = "/etc/cattun/key.pem"
[[auth.tokens]]
token = "tok_abcdef123456"
allowed_patterns = ["*.coolify.example.com", "api.example.com"]cattun server -c cattun-server.tomlCreate cattun-client.toml:
[client]
server_addr = "vps.example.com:4433"
token = "tok_abcdef123456"
[[tunnels]]
subdomain = "*.coolify.example.com"
local_addr = "http://127.0.0.1:8080"
[[tunnels]]
subdomain = "api.example.com"
local_addr = "http://127.0.0.1:3000"cattun client -c cattun-client.toml| Section | Field | Type | Description |
|---|---|---|---|
[server] |
quic_listen |
String |
QUIC listen address for tunnel clients |
[server] |
https_listen |
String |
HTTPS listen address for public traffic |
[server] |
http_listen |
String? |
Optional HTTP listener for 80 -> 443 redirect |
[tls] |
cert_file |
String |
Path to TLS certificate (PEM) |
[tls] |
key_file |
String |
Path to TLS private key (PEM) |
[tls] |
client_ca_file |
String? |
CA file for verifying client certificates (mTLS) |
[[auth.tokens]] |
token |
String |
Pre-shared authentication token |
[[auth.tokens]] |
allowed_patterns |
Vec<String> |
Subdomain patterns this token can register |
When configured, tokens are managed in the database and an audit log is persisted. TOML tokens are seeded to the database on startup.
[database]
url = "postgres://cattun:pass@localhost/cattun"
max_connections = 10 # default: 10Enables distributed rate limiting via sliding window sorted sets.
[redis]
url = "redis://localhost:6379"[rate_limit]
requests_per_minute = 1000 # default: 1000
burst = 100 # default: 100Rate limiting uses a Lua script for atomic sliding window checks. If Redis goes down, requests are allowed through (fail-open).
[admin]
listen = "127.0.0.1:9090"
token = "admin_secret"| Section | Field | Type | Description |
|---|---|---|---|
[client] |
server_addr |
String |
Server QUIC address (host:port) |
[client] |
token |
String? |
Pre-shared authentication token (optional if using mTLS) |
[client] |
client_cert_file |
String? |
Client certificate file for mTLS |
[client] |
client_key_file |
String? |
Client private key file for mTLS |
[[tunnels]] |
subdomain |
String |
Subdomain pattern to register |
[[tunnels]] |
local_addr |
String |
Local service address to forward to |
Instead of (or in addition to) a token, clients can authenticate with a TLS certificate. The server maps the certificate's SHA-256 fingerprint to a token entry via the cert_mappings table, inheriting the same allowed_patterns.
# Server: add CA for client verification
[tls]
cert_file = "/etc/cattun/cert.pem"
key_file = "/etc/cattun/key.pem"
client_ca_file = "/etc/cattun/client-ca.pem"
# Client: authenticate with certificate
[client]
server_addr = "vps.example.com:4433"
client_cert_file = "/etc/cattun/client.crt"
client_key_file = "/etc/cattun/client.key"*.example.com matches foo.example.com and bar.example.com, but not example.com itself.
When [admin] is configured, an HTTP API is available for managing tokens and inspecting state. All /api/ endpoints require Authorization: Bearer <admin_token>.
| Method | Path | Description |
|---|---|---|
GET |
/api/tokens |
List all tokens (id, label, patterns, active, created_at) |
POST |
/api/tokens |
Create a new token. Returns the raw token once |
DELETE |
/api/tokens/:id |
Revoke a token (soft-delete) |
POST |
/api/tokens/:id/rotate |
Rotate: creates a new token with the same patterns and revokes the old one |
GET |
/api/tunnels |
List active tunnels (subdomain, local_addr, client_addr, uptime, request_count) |
GET |
/api/audit |
Query audit log (?event_type=&since=&token_id=&limit=) |
GET |
/metrics |
Prometheus metrics (no auth required) |
# Create a token
curl -X POST http://localhost:9090/api/tokens \
-H "Authorization: Bearer admin_secret" \
-H "Content-Type: application/json" \
-d '{"label": "team-a", "allowed_patterns": ["*.a.example.com"]}'
# List active tunnels
curl http://localhost:9090/api/tunnels \
-H "Authorization: Bearer admin_secret"
# Rotate a token (zero-downtime)
curl -X POST http://localhost:9090/api/tokens/<uuid>/rotate \
-H "Authorization: Bearer admin_secret"
# Scrape Prometheus metrics
curl http://localhost:9090/metricsAvailable at the /metrics endpoint on the admin API port:
| Metric | Type | Description |
|---|---|---|
cattun_requests_total |
counter | Total proxied requests (labels: status, host) |
cattun_request_duration_seconds |
histogram | Request latency (label: host) |
cattun_active_tunnels |
gauge | Currently registered tunnels |
cattun_active_connections |
gauge | Active QUIC connections |
cattun_auth_attempts_total |
counter | Authentication attempts (label: result) |
cattun_rate_limit_hits_total |
counter | Requests rejected by rate limiter |
cattun uses tracing with structured fields (method, host, status, latency, conn_id, subdomain). Configure log level via RUST_LOG:
RUST_LOG=info cattun server -c cattun-server.toml
RUST_LOG=cattun_server=debug cattun server -c cattun-server.tomlWhen PostgreSQL is configured, the following events are persisted to the audit_log table:
auth_success/auth_failure-- with token_id, client_addrtunnel_registered/tunnel_unregistered-- with subdomain, conn_idtoken_created/token_revoked/token_rotated-- via admin APIrate_limited-- when a request is rejected
The audit logger uses a background channel (mpsc) with batch inserts to avoid blocking the hot path.
Requires Rust 1.86+ (edition 2024).
cargo build --release --package cattunThe binary is at target/release/cattun.
FROM rust:1.86 AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
RUN cargo build --release --package cattun
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/cattun /usr/local/bin/cattun
ENTRYPOINT ["cattun"]docker build -t cattun .
docker run cattun server -c /etc/cattun/server.toml Internet
|
HTTPS (443/tcp)
|
+-------+-------+
| cattun-server |
| HTTPS listener|
| + router |
+-------+-------+
|
QUIC (4433/udp)
|
+-------+-------+
| cattun-client |
| stream handler|
+-------+-------+
|
HTTP (localhost)
|
+-----------+
| local svc |
+-----------+
Request flow:
- Client connects to server over QUIC, opens a control stream, sends
Registerwith token and tunnel specs - Server validates the token (or client certificate), checks patterns against the allowlist, registers subdomain routes
- Incoming HTTPS request hits the server -- router looks up the
Hostheader against registered tunnels - Rate limiter checks the request against the token's quota (if Redis is configured)
- Server opens a new QUIC bidirectional stream to the client, sends a
ProxyHeader(host + local_addr) - Client reads the header, connects to the local service, and relays bytes in both directions
- Control stream maintains heartbeat; on disconnect, client reconnects with backoff
Framing: control messages are serialized with bincode, length-prefixed with 4-byte big-endian u32.
When PostgreSQL is configured, the following tables are created via migrations:
tokens-- token hashes, labels, allowed patterns, active status, expirycert_mappings-- SHA-256 certificate fingerprints mapped to token entries (mTLS)audit_log-- timestamped events with JSONB details
# Run all tests
cargo test
# Run with logging
RUST_LOG=debug cargo test -- --nocapture
# Run a specific test
cargo test test_graceful_shutdownLicensed under either of
- Apache2
- MIT
at your option.
