Skip to content

GuyWithXM5/mtlsd

Repository files navigation

mtlsd — Mutual TLS Tunnel Daemon

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

Use Cases

  • 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.

Features

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

Quick Start

1. Generate certificates

Generate a test CA, server, and client certificates:

make certs

This creates a full PKI under testdata/certs/ using OpenSSL.

2. Configure

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

Client — 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

3. Run

# Build
make build

Start 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:9080

Now 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 mtlsd

When 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
  1. Caller sends hello mtlsd to the test client on :7070
  2. Test client forwards to the mtlsd client daemon on :9080
  3. mtlsd client wraps it in mTLS and sends to the mtlsd server on :8443
  4. mtlsd server checks the ACL, terminates TLS, and forwards to the echo server on :8080
  5. Echo server replies with echo: hello mtlsd
  6. Response flows back through :8443:9080:7070 → caller

4. Check health and metrics

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
# ...

5. Hot-reload certificates

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"

Connecting Two Services Across Machines

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) │
└──────────────────────┘                          └──────────────────────┘

Step 1 — Generate and distribute certificates

make certs

Copy 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

Step 2 — Configure Machine A (server side)

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.yaml

Step 3 — Configure Machine B (client side)

Create 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/SAN

Start the client daemon:

./mtlsd -config client.yaml

Step 4 — Connect your services

Point Service B (the worker) at 127.0.0.1:9000 instead of reaching Service A directly. mtlsd handles the rest:

  1. Service B connects to 127.0.0.1:9000 (plain TCP, no TLS code needed)
  2. mtlsd client wraps the connection in mTLS and sends it to 192.168.1.10:8443
  3. mtlsd server verifies the client certificate, checks the ACL, and forwards to 127.0.0.1:3000
  4. Service A receives a plain TCP connection and responds normally
  5. The response flows back through the same tunnel

Your application code on both sides stays completely unchanged — it just talks plain TCP to localhost.

Configuration Reference

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.

Access Control

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 Organization

Denied connections are closed immediately and logged with the peer's identity.

Health Probes

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

Prometheus Metrics

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

Certificate Hot-Reload

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.

Graceful Shutdown

On SIGTERM or SIGINT, mtlsd:

  1. Stops accepting new connections
  2. Waits for existing connections to complete
  3. Shuts down health and metrics servers
  4. Exits cleanly

Logging

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.

Modules

config — YAML Configuration Loader

Parses and validates the YAML config file into typed structs consumed by all other modules. Supports health, metrics, and logging configuration sections.

certstore — Certificate & Key Management

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.

acl — Access Control & Authorization

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.

proxy — Connection Proxy Core

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.

listener — TLS Listener (Server Mode)

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.

dialer — mTLS Dialer (Client Mode)

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.

health — Health & Readiness Endpoint

Kubernetes-compatible /healthz, /readyz, and /certz probes on a separate non-TLS listener. Reports liveness, certificate load readiness, and cert expiry information as JSON.

metrics — Prometheus Metrics

Exposes /metrics in Prometheus format. Registers global collectors that are recorded from within the proxy, ACL, listener, and dialer modules.

signals — Signal Handling

Handles SIGINT/SIGTERM for graceful shutdown and SIGHUP for certificate hot-reload. Returns a cancellable context used by all subsystems.

logging — Structured Logging

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.

Building

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/

Project Structure

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

License

MIT

About

a TCP Proxy tool to enforce Mutual TLS Authentication (mtls)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors