This document describes how the Hallmaster Proxy sits between Discord bot containers and Discord's infrastructure, the Docker networking that makes the interception transparent to the bots, and the request lifecycle inside the proxy itself.
+----------------------+ +----------------------+ +---------------+
| bot container | | hallmaster-proxy | | Discord |
| (Node, Python, ...) | HTTPS / | container | HTTPS / | discord.com |
| | WSS | | WSS | gateway.dgg |
| trusts hallmaster | <-------> | terminates TLS with | <-------> | |
| Root CA | | per-host leaf cert | | |
+----------------------+ +----------------------+ +---------------+
^ ^
| |
| docker network DNS aliases | external DNS (8.8.8.8:53)
| (discord.com -> proxy) | bypass to reach real Discord IPs
| |
+------------ hallmaster-proxy-net --+
Every container — proxy and bots — joins the user-defined Docker network
hallmaster-proxy-net. The proxy registers itself with the Docker DNS server
under several aliases:
discord.comdiscord.gggateway.discord.gg
so that any in-container DNS lookup for those hostnames resolves to the proxy's
IP on that network. The Hallmaster Runner base image (which bot images inherit
from) also installs iptables rules that redirect outgoing traffic on ports
80 and 443 to the proxy, catching the cases where a bot dials by raw IP rather
than hostname.
The end result is that the bot believes it is talking directly to Discord, but its TLS handshake terminates inside the proxy.
proxy/
├── main.go wires everything together
└── internals/
├── mitm.go MITMProxy + HandlerDeps + Handshaker + Serve
├── certs/ Root CA loading, on-the-fly leaf signing (cached + auto-renewing)
├── config/ env-driven Config struct
├── discord/ gateway-specific bits (zlib-stream decoder)
├── dnsbypass/ external DNS resolver (Resolver interface)
├── handlers/
│ ├── https.go per-request HTTPS forwarding + Tamperer hooks
│ └── ws.go bidirectional WebSocket pump + Tamperer hooks
├── healthz/ loopback /healthz, 200 if ready() else 503
├── httpio/ pure DecodeBody + framing-only Encode
├── internaltest/ shared test helpers (not imported from production)
└── tamper/ Tamperer interface + Nop + Logging implementations
The proxy listens for TCP on 0.0.0.0:$PROXY_PORT (typically 443).
MITMProxy.Serve(ctx, ln, deps, handler) runs the accept loop until ctx
is cancelled or the listener errors out; Listen is a thin convenience
wrapper around Serve(context.Background(), net.Listen(…), …) used by
main.go. For each accepted connection the proxy peeks the first byte:
0x16-> TLS ClientHello, treat as a direct TLS connection.- Anything else -> read a plain HTTP request; if it is
CONNECT host:port, acknowledge with200 Connection establishedand treat the remainder of the socket as a TLS handshake. Other HTTP methods are rejected.
This dual-mode accept means both bot -> proxy: HTTPS (when an iptables
redirect lands a real Discord-bound TLS handshake on the proxy) and
bot -> proxy: HTTP CONNECT (when the runtime is configured to use the proxy
explicitly, e.g. NODE_USE_ENV_PROXY=1) both work.
On every TLS handshake the proxy uses a GetCertificate callback. The callback
reads the SNI hostname from ClientHelloInfo.ServerName, asks the
MITMProxyCerts cache for a leaf cert for that hostname, and returns it.
MITMProxyCerts:
- Loads the Root CA (public cert + private key) at startup from the paths in
PROXY_SSL_CA_CERT_PATH/PROXY_SSL_CA_KEY_PATH. Refuses to load a key whose file mode permits group/other read. - On every new hostname, generates a 2048-bit RSA key and a leaf cert valid
for 7 days, signed by the Root CA.
tls.Certificate.Leafis populated so the cache can checkNotAfter. - Caches the resulting
*tls.Certificatein async.Map, with asingleflight.Groupensuring only one goroutine builds a given hostname's cert when many connections race for it. - Auto-renews any cached cert within 24h of expiry by falling through into the singleflight regeneration path.
The bot, having the Root CA installed in its trust store, accepts the leaf cert and the handshake completes — the bot now has a plaintext channel to the proxy that it believes is a plaintext channel to Discord.
Because the bot's DNS lookups for discord.com etc. resolve to the proxy
itself, the proxy cannot reuse the same hostname to reach the real Discord.
It uses dnsbypass.ExternalResolver — a net.Resolver configured to dial
$PROXY_DNS_SERVER (default 8.8.8.8:53) — to resolve the real upstream IP,
then dials tcp to <real-ip>:<port> with ServerName still set to the
original hostname so SNI and certificate validation against Discord's cert
both work.
A cfg.UpstreamDialTimeout (10s) bounds the dial. Tests can override the
dial itself via HandlerDeps.DialUpstream (e.g. to route through net.Pipe)
and can inject a custom RootCAs / InsecureSkipVerify posture via
HandlerDeps.UpstreamTLSConfig — the handler clones it before setting
ServerName and NextProtos.
The proxy maintains one upstream TLS connection per
(client TLS session, originalHost) pair. If a subsequent request on the
same client session targets a different host, the existing upstream is
closed and a fresh dial is made — pipelining across hosts is supported by
re-dialling, not multiplexing.
Once the upstream TLS connection is up, handlers/https.go loops:
http.ReadRequestfrom the client.- Decide whether to intercept or blind-forward via
isDiscordHost(req.Host)(suffix allowlist:discord.com,discord.gg,gateway.discord.gg, plus their one-level subdomains) or relay detection (req.Hostequals the proxy hostname). Anything else falls through to a blindio.Copypump in both directions. - For intercepted traffic: hand the request to
Tamperer.Request, write the (possibly rewritten) request upstream, read the response back, callhttpio.DecodeBody(resp)to get a decoded[]byteview for the tamperer, hand both toTamperer.Response(req, resp, decodedBody), normalise framing viahttpio.Encode(Content-Lengthreset,Transfer-Encodingdropped — the body bytes themselves are forwarded as-is, matching the originalContent-Encoding), and write back to the client. - Nested
CONNECTinside an already-tunnelled session is handled iteratively in the same loop, capped atmaxConnectDepth = 4. The outer upstream is closed before re-handshaking against the inner host. - If the response is
101 Switching Protocolsfor a WebSocket upgrade, the loop hands off toInspectWS(see below) and stops reading HTTP from this connection.
For Discord's gateway, the bot sends a GET /?v=10&encoding=json&compress=zlib-stream
which upgrades to WebSocket. After the 101 response is mirrored back to the
bot, handlers/ws.go's InspectWS pumps
frames in both directions concurrently:
bot -> Discord: read a frame off the client, unmask if needed, hand the unmasked payload toTamperer.WSOutgoing(whose return value is forwarded upstream).Discord -> bot: read a frame off the upstream. Ifcompress=zlib-streamwas negotiated, feed binary frames into a statefulZlibStreamDecoderso the Tamperer sees readable JSON; but always forward the original compressed frame to the bot — bots that negotiated compression expect compressed bytes. For uncompressed connections, the Tamperer's return value IS what gets forwarded.
When either direction hits an OpClose or an error, both goroutines drain and
the connection terminates.
- DNS aliases vs
iptables: DNS aliases catch hostname-based connections.iptables(installed by the Hallmaster Runner image) catches IP-based connections (some runtimes resolve once and dial the IP from then on, or ship hard-coded fallback IPs). - External resolver leak:
PROXY_DNS_SERVERis dialled via UDP from the proxy container. The Docker network must allow egress to that resolver, or Discord hostnames will never be resolved. - Relay traffic: if a runtime uses the proxy as an explicit HTTP proxy
(e.g.
HTTPS_PROXY=http://hallmaster-proxy:443), the bot dials the proxy and then issuesCONNECT discord.com:443. The proxy'sreq.Hostwill be the bot's view of the target (Discord), and thereq.Host == proxyHostPortbranch covers the case where a bot accidentally targets the proxy directly. - The proxy is not designed to be reachable from outside the Docker
network. It binds
0.0.0.0onPROXY_PORTinside the container, but no port is published in the example compose. Keep it that way: this is a per-deployment MITM with a self-signed CA, not a public service.
The proxy starts a second HTTP server on 127.0.0.1:$PROXY_HEALTH_PORT
serving GET /healthz. The endpoint returns 200 ok once
MITMProxy.Ready() reports true (i.e. Serve has entered its accept loop)
and 503 not ready otherwise. Docker's healthcheck wgets that endpoint
every 15s. Because it binds loopback only, it is not exposed on the Docker
network.
- The Hallmaster Runner base image — installs the Root CA into the system
trust store and configures
iptables. Bot containers extend this image. - The bot images themselves — these are pulled into compose via
image:orbuild:; the proxy is agnostic to what runtime they use as long as the runtime trusts the Root CA.