1. The problem I’m having:
I’m trying to convert a rather basic NGINX proxy to Caddy for the sake of memory safety, but the proxy seems to fail with my current Caddy configuration.
2. Error messages and/or full log output:
Below are logs of caddy-relay
when trying to connect through the Signal app.
{"level":"error","ts":1724048911.1883106,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"174.72.24.251","remote_port":"33150","client_ip":"174.72.24.251","proto":"HTTP/1.1","method":"GET","host":"connect.netrunner.academy","uri":"/","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"],"Accept-Language":["en-US,en;q=0.9"],"Connection":["close"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"connect.netrunner.academy"}},"duration":0.003698411,"status":502,"err_id":"x1z67qgrv","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
{"level":"error","ts":1724050340.4996786,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"5.164.29.116","remote_port":"33636","client_ip":"5.164.29.116","proto":"HTTP/1.1","method":"GET","host":"connect.netrunner.academy","uri":"/","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36 (scanner.ducks.party)"],"Accept-Encoding":["gzip"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"connect.netrunner.academy"}},"duration":0.013764111,"status":502,"err_id":"ra4kmkf4j","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
{"level":"error","ts":1724050340.9463775,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"5.164.29.116","remote_port":"33636","client_ip":"5.164.29.116","proto":"HTTP/1.1","method":"GET","host":"connect.netrunner.academy","uri":"/favicon.ico","headers":{"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36 (scanner.ducks.party)"],"Accept-Encoding":["gzip"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"connect.netrunner.academy"}},"duration":0.002413049,"status":502,"err_id":"rfxgds6y8","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
The expected result would be for caddy-proxy
to match one of the defined hosts and act as a transparent proxy between the Signal server and the end user, but instead I’m seeing error 502
which would indicate my caddy config is not working as intended.
When trying to visit the proxy from Chrome, the expected result should be a basic error 404
, but once again the logs are instead showing error 502
. Below are the logs for desktop client requests:
{"level":"error","ts":1724048782.0440547,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"172.18.0.1","remote_port":"40698","client_ip":"172.18.0.1","proto":"HTTP/2.0","method":"GET","host":"connect.netrunner.academy","uri":"/","headers":{"Sec-Ch-Ua-Platform":["\"Chrome OS\""],"Upgrade-Insecure-Requests":["1"],"Sec-Fetch-Dest":["document"],"Sec-Fetch-User":["?1"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Ch-Ua-Mobile":["?0"],"User-Agent":["Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"],"Sec-Fetch-Site":["none"],"Sec-Fetch-Mode":["navigate"],"Sec-Ch-Ua":["\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\""],"Dnt":["1"],"Priority":["u=0, i"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"],"Accept-Language":["en-US,en;q=0.9"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"connect.netrunner.academy"}},"duration":0.018127019,"status":502,"err_id":"nepxej5ud","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
{"level":"error","ts":1724048910.7399435,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"172.18.0.1","remote_port":"40698","client_ip":"172.18.0.1","proto":"HTTP/2.0","method":"GET","host":"connect.netrunner.academy","uri":"/","headers":{"Sec-Fetch-Mode":["navigate"],"Sec-Fetch-User":["?1"],"Sec-Fetch-Dest":["document"],"Accept-Language":["en-US,en;q=0.9"],"Sec-Ch-Ua":["\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\""],"Sec-Ch-Ua-Platform":["\"Chrome OS\""],"Upgrade-Insecure-Requests":["1"],"Dnt":["1"],"User-Agent":["Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"],"Priority":["u=0, i"],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Fetch-Site":["none"],"Accept-Encoding":["gzip, deflate, br, zstd"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"connect.netrunner.academy"}},"duration":0.017589707,"status":502,"err_id":"iuinam6d2","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
{"level":"error","ts":1724048922.9016356,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"172.18.0.1","remote_port":"40698","client_ip":"172.18.0.1","proto":"HTTP/2.0","method":"GET","host":"connect.netrunner.academy","uri":"/","headers":{"Dnt":["1"],"Sec-Fetch-Site":["none"],"Upgrade-Insecure-Requests":["1"],"Sec-Purpose":["prefetch;prerender"],"Purpose":["prefetch"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"],"Sec-Fetch-Dest":["document"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-User":["?1"],"Priority":["u=0, i"],"Sec-Ch-Ua":["\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Chrome OS\""],"User-Agent":["Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"],"Sec-Fetch-Mode":["navigate"],"Accept-Language":["en-US,en;q=0.9"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"connect.netrunner.academy"}},"duration":0.013143114,"status":502,"err_id":"fdx61i17d","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
{"level":"error","ts":1724056955.229329,"logger":"http.log.error","msg":"EOF","request":{"remote_ip":"172.18.0.1","remote_port":"53574","client_ip":"172.18.0.1","proto":"HTTP/2.0","method":"GET","host":"connect.netrunner.academy","uri":"/favicon.ico","headers":{"User-Agent":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15"],"Referer":["https://connect.netrunner.academy/"],"Accept-Encoding":["gzip, deflate, br"],"Accept":["*/*"],"Sec-Fetch-Site":["same-origin"],"Sec-Fetch-Dest":["image"],"Accept-Language":["en-CA,en-US;q=0.9,en;q=0.8"],"Sec-Fetch-Mode":["no-cors"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"connect.netrunner.academy"}},"duration":0.00295089,"status":502,"err_id":"3jp1qdm08","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}
3. Caddy version:
2.8.4-r2
4. How I installed and ran Caddy:
Caddy was installed via the Chainguard Caddy image labeled as latest (Chainguard Images - caddy - versions).
a. System environment:
Docker compose file running inside of Ubuntu 24.04.
services:
caddy-terminate:
image: cgr.dev/chainguard/caddy:latest
ports:
- "80:80"
- "443:443"
restart: unless-stopped
volumes:
- ./data/caddy-terminate/Caddyfile:/etc/caddy/Caddyfile:ro,Z
- caddy_data:/data:Z
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
command: run --config /etc/caddy/Caddyfile
caddy-relay:
image: cgr.dev/chainguard/caddy:latest
restart: unless-stopped
volumes:
- ./data/caddy-relay/Caddyfile:/etc/caddy/Caddyfile:ro,Z
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
command: run --config /etc/caddy/Caddyfile
volumes:
caddy_data:
external: true
The Caddy configuration file is trying to be converted directly from NGINX and is split into two containers:
caddy-terminate
which handles all incoming traffic on ports 80 and 443. All traffic on port 80 should return 404, while all traffic on port 443 should be forwarded to thecaddy-relay
container on port 4433.caddy-relay
which is supposed to check the incoming ClientHello request information and match it to the corresponding backend service. If there is no match, it should immediately terminate the connection.
b. My complete Caddy config:
caddy-terminate:
connect.netrunner.academy:443 {
tls {
protocols tls1.2 tls1.3
}
reverse_proxy caddy-relay:4433
}
connect.netrunner.academy:80 {
route / {
respond 404
}
}
caddy-relay:
:4433 {
@signal_service host chat.signal.org ud-chat.signal.org
handle @signal_service {
reverse_proxy https://chat.signal.org:443
}
@storage_service host storage.signal.org
handle @storage_service {
reverse_proxy https://storage.signal.org:443
}
@signal_cdn host cdn.signal.org
handle @signal_cdn {
reverse_proxy https://cdn.signal.org:443
}
@signal_cdn2 host cdn2.signal.org
handle @signal_cdn2 {
reverse_proxy https://cdn2.signal.org:443
}
@signal_cdn3 host cdn3.signal.org
handle @signal_cdn3 {
reverse_proxy https://cdn3.signal.org:443
}
@cdsi host cdsi.signal.org
handle @cdsi {
reverse_proxy https://cdsi.signal.org:443
}
@content_proxy host contentproxy.signal.org
handle @content_proxy {
reverse_proxy https://contentproxy.signal.org:443
}
@uptime host uptime.signal.org
handle @uptime {
reverse_proxy https://uptime.signal.org:443
}
@sfu host sfu.voip.signal.org
handle @sfu {
reverse_proxy https://sfu.voip.signal.org:443
}
@svr2 host svr2.signal.org
handle @svr2 {
reverse_proxy https://svr2.signal.org:443
}
@updates host updates.signal.org
handle @updates {
reverse_proxy https://updates.signal.org:443
}
@updates2 host updates2.signal.org
handle @updates2 {
reverse_proxy https://updates2.signal.org:443
}
@svr31 host backend1.svr3.signal.org
handle @svr31 {
reverse_proxy https://backend1.svr3.signal.org:443
}
@svr32 host backend2.svr3.signal.org
handle @svr32 {
reverse_proxy https://backend2.svr3.signal.org:443
}
@svr33 host backend3.svr3.signal.org
handle @svr33 {
reverse_proxy https://backend3.svr3.signal.org:443
}
handle {
abort
}
}
5. Links to relevant resources:
All of my configurations are intended to be a direct mirror of the official Signal-TLS-Proxy repository which uses NGINX, but migrated to Caddy. The relevant files I am attempting to convert are listed below:
nginx-terminate (used as a base for caddy-terminate):
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
location /.well-known/acme-challenge/ {
# init-certificate.sh uses --standalone, so we must proxy renewals to the certbot server
proxy_pass http://certbot:80;
}
location / {
return 404;
}
}
}
stream {
upstream relay {
server nginx-relay:4433;
}
server {
listen 443 ssl;
proxy_pass relay;
access_log off;
error_log /dev/null;
ssl_certificate /etc/letsencrypt/active/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/active/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
}
nginx-relay (used as a base for caddy-relay):
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
stream {
map $ssl_preread_server_name $name {
chat.signal.org signal-service;
ud-chat.signal.org signal-service;
storage.signal.org storage-service;
cdn.signal.org signal-cdn;
cdn2.signal.org signal-cdn2;
cdn3.signal.org signal-cdn3;
cdsi.signal.org cdsi;
contentproxy.signal.org content-proxy;
sfu.voip.signal.org sfu;
svr2.signal.org svr2;
updates.signal.org updates;
updates2.signal.org updates2;
backend1.svr3.signal.org svr31;
backend2.svr3.signal.org svr32;
backend3.svr3.signal.org svr33;
default deny;
}
upstream signal-service {
server chat.signal.org:443;
}
upstream storage-service {
server storage.signal.org:443;
}
upstream signal-cdn {
server cdn.signal.org:443;
}
upstream signal-cdn2 {
server cdn2.signal.org:443;
}
upstream signal-cdn3 {
server cdn3.signal.org:443;
}
upstream cdsi {
server cdsi.signal.org:443;
}
upstream content-proxy {
server contentproxy.signal.org:443;
}
upstream sfu {
server sfu.voip.signal.org:443;
}
upstream svr2 {
server svr2.signal.org:443;
}
upstream svr31 {
server backend1.svr3.signal.org:443;
}
upstream svr32 {
server backend2.svr3.signal.org:443;
}
upstream svr33 {
server backend3.svr3.signal.org:443;
}
upstream updates {
server updates.signal.org:443;
}
upstream updates2 {
server updates2.signal.org:443;
}
upstream deny {
server 127.0.0.1:9;
}
server {
listen 4433;
proxy_pass $name;
ssl_preread on;
error_log /dev/null;
access_log off;
}
}
If it makes it easier for context switching, below are the two git repositories:
Official Signal-TLS-Proxy repository: GitHub - signalapp/Signal-TLS-Proxy
My fork which tries to migrate it to Caddy: GitHub - MiahaCybersec/Signal-TLS-Proxy: An experimental port of signal's TLS proxy from NGINX to Caddy. Not currently recommended for production.