A lightweight mTLS tunnel daemon written in Go. It lets any two services communicate over a mutually authenticated TLS connection without modifying application code. Write a single YAML config, run one binary.
[Client App] → [mtlsd CLIENT] ──mTLS──▶ [mtlsd SERVER] → [Backend Service]
plain TCP plain TCP
- Zero-trust networking — Enforce mutual certificate authentication between services without touching application code.
- Sidecar proxy — Run alongside any TCP service (databases, caches, APIs) to add mTLS transparently.
- Cross-network tunneling — Securely connect services across VPCs, data centers, or cloud providers.
- Certificate-based access control — Restrict which services can connect based on certificate CN or Organization fields.
| Feature | Description |
|---|---|
| Server mode | Accepts inbound mTLS connections, forwards plain TCP to a backend |
| Client mode | Accepts plain TCP locally, wraps it in mTLS to reach a server |
| Certificate-based ACL | Allow/deny connections by Subject CN or Organization |
| Hot cert rotation | Certificates reload automatically on file change (fsnotify) or SIGHUP |
| Health probes | /healthz, /readyz, /certz Kubernetes-compatible endpoints |
| Prometheus metrics | Active connections, bytes proxied, ACL decisions, TLS handshake latency |
| Structured logging | Configurable slog-based logging in text or JSON format |
| Graceful shutdown | SIGTERM drains connections; SIGHUP reloads certificates |
| Concurrency control | Semaphore-based connection limiting with backoff on accept errors |
| Configurable timeouts | Dial timeout and idle timeout per connection |
| Single binary | No runtime dependencies, statically compiled Go |
| YAML config | One config file controls everything |
Generate a test CA, server, and client certificates:
make certsThis creates a full PKI under testdata/certs/ using OpenSSL.
Server — accepts mTLS, forwards to a local backend on port 8080:
# server.yaml
mode: server
listen: "0.0.0.0:8443"
target: "127.0.0.1:8080"
tls:
cert: "testdata/certs/server.pem"
key: "testdata/certs/server-key.pem"
ca: "testdata/certs/ca.pem"
client_auth: required
min_version: "TLS1.2"
acl:
allow_cn:
- "frontend-service"
proxy:
dial_timeout: 10s
idle_timeout: 90s
max_connections: 1000
health:
enabled: true
listen: "127.0.0.1:9000"
metrics:
enabled: true
listen: "127.0.0.1:9001"
logging:
level: info
format: textClient — accepts plain TCP on port 9080, tunnels to the server over mTLS:
# client.yaml
mode: client
listen: "127.0.0.1:9080"
target: "127.0.0.1:8443"
tls:
cert: "testdata/certs/client.pem"
key: "testdata/certs/client-key.pem"
ca: "testdata/certs/ca.pem"
server_name: "localhost"
min_version: "TLS1.2"
proxy:
dial_timeout: 10s
idle_timeout: 90s
max_connections: 500
logging:
level: info
format: text# Build
make buildStart all four processes, each in its own terminal:
# Terminal 1 — echo server (the backend service on port 8080)
go run ./examples/echo-server -addr 127.0.0.1:8080
# → level=INFO msg="echo server listening" addr=127.0.0.1:8080# Terminal 2 — mtlsd server daemon (accepts mTLS on :8443, forwards to echo server on :8080)
./bin/mtlsd -config examples/server.yaml
# → level=INFO msg="mtlsd starting" mode=server
# → level=INFO msg="certificates loaded" cert=testdata/certs/server.pem key=testdata/certs/server-key.pem ca=testdata/certs/ca.pem
# → level=INFO msg="listener: serving mTLS" listen=0.0.0.0:8443 target=127.0.0.1:8080
# → level=INFO msg="health server listening" addr=127.0.0.1:9000
# → level=INFO msg="metrics server listening" addr=127.0.0.1:9001
# → level=INFO msg="certstore: watching files for changes" cert=... key=... ca=...# Terminal 3 — mtlsd client daemon (accepts plain TCP on :9080, tunnels over mTLS to :8443)
./bin/mtlsd -config examples/client.yaml
# → level=INFO msg="mtlsd starting" mode=client
# → level=INFO msg="certificates loaded" cert=testdata/certs/client.pem key=testdata/certs/client-key.pem ca=testdata/certs/ca.pem
# → level=INFO msg="dialer: listening" listen=127.0.0.1:9080 server=127.0.0.1:8443# Terminal 4 — test client (listens on :7070, forwards to tunnel on :9080)
go run ./examples/test-client -listen 127.0.0.1:7070 -tunnel 127.0.0.1:9080
# → level=INFO msg="test-client listening" listen=127.0.0.1:7070 tunnel=127.0.0.1:9080Now send traffic into the test client and watch it flow through the full chain:
echo "hello mtlsd" | nc 127.0.0.1 7070
# → echo: hello mtlsdWhen the request flows through, you'll see structured logs in each terminal:
# Terminal 2 (server): level=INFO msg="listener: accepted connection" peer_cn=frontend-service
# Terminal 3 (client): level=INFO msg="dialer: connected" server=127.0.0.1:8443
The data flows through all four processes and back:
[caller] → [test-client :7070] → [mtlsd CLIENT :9080] ──mTLS──▶ [mtlsd SERVER :8443] → [echo-server :8080]
response flows back the same path
- Caller sends
hello mtlsdto the test client on:7070 - Test client forwards to the mtlsd client daemon on
:9080 - mtlsd client wraps it in mTLS and sends to the mtlsd server on
:8443 - mtlsd server checks the ACL, terminates TLS, and forwards to the echo server on
:8080 - Echo server replies with
echo: hello mtlsd - Response flows back through
:8443→:9080→:7070→ caller
While the server is running:
# Liveness probe
curl http://127.0.0.1:9000/healthz
# {"status":"alive"}
# Readiness probe
curl http://127.0.0.1:9000/readyz
# {"status":"ready"}
# Certificate expiry info
curl http://127.0.0.1:9000/certz
# {"days_left":3649,"not_after":"2036-03-26T..."}
# Prometheus metrics
curl http://127.0.0.1:9001/metrics
# mtlsd_active_connections 1
# mtlsd_connections_total 3
# mtlsd_bytes_proxied_total{direction="client_to_target"} 42
# ...Replace certificate files on disk — mtlsd detects the change via filesystem notifications and reloads automatically:
cp /new/certs/server.pem testdata/certs/server.pem
# → level=DEBUG msg="certstore: file changed, scheduling reload" file=testdata/certs/server.pem
# → level=INFO msg="certstore: certificates reloaded successfully"Or send SIGHUP manually:
kill -HUP $(pidof mtlsd)
# → level=INFO msg="received SIGHUP, reloading certificates"
# → level=INFO msg="certificates reloaded successfully"Suppose you have two services running on separate machines and you want them to communicate securely over mTLS — without changing any application code.
Scenario: Service A (an API server) runs on machine-a at 192.168.1.10:3000. Service B (a worker) runs on machine-b at 192.168.1.20 and needs to call Service A.
Machine B (192.168.1.20) Machine A (192.168.1.10)
┌──────────────────────┐ ┌──────────────────────┐
│ Worker (Service B) │ │ API (Service A) │
│ connects to │ │ listening on :3000 │
│ 127.0.0.1:9000 │ │ │
│ │ │ │ ▲ │
│ ▼ │ │ │ │
│ mtlsd CLIENT (:9000) │───── mTLS tunnel ──────▶│ mtlsd SERVER (:8443) │
└──────────────────────┘ └──────────────────────┘
make certsCopy the certs to both machines:
- Machine A (server):
ca.pem,server.pem,server-key.pem - Machine B (client):
ca.pem,client.pem,client-key.pem
Create server.yaml on Machine A:
mode: server
listen: "0.0.0.0:8443"
target: "127.0.0.1:3000" # forward to your local API service
tls:
cert: "/etc/mtlsd/server.pem"
key: "/etc/mtlsd/server-key.pem"
ca: "/etc/mtlsd/ca.pem"
client_auth: required
acl:
allow_cn:
- "worker-service" # only allow the worker's certificate
health:
enabled: true
listen: "127.0.0.1:9000"
metrics:
enabled: true
listen: "127.0.0.1:9001"Start the server daemon:
./mtlsd -config server.yamlCreate client.yaml on Machine B:
mode: client
listen: "127.0.0.1:9000" # local port your worker connects to
target: "192.168.1.10:8443" # Machine A's mtlsd server address
tls:
cert: "/etc/mtlsd/client.pem"
key: "/etc/mtlsd/client-key.pem"
ca: "/etc/mtlsd/ca.pem"
server_name: "machine-a" # must match the server certificate's CN/SANStart the client daemon:
./mtlsd -config client.yamlPoint Service B (the worker) at 127.0.0.1:9000 instead of reaching Service A directly. mtlsd handles the rest:
- Service B connects to
127.0.0.1:9000(plain TCP, no TLS code needed) - mtlsd client wraps the connection in mTLS and sends it to
192.168.1.10:8443 - mtlsd server verifies the client certificate, checks the ACL, and forwards to
127.0.0.1:3000 - Service A receives a plain TCP connection and responds normally
- The response flows back through the same tunnel
Your application code on both sides stays completely unchanged — it just talks plain TCP to localhost.
| Field | Type | Default | Description |
|---|---|---|---|
mode |
string | required | server, client, or bridge |
listen |
string | required | Address to listen on (e.g. 0.0.0.0:8443) |
target |
string | required | Address to forward traffic to |
tls.cert |
string | required | Path to PEM certificate |
tls.key |
string | required | Path to PEM private key |
tls.ca |
string | required | Path to CA certificate for peer verification |
tls.client_auth |
string | required |
required, optional, or none (server mode) |
tls.server_name |
string | Expected server CN/SAN for certificate verification (client mode) | |
tls.min_version |
string | TLS1.2 |
TLS1.2 or TLS1.3 |
acl.allow_cn |
[]string | [] | Allowed Subject Common Names |
acl.allow_org |
[]string | [] | Allowed Subject Organizations |
proxy.dial_timeout |
duration | 10s |
Timeout for connecting to target |
proxy.idle_timeout |
duration | 90s |
Close connection after this idle period |
proxy.max_connections |
int | 1000 |
Maximum concurrent connections |
health.enabled |
bool | false |
Enable health probe HTTP server |
health.listen |
string | 127.0.0.1:9000 |
Health server listen address |
metrics.enabled |
bool | false |
Enable Prometheus metrics HTTP server |
metrics.listen |
string | 127.0.0.1:9001 |
Metrics server listen address |
logging.level |
string | info |
Log level: debug, info, warn, error |
logging.format |
string | text |
Log format: text or json |
If no ACL rules are configured, any client with a valid certificate (signed by the CA) is allowed.
The ACL evaluates after a successful TLS handshake. It checks the first peer certificate against the configured rules using OR logic — a connection is allowed if any rule matches.
acl:
allow_cn:
- "frontend-service" # exact match on Subject CN
- "worker-service"
allow_org:
- "engineering" # exact match on Subject OrganizationDenied connections are closed immediately and logged with the peer's identity.
When health.enabled: true, mtlsd serves three HTTP endpoints on a separate non-TLS listener:
| Endpoint | Purpose |
|---|---|
/healthz |
Liveness — returns 200 if the process is running |
/readyz |
Readiness — returns 200 if certificates are loaded and valid |
/certz |
Certificate info — returns JSON with not_after and days_left |
When metrics.enabled: true, mtlsd exposes a /metrics endpoint in Prometheus exposition format:
| Metric | Type | Description |
|---|---|---|
mtlsd_active_connections |
Gauge | Currently active proxy connections |
mtlsd_connections_total |
Counter | Total connections accepted |
mtlsd_bytes_proxied_total |
Counter | Total bytes proxied (labels: direction) |
mtlsd_connection_duration_seconds |
Histogram | Duration of proxy connections |
mtlsd_acl_decisions_total |
Counter | ACL authorization decisions (labels: result) |
mtlsd_tls_handshake_duration_seconds |
Histogram | TLS handshake duration |
mtlsd_dial_errors_total |
Counter | Failed dial attempts |
mtlsd watches certificate files for changes using filesystem notifications (fsnotify). When any certificate, key, or CA file is modified on disk, mtlsd automatically reloads the new material and uses it for all subsequent connections — without dropping existing connections or restarting the process.
You can also trigger a manual reload by sending SIGHUP:
kill -HUP $(pidof mtlsd)This is useful for certificate rotation scripts or secret management tools (e.g., Vault, cert-manager) that write new certificates to disk.
On SIGTERM or SIGINT, mtlsd:
- Stops accepting new connections
- Waits for existing connections to complete
- Shuts down health and metrics servers
- Exits cleanly
mtlsd uses Go's log/slog for structured, leveled logging. Configure via the logging: block:
logging:
level: info # debug | info | warn | error (default: info)
format: text # text | json (default: text)Text format (default):
time=2026-03-28T23:07:44.000Z level=INFO msg="listener: accepted connection" peer_cn=frontend-service
JSON format (for log aggregators like Loki, Datadog, ELK):
{"time":"2026-03-28T23:07:44.000Z","level":"INFO","msg":"listener: accepted connection","peer_cn":"frontend-service"}All log messages include structured key-value fields (peer_cn, listen, server, error, etc.). Set level: debug to see ACL match details, certstore file-change events, and proxy idle timeouts.
Parses and validates the YAML config file into typed structs consumed by all other modules. Supports health, metrics, and logging configuration sections.
Loads PEM cert/key/CA from disk at startup and builds tls.Config using dynamic callbacks (GetConfigForClient for server, GetClientCertificate + VerifyConnection for client). Supports hot-reload via filesystem watching (fsnotify) and SIGHUP signal — certificates are atomically swapped under a read-write mutex so existing connections are never disrupted.
Post-handshake authorization that decides whether a peer identity is allowed after mutual TLS auth succeeds. Supports exact match on Subject CN and Subject Organization with OR logic — any single match allows the connection. If no rules are configured, any valid cert is allowed.
The inner loop. Accepts a connection on one side, dials the other, bidirectionally copies data. Enforces dial timeout and idle timeout, with panic recovery per goroutine. Reports bytes proxied to Prometheus metrics.
Wraps net.Listen + tls.NewListener to produce a TLS-accepting loop. Uses a semaphore (buffered channel) to enforce max_connections, and exponential backoff on transient accept errors to prevent CPU-wasting tight loops.
Manages outbound mTLS connections to a remote server-mode instance. Uses tls.DialWithDialer with cert from certstore, server cert verified against CA, dial timeout from config.
Kubernetes-compatible /healthz, /readyz, and /certz probes on a separate non-TLS listener. Reports liveness, certificate load readiness, and cert expiry information as JSON.
Exposes /metrics in Prometheus format. Registers global collectors that are recorded from within the proxy, ACL, listener, and dialer modules.
Handles SIGINT/SIGTERM for graceful shutdown and SIGHUP for certificate hot-reload. Returns a cancellable context used by all subsystems.
Configures the global slog default logger at startup with the specified level and format (text or JSON). All modules use slog.Info/Warn/Error/Debug for structured, leveled output.
make build # Build binary to bin/mtlsd
make test # Run all tests with race detector
make certs # Generate test PKI
make smoke # Build + certs + end-to-end smoke test
make clean # Remove bin/ and testdata/certs/mtlsd/
├── cmd/mtlsd/ # Entry point
├── internal/
│ ├── config/ # YAML config loading and validation
│ ├── certstore/ # Certificate loading, rotation, tls.Config builder
│ ├── acl/ # CN + Org access control
│ ├── proxy/ # Bidirectional TCP splice with metrics
│ ├── listener/ # TLS accept loop with semaphore (server mode)
│ ├── dialer/ # mTLS dialer (client mode)
│ ├── health/ # /healthz /readyz /certz HTTP endpoints
│ ├── metrics/ # Prometheus /metrics endpoint
│ ├── signals/ # SIGTERM/SIGHUP signal handling
│ └── logging/ # Structured slog configuration
├── pkg/tlsutil/ # TLS version and cipher helpers
├── scripts/ # Cert generation, smoke test
├── examples/ # Example YAML configs, echo server, test client
└── testdata/ # Test certs and configs
MIT