Reverse Proxy / Load Balancer

MezHub can be deployed behind a TLS-terminating reverse proxy or load balancer. This is the recommended production topology — the load balancer handles TLS certificates, provides DDoS protection, and allows you to scale horizontally with multiple MezHub instances.


Architecture

When deployed behind a load balancer, the traffic flow is:

Connection flow text
Client                    Load Balancer               MezHub
  |                           |                          |
  |---[TLS]--- HTTPS :443 --->|---[plaintext]--- :3080 ->|  Web UI + API
  |---[TLS]--- SSH :3023 ---->|---[TCP]--------- :3023 ->|  SSH proxy
  |---[TLS]--- Tunnel :3024 ->|---[TCP]--------- :3024 ->|  Agent reverse tunnels
  |---[TLS]--- gRPC :3025 --->|---[h2c]--------- :3025 ->|  Auth gRPC API

The load balancer terminates TLS on the public ports and forwards cleartext upstream to MezHub. MezHub's gRPC auth listener (port 3025) is the only port that needs an explicit toggle to accept cleartext: set MEZITE_GRPC_ALLOW_HTTP=true (or auth.grpc_allow_http: true in YAML) to wrap the gRPC server in an h2c handler. To skip mTLS on the gRPC port entirely (e.g. when the LB terminates TLS without re-encrypting), also set MEZITE_AUTH_H2C=true. The proxy's HTTPS listener (3080) can be flipped to cleartext via proxy.tls_mode: "off" in YAML. Clients still see TLS — the LB handles the encryption on the public side.


Why h2c for gRPC?

gRPC requires HTTP/2. When a load balancer terminates TLS, the connection between the load balancer and MezHub is plaintext. Standard HTTP/2 requires TLS (via ALPN negotiation), so plaintext HTTP/2 needs a special mode called h2c (HTTP/2 cleartext).

Two env vars control the gRPC port:

  • MEZITE_GRPC_ALLOW_HTTP=true — wraps the gRPC server in an h2c handler so it accepts h2c prior-knowledge (direct gRPC clients that send HTTP/2 without negotiation) and HTTP/1.1 Upgrade: h2c (reverse proxies that upgrade). mTLS credentials are still loaded.
  • MEZITE_AUTH_H2C=true — additionally disables TLS on the gRPC port (the listener becomes plaintext). Use this when the LB terminates TLS and you do not want re-encryption between the LB and MezHub.

For most reverse-proxy deployments, set MEZITE_GRPC_ALLOW_HTTP=true; add MEZITE_AUTH_H2C=true only if the LB does not re-encrypt upstream.


MezHub Configuration

mezite.yaml — behind a load balancer yaml
cluster_name: my-cluster
data_dir: /var/lib/mezite

database:
  driver: sqlite          # or: postgres
  url: /data/mezhub.db    # or: postgres://user:pass@host/mezite

auth:
  enabled: true
  listen_addr: 0.0.0.0:3025

proxy:
  enabled: true
  listen_addr: 0.0.0.0:3080
  ssh_listen_addr: 0.0.0.0:3023
  tunnel_listen_addr: 0.0.0.0:3024

  # The public address that clients use to reach this cluster.
  # This must match the hostname that resolves to your load balancer.
  public_addr: mezite.example.com

  # Disable TLS on the proxy — the load balancer handles TLS.
  tls_mode: "off"
Required environment variables bash
# Enable h2c (HTTP/2 cleartext) on the gRPC auth port (3025).
# This is the targeted option — mTLS credentials stay loaded.
MEZITE_GRPC_ALLOW_HTTP=true

# Add this only if the LB terminates TLS and does NOT re-encrypt
# upstream. It disables TLS on the gRPC port entirely.
# MEZITE_AUTH_H2C=true

To trust an HTTP header injected by the LB (for rate limiting and the client_ip audit field), set proxy.trusted_ip_header in mezite.yaml — this knob is YAML-only, there is no env-var binding for it. The header only applies to the HTTPS proxy listener; for SSH, tunnel, and any L4 passthrough setup, use PROXY protocol (next section).

mezite.yaml — trust an LB header on the HTTPS listener yaml
proxy:
  trusted_ip_header: X-Forwarded-For   # or: Fly-Client-IP, CF-Connecting-IP, etc.

Preserving the real client IP — PROXY protocol v2

proxy.trusted_ip_header only works on HTTP listeners: it reads an HTTP header the load balancer injects. For the SSH, tunnel, and (in single-port mode) gRPC listeners there is no HTTP layer, so an HTTP header can't carry the source IP. Any deployment where the load balancer operates in TLS passthrough / L4 mode (e.g. AWS NLB with TCP targets, HAProxy in mode tcp) hits the same limit even on the HTTPS port: the LB never sees cleartext, so it can't add headers.

MezHub's listeners support PROXY protocol v1 and v2 — a binary header the LB prepends to each TCP connection that carries the original client address. It survives TLS passthrough because it sits in front of the TLS handshake rather than inside HTTP. Enable it on the listener and configure your LB to send it on every port you expose through MezHub.

mezite.yaml — accept PROXY protocol from a trusted LB yaml
proxy:
  proxy_protocol: "on"

  # Only peers whose direct TCP source IP falls in one of these CIDRs
  # are allowed to send PROXY headers. Keep this as tight as the LB's
  # actual egress range — any host inside the CIDR that can reach the
  # listener could otherwise forge client_ip in the audit log by
  # sending a crafted PROXY header.
  proxy_protocol_trusted_cidrs:
    - 10.0.0.0/24    # example: the LB's private subnet
    - 192.0.2.5/32   # example: a single-IP LB

Peers whose source IP is NOT in the trusted list are rejected if they send a PROXY header — so an attacker who can reach the port directly can't spoof client_ip. Trusted peers that send a connection without a PROXY header fall through to the raw TCP address, which means in-cluster health probes (kubelet, etc.) continue to work when you flip the flag on.

Once enabled, every downstream code path that previously saw the LB's IP — audit event client_ip, the per-IP connection limiter, role IP pinning — transparently sees the real caller. No code or query changes needed.


Port Mapping

Your load balancer needs to forward traffic to MezHub's internal ports. The mapping depends on whether you expose each service on its standard port or consolidate onto fewer external ports.

Standard Port Mapping

Service External Port Internal Port Protocol LB Config
Web UI + API 443 3080 HTTPS TLS termination, forward HTTP
SSH Proxy 3023 3023 TCP+TLS TLS termination, forward TCP
Agent Tunnel 3024 3024 TCP+TLS TLS termination, forward TCP
gRPC Auth 3025 3025 gRPC over TLS TLS termination, forward h2c (HTTP/2 cleartext)

Agent Configuration

Agents connecting through a TLS-terminating load balancer need MEZITE_TLS_WRAP=true. This wraps the tunnel connection in TLS so the load balancer can terminate it, then the SSH tunnel protocol runs over the plaintext connection inside.

Agent environment variables (behind LB) bash
# Connect to the load balancer's tunnel port
MEZITE_PROXY_ADDR=mezite.example.com:3024

# Auth gRPC address (through the load balancer)
MEZITE_AUTH_ADDR=mezite.example.com:3025

# Wrap the tunnel in TLS for the load balancer
MEZITE_TLS_WRAP=true

# Standard agent config
MEZITE_JOIN_TOKEN=<your-join-token>
MEZITE_NODE_NAME=web-server-01
MEZITE_DATA_DIR=/var/lib/mezite

Without MEZITE_TLS_WRAP=true, the agent sends raw SSH protocol on the tunnel port. The load balancer wouldn't know how to handle this (it expects TLS on the external port). With TLS wrapping, the agent sends TLS on the wire, the load balancer terminates it, and the plaintext SSH tunnel protocol reaches MezHub.


Client Configuration

Clients (msh, mezctl) connect to the load balancer's external address. No special flags are needed — TLS is handled by the load balancer, and clients use standard TLS to connect.

Client usage bash
# Login (connects to LB on :443, LB forwards to :3080)
msh login --proxy=mezite.example.com

# SSH (connects to LB on :3023, LB forwards to :3023)
msh ssh --login=ubuntu web-server-01

# Admin CLI (connects to LB on :3025, LB forwards h2c to :3025)
mezctl --auth-server=mezite.example.com:3025 nodes list

Nginx Example

nginx.conf — reverse proxy for MezHub nginx
# HTTPS → MezHub proxy (Web UI + API)
server {
    listen 443 ssl;
    server_name mezite.example.com;

    ssl_certificate /etc/ssl/mezite.crt;
    ssl_certificate_key /etc/ssl/mezite.key;

    location / {
        proxy_pass http://mezhub:3080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support (for web terminal)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

# gRPC → MezHub auth (port 3025)
server {
    listen 3025 ssl http2;
    server_name mezite.example.com;

    ssl_certificate /etc/ssl/mezite.crt;
    ssl_certificate_key /etc/ssl/mezite.key;

    location / {
        grpc_pass grpc://mezhub:3025;
        grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

# SSH proxy (port 3023) — TCP passthrough with TLS termination
stream {
    upstream mezhub_ssh {
        server mezhub:3023;
    }
    server {
        listen 3023 ssl;
        ssl_certificate /etc/ssl/mezite.crt;
        ssl_certificate_key /etc/ssl/mezite.key;
        proxy_pass mezhub_ssh;
    }

    # Agent tunnel (port 3024) — TCP passthrough with TLS termination
    upstream mezhub_tunnel {
        server mezhub:3024;
    }
    server {
        listen 3024 ssl;
        ssl_certificate /etc/ssl/mezite.crt;
        ssl_certificate_key /etc/ssl/mezite.key;
        proxy_pass mezhub_tunnel;
    }
}

Troubleshooting

Agent tunnel fails with "SSH handshake: EOF"

The agent connected to the wrong port. Make sure MEZITE_PROXY_ADDR points to the tunnel port (3024), not the HTTPS port (443). The HTTPS port serves the Web UI and API — it does not speak the SSH tunnel protocol.

mezctl returns "connection refused" or TLS errors

mezctl connects directly to the gRPC port (3025). Make sure your load balancer exposes port 3025 and forwards it as h2c (HTTP/2 cleartext) to MezHub's internal port 3025.

Agent recording upload fails with 502

The agent uploads recordings via gRPC on port 3025. If your load balancer does not support HTTP/2 on this port, the upload will fail. Ensure the load balancer is configured for gRPC/h2c on port 3025 and set MEZITE_GRPC_ALLOW_HTTP=true on MezHub so its gRPC server accepts h2c (add MEZITE_AUTH_H2C=true as well if the LB does not re-encrypt upstream).


Next Steps