Caddy reverse_proxy not working over HTTP/3

1. Output of caddy version:

daniel@dilithium:~/caddy$ sudo docker exec caddy caddy version
v2.6.2 h1:wKoFIxpmOJLGl3QXoo6PNbYvGW4xLEgo32GPBEjWL8o=

2. How I run Caddy:

Caddy runs in Docker on a macvlan network and is a reverse proxy for a single domain (at the moment), which is my Emby media server.

a. System environment:

Server: Bare Metal Ubuntu 20.04
Docker Version: 20.10.22

daniel@dilithium:~$ uname -a
Linux dilithium 5.4.0-135-generic #152-Ubuntu SMP Wed Nov 23 20:19:22 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

b. Command:

sudo docker compose start caddy

c. Service/unit/compose file:

services:

  caddy:
    build: ./dockerfile-caddy
    container_name: caddy
    hostname: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      - MY_DOMAIN
      - CLOUDFLARE_API_TOKEN
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./data:/data
      - ./config:/config
networks:
  default:
    name: phynet6
    external: true

docker-compose.yml

FROM caddy:2.6.2-builder AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare

FROM caddy:2.6.2

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

### I don't think this does anything, I just used it for testing
EXPOSE 80/tcp
EXPOSE 443/tcp
EXPOSE 443/udp

d. My complete Caddy config:

This is my actual Caddyfile with nothing changed

{
  acme_dns cloudflare {$CLOUDFLARE_API_TOKEN}
}

emby.{$MY_DOMAIN} {
    reverse_proxy https://[2600:1700:ada8:750f::3002]:8920 {
      transport http {
        tls_server_name emby.danielmarks.dev
        tls_insecure_skip_verify
        versions 2
      }
    }
}

3. The problem I’m having:

Caddy is not proxying over HTTP/3, and I’m not getting a connection via QUIC from either a browser or curl. This used to work back when we had to use the experimental_http3 flag, but now it won’t even negotiate http3.

http/2 and http/1.1 works as expected

4. Error messages and/or full log output:

Logs when server is initialized

{"level":"info","ts":1671966298.820925,"msg":"using provided configuration","config_file":"/etc/caddy/Caddyfile","config_adapter":"caddyfile"}
{"level":"warn","ts":1671966298.8223689,"msg":"Caddyfile input is not formatted; run the 'caddy fmt' command to fix inconsistencies","adapter":"caddyfile","file":"/etc/caddy/Caddyfile","line":2}
{"level":"info","ts":1671966298.8233094,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforce_origin":false,"origins":["//localhost:2019","//[::1]:2019","//127.0.0.1:2019"]}
{"level":"info","ts":1671966298.8235204,"logger":"http","msg":"server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS","server_name":"srv0","https_port":443}
{"level":"info","ts":1671966298.8235352,"logger":"http","msg":"enabling automatic HTTP->HTTPS redirects","server_name":"srv0"}
{"level":"info","ts":1671966298.823603,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc000492850"}
{"level":"info","ts":1671966298.8238966,"logger":"tls","msg":"cleaning storage unit","description":"FileStorage:/data/caddy"}
{"level":"info","ts":1671966298.8239021,"logger":"http","msg":"enabling HTTP/3 listener","addr":":443"}
{"level":"debug","ts":1671966298.8240638,"logger":"http","msg":"starting server loop","address":"[::]:443","tls":true,"http3":true}
{"level":"info","ts":1671966298.8240795,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
{"level":"debug","ts":1671966298.8241053,"logger":"http","msg":"starting server loop","address":"[::]:80","tls":false,"http3":false}
{"level":"info","ts":1671966298.8241127,"logger":"http.log","msg":"server running","name":"remaining_auto_https_redirects","protocols":["h1","h2","h3"]}
{"level":"info","ts":1671966298.8241162,"logger":"http","msg":"enabling automatic TLS certificate management","domains":["emby.danielmarks.dev"]}
{"level":"debug","ts":1671966298.8244905,"logger":"tls","msg":"loading managed certificate","domain":"emby.danielmarks.dev","expiration":1678652887,"issuer_key":"acme-v02.api.letsencrypt.org-directory","storage":"FileStorage:/data/caddy"}
{"level":"info","ts":1671966298.824511,"logger":"tls","msg":"finished cleaning storage units"}
{"level":"debug","ts":1671966298.8247864,"logger":"tls.cache","msg":"added certificate to cache","subjects":["emby.danielmarks.dev"],"expiration":1678652887,"managed":true,"issuer_key":"acme-v02.api.letsencrypt.org-directory","hash":"dccb5f3436632db870b67c49d705580dd7edeb8683b97529e46231bc7d205d26","cache_size":1,"cache_capacity":10000}
{"level":"debug","ts":1671966298.8248105,"logger":"events","msg":"event","name":"cached_managed_cert","id":"0d168666-f017-4944-812a-ec0c2be8b065","origin":"tls","data":{"sans":["emby.danielmarks.dev"]}}
{"level":"info","ts":1671966298.8249636,"msg":"autosaved config (load with --resume flag)","file":"/config/caddy/autosave.json"}
{"level":"info","ts":1671966298.824974,"msg":"serving initial configuration"}

Log when I query the server with HTTP/3 via curl

{"level":"debug","ts":1671966623.1476204,"logger":"events","msg":"event","name":"tls_get_certificate","id":"37162c87-98ab-4ca5-85c3-96667f789ae5","origin":"tls","data":{"client_hello":{"CipherSuites":[4865,4866,4867],"ServerName":"emby.danielmarks.dev","SupportedCurves":[29,23,24],"SupportedPoints":null,"SignatureSchemes":[1027,2052,1025,1283,2053,1281,2054,1537,513],"SupportedProtos":["h3","h3-29","h3-28","h3-27"],"SupportedVersions":[772],"Conn":{}}}}
{"level":"debug","ts":1671966623.1477995,"logger":"tls.handshake","msg":"choosing certificate","identifier":"emby.danielmarks.dev","num_choices":1}
{"level":"debug","ts":1671966623.1478229,"logger":"tls.handshake","msg":"default certificate selection results","identifier":"emby.danielmarks.dev","subjects":["emby.danielmarks.dev"],"managed":true,"issuer_key":"acme-v02.api.letsencrypt.org-directory","hash":"dccb5f3436632db870b67c49d705580dd7edeb8683b97529e46231bc7d205d26"}
{"level":"debug","ts":1671966623.1478362,"logger":"tls.handshake","msg":"matched certificate in cache","remote_ip":"2600:1700:ada8:750f:419e:fef9:b6ae:caab","remote_port":"50913","subjects":["emby.danielmarks.dev"],"managed":true,"expiration":1678652887,"hash":"dccb5f3436632db870b67c49d705580dd7edeb8683b97529e46231bc7d205d26"}

5. What I already tried:

Here’s a curl output (patched with quiche):

danielmarks@Daniels-MBP ~ % curl --http3 https://emby.danielmarks.dev:443 -v
*   Trying [2600:1700:ada8:750f::3001]:443...
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* Connect socket 5 over QUIC to 2600:1700:ada8:750f::3001:443
* Sent QUIC client Initial, ALPN: h3,h3-29,h3-28,h3-27
* Connection timeout after 300000 ms
* Closing connection 0
curl: (28) Connection timeout after 300000 ms

I’ve run pcaps and found that the caddy server is not attempting to respond at all.

aforementioned pcap: http://global.danielmarks.dev/ds/quic-pcap.pcapng

Perhaps Caddy doesn’t proxy HTTP/3 when the origin is HTTP/2?

6. Links to relevant resources:

:80 and :443/tcp won’t work for me either.
Is that a dynamic IP that just changed, maybe?

This does, however, sound like a firewall issue to me.
Is your docker daemon configured to properly support IPv6?

You can also try replacing

-ports:
-    - "80:80"
-    - "443:443"
-    - "443:443/udp"
+ network_mode: "host"

just to be sure, Docker isn’t doing something funky with ip{,6}tables :thinking:

2 Likes

I appreciate you looking at this! I ended up resolving it while typing up a response, so I will post it below for any other unfortunate souls who end up on this thread.

TL;DR My solution was to just use SLAAC moving forward, so there’s only a single IP address for UDP packets to exit from.

:80 and :443/tcp won’t work for me either.
Is that a dynamic IP that just changed, maybe?

I allowlist IPs to prevent arbitrary connections to the server, it’s mostly just friends and family using it so this is easy enough to maintain.

This does, however, sound like a firewall issue to me.

That’s what I was thinking at first, but I’m just using the base docker caddy image on a macvlan network which inherently has no software networking other than whatever is running on the container. There’s no hardware firewall between Caddy and me for the purposes of testing, but on my external firewall I do also expose 443/udp.

Is your docker daemon configured to properly support IPv6?

Docker technically isn’t actually doing much in terms of networking because it’s a macvlan network. From what I saw, Caddy is receiving the connection request over IPv6, it just isn’t responding. At least, that’s how I would interpret the logs in my initial post, but that may be an incorrect assessment.

In the interests of testing, I added an A record for my Caddy server, and it looks like the connection is actually going through over IPv4:

danielmarks@Daniels-MBP ~ % curl --http3 https://emby.danielmarks.dev:443 -v -4
*   Trying 192.168.40.209:443...
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* Connect socket 5 over QUIC to 192.168.40.209:443
* Sent QUIC client Initial, ALPN: h3,h3-29,h3-28,h3-27
*  subjectAltName: host "emby.danielmarks.dev" matched cert's "emby.danielmarks.dev"
* Verified certificate just fine
* Connected to emby.danielmarks.dev (192.168.40.209) port 443 (#0)
* Connected to emby.danielmarks.dev (192.168.40.209) port 443 (#0)
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: emby.danielmarks.dev]
* h2h3 [user-agent: curl/7.87.1-DEV]
* h2h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x14c011600)
> GET / HTTP/3
> Host: emby.danielmarks.dev
> user-agent: curl/7.87.1-DEV
> accept: */*
> 
< HTTP/3 302
< server: Caddy
< server: Kestrel
< date: Tue, 27 Dec 2022 05:36:26 GMT
< location: web/index.html
* Connection #0 to host emby.danielmarks.dev left intact

This led me to check the container’s network to make sure it was actually binding to the correct ports, and it appears it does.

daniel@dilithium:~$ sudo docker exec caddy netstat -tulpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:2019          0.0.0.0:*               LISTEN      1/caddy
tcp        0      0 127.0.0.11:46373        0.0.0.0:*               LISTEN      -
tcp        0      0 :::443                  :::*                    LISTEN      1/caddy
tcp        0      0 :::80                   :::*                    LISTEN      1/caddy
udp        0      0 127.0.0.11:33675        0.0.0.0:*                           -
udp        0      0 :::443                  :::*                                1/caddy

After seeing that, I decided to run a pcap on the server itself (sudo tcpdump -A -i ens2f0 udp port 443 -w tcpdump.pcap): https://global.danielmarks.dev/ds/tcpdump.pcap

For the IPv6 connection, you can see the Client HELLO being sent to 2600:1700:ada8:750f::3001, which is the IP address of the Caddy server. The interesting part comes in when the Caddy server responds, as it responds from 2600:1700:ada8:750f:42:c0ff:fea8:28d1, which is (after some digging around) another IPv6 address attached to the docker instance (via SLAAC).

I changed the AAAA record to the SLAAC address, and voila!

danielmarks@Daniels-MBP ~ % curl --http3 https://emby.danielmarks.dev:443 -v -6
*   Trying [2600:1700:ada8:750f:42:c0ff:fea8:28d1]:443...
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* Connect socket 5 over QUIC to 2600:1700:ada8:750f:42:c0ff:fea8:28d1:443
* Sent QUIC client Initial, ALPN: h3,h3-29,h3-28,h3-27
*  subjectAltName: host "emby.danielmarks.dev" matched cert's "emby.danielmarks.dev"
* Verified certificate just fine
* Connected to emby.danielmarks.dev (2600:1700:ada8:750f:42:c0ff:fea8:28d1) port 443 (#0)
* Connected to emby.danielmarks.dev (2600:1700:ada8:750f:42:c0ff:fea8:28d1) port 443 (#0)
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: emby.danielmarks.dev]
* h2h3 [user-agent: curl/7.87.1-DEV]
* h2h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x134014c00)
> GET / HTTP/3
> Host: emby.danielmarks.dev
> user-agent: curl/7.87.1-DEV
> accept: */*
> 
< HTTP/3 302
< server: Caddy
< server: Kestrel
< date: Tue, 27 Dec 2022 07:30:36 GMT
< location: web/index.html
* Connection #0 to host emby.danielmarks.dev left intact

By default, Caddy (and therefore quic-go) is binding to ::, which leaves outbound routing left to the kernel to decide. TCP is connection-oriented, so this worked flawlessly with HTTP/2 because the TCP flow was happening over the same IP address. My understanding of QUIC means that ignoring the handshake response is intended if the handshake response is from a different IP address, as UDP is connectionless.

Thanks again for the help! Using “host” wasn’t feasible for me for a few reasons, but that would have also resolved the issue.

4 Likes

Thanks for posting your very elaborate response :slight_smile:

I totally missed the macvlan part^^

3 Likes

This topic was automatically closed after 30 days. New replies are no longer allowed.