HTTP connection upgrade to TCP seems broken since 2.4

Summary

Upgrading a HTTP connection to TCP no long work for versions greater than v2.3.
This is NOT about upgrading a connection in a TCP frontend using switch-mode http, nor is it about WebSocket. It’s about the Connection: Upgrade and Upgrade: tcp HTTP headers.

With HAProxy 2.2: client and server could upgrade the connection and converse bidirectionnaly using a proprietary binary format on the connection.

With HAProxy 3.0: proxy returns HTTP 200 (instead of expected 101) and connection upgrade fails.

Looking at traces, the problem seems to originate from the parsing of the HTTP request. The Upgrade header seems ignored and is not passed to the backend, so no upgrade takes place.

Looking at the code, the h1_parse_upgrade_header seems to ignore the Upgrade header unless it’s set to websocket.

Do you think this is the intended behavior? Or is it a shortcoming?

Context

The docker-socket-proxy project uses HAproxy to filter requests to the Docker daemon using ACLs (like here). It worked well with HAproxy v2.2 and broke when I updated HAproxy to v3.0.

The proxied protocol is the Docker Engine API. It’s mostly HTTP REST but for some endpoints, the connection is “hijacked”: upgraded to TCP, then used for streaming a proprietary binary format in both directions. Hijacking is detailed here in Docker doc.

An example of problematic call is the POST to /v1.46/exec/{id}/start.

Observations

Note: output of various commands were redacted for brevity

When working (HAproxy 2.2)

Proxy logs

dockerhttp dockerhttp/dockersocket 0/0/0/0/29 101 316 - - ---- 2/2/0/0/0 0/0 "POST /v1.46/exec/a84c8fa7af83d2d6fb8438e107f82fc569284db962ba8f57f0794686c5cb7068/start HTTP/1.1"

Wireshark capture, between client and proxy

Hypertext Transfer Protocol
    POST /v1.46/exec/a84c8fa7af83d2d6fb8438e107f82fc569284db962ba8f57f0794686c5cb7068/start HTTP/1.1\r\n
    Connection: Upgrade\r\n
    Content-Type: application/json\r\n
    Upgrade: tcp\r\n

Hypertext Transfer Protocol
    HTTP/1.1 101 UPGRADED\r\n
    content-type: application/vnd.docker.multiplexed-stream\r\n
    connection: Upgrade\r\n
    upgrade: tcp\r\n

Transmission Control Protocol
    # A paquet with binary data that respect the docker.multiplexed-stream content-type

Traces

[04|h1|0|mux_h1.c:1534] H1 request fully rcvd : [F] [MSG_DONE, MSG_RPBEFORE] - "POST /v1.46/exec/a84c8fa7af83d2d6fb8438e107f82fc569284db962ba8f57f0794686c5cb7068/start HTTP/1.1" - h1c=0x7551d2ecf090(0x00000000) h1s=0x7551d2ecf7b0(0x00000410) ibuf=280@0x7551d2a02f00+48/16384 obuf=0@0+0/0
	htx=0x7551d2a07810(size=16336,data=276,used=10,wrap=NO,flags=0x00000010,extra=0,first=0,head=0,tail=9,tail_addr=276,head_addr=0,end_addr=0)
		[0] type=HTX_BLK_REQ_SL    - size=118    - addr=0     	POST /v1.46/exec/a84c8fa7af83d2d6fb8438e107f82fc569284db962ba8f57f0794686c5cb7068/start HTTP/1.1
		[1] type=HTX_BLK_HDR       - size=18     - addr=118   	host: 127.0.0.1:2375
		[2] type=HTX_BLK_HDR       - size=38     - addr=136   	user-agent: Docker-Client/27.1.1 (linux)
		[3] type=HTX_BLK_HDR       - size=16     - addr=174   	content-length: 29
		[4] type=HTX_BLK_HDR       - size=17     - addr=190   	connection: Upgrade
		[5] type=HTX_BLK_HDR       - size=28     - addr=207   	content-type: application/json
		[6] type=HTX_BLK_HDR       - size=10     - addr=235   	upgrade: tcp
		[7] type=HTX_BLK_EOH       - size=1      - addr=245   	<empty>
		[8] type=HTX_BLK_DATA      - size=29     - addr=246
		[9] type=HTX_BLK_EOM       - size=1      - addr=275   	<empty>

# snip

[04|h1|0|mux_h1.c:1739] sending request headers : [B] [MSG_RQBEFORE, MSG_RPBEFORE] - "POST /v1.46/exec/a84c8fa7af83d2d6fb8438e107f82fc569284db962ba8f57f0794686c5cb7068/start HTTP/1.1" - h1c=0x7551d2ecf680(0x00000000) h1s=0x7551d2ecf9a0(0x00000010) ibuf=0@0+0/0 obuf=0@0x7551d2a10a50+0/16384
	htx=0x7551d2a07810(size=16336,data=276,used=10,wrap=NO,flags=0x00000010,extra=0,first=0,head=0,tail=9,tail_addr=276,head_addr=0,end_addr=0)
		[0] type=HTX_BLK_REQ_SL    - size=118    - addr=0     	POST /v1.46/exec/a84c8fa7af83d2d6fb8438e107f82fc569284db962ba8f57f0794686c5cb7068/start HTTP/1.1
		[1] type=HTX_BLK_HDR       - size=18     - addr=118   	host: 127.0.0.1:2375
		[2] type=HTX_BLK_HDR       - size=38     - addr=136   	user-agent: Docker-Client/27.1.1 (linux)
		[3] type=HTX_BLK_HDR       - size=16     - addr=174   	content-length: 29
		[4] type=HTX_BLK_HDR       - size=17     - addr=190   	connection: Upgrade
		[5] type=HTX_BLK_HDR       - size=28     - addr=207   	content-type: application/json
		[6] type=HTX_BLK_HDR       - size=10     - addr=235   	upgrade: tcp
		[7] type=HTX_BLK_EOH       - size=1      - addr=245   	<empty>
		[8] type=HTX_BLK_DATA      - size=29     - addr=246
		[9] type=HTX_BLK_EOM       - size=1      - addr=275   	<empty>

# snip

[04|h1|0|mux_h1.c:1501] rcvd H1 response headers : [B] [MSG_TUNNEL, MSG_TUNNEL] - VAL=210 - "HTTP/1.1 101 UPGRADED" - h1c=0x7551d2ecf680(0x00000000) h1s=0x7551d2ecf9a0(0x00004020) ibuf=210@0x7551d2a02f00+0/16384 obuf=0@0+0/0
	htx=0x7551d2a07810(size=16336,data=201,used=9,wrap=NO,flags=0x00000000,extra=18446744073709551615,first=0,head=0,tail=8,tail_addr=201,head_addr=0,end_addr=0)
		[0] type=HTX_BLK_RES_SL    - size=43     - addr=0     	HTTP/1.1 101 UPGRADED
		[1] type=HTX_BLK_HDR       - size=53     - addr=43    	content-type: application/vnd.docker.multiplexed-stream
		[2] type=HTX_BLK_HDR       - size=17     - addr=96    	connection: Upgrade
		[3] type=HTX_BLK_HDR       - size=10     - addr=113   	upgrade: tcp
		[4] type=HTX_BLK_HDR       - size=15     - addr=123   	api-version: 1.46
		[5] type=HTX_BLK_HDR       - size=24     - addr=138   	docker-experimental: false
		[6] type=HTX_BLK_HDR       - size=11     - addr=162   	ostype: linux
		[7] type=HTX_BLK_HDR       - size=27     - addr=173   	server: Docker/27.1.1 (linux)
		[8] type=HTX_BLK_EOH       - size=1      - addr=200   	<empty>

When broken (HAproxy 3.0)

Proxy logs

dockerhttp dockerhttp/dockersocket 0/0/0/0/20 200 156 - - ---- 1/1/0/0/0 0/0 "POST /v1.46/exec/239ed0d2f465f6fca17c9d8a68e72b8b41e396d34516e910d810bbc8fbc2b160/start HTTP/1.1"

Wireshark capture, between client and proxy

Hypertext Transfer Protocol
    POST /v1.46/exec/239ed0d2f465f6fca17c9d8a68e72b8b41e396d34516e910d810bbc8fbc2b160/start HTTP/1.1\r\n
    Connection: Upgrade\r\n
    Content-Type: application/json\r\n
    Upgrade: tcp\r\n

Hypertext Transfer Protocol
    HTTP/1.1 200 OK\r\n
    content-type: application/vnd.docker.raw-stream\r\n
    connection: close\r\n

# No TCP packet with binary data

Traces

[02|h1|1|mux_h1.c:2087] H1 request fully rcvd : [F,EMB] [MSG_DONE, MSG_RPBEFORE] - req=(.fl=0x00001511 .curr_len=0 .body_len=29)  res=(.fl=0x00001404 .curr_len=0 .body_len=0) - "POST /v1.46/exec/239ed0d2f465f6fca17c9d8a68e72b8b41e396d34516e910d810bbc8fbc2b160/start HTTP/1.1" - h1c=0x7341fce5e1f0(0x00000000) conn=0x7341fce5e100(0x80000300) h1s=0x7341fce5e2e0(0x00000010) sd=0x7341fd80ec90(0x00500021) ibuf=280@0x7341fce8a530+48/16384 obuf=0@0+0/0
	htx=0x7341fce7ca00(size=16336,data=261,used=8,wrap=NO,flags=0x00000010,extra=0,first=0,head=0,tail=7,tail_addr=261,head_addr=0,end_addr=0)
		[0] type=HTX_BLK_REQ_SL    - size=114    - addr=0     	POST /v1.46/exec/239ed0d2f465f6fca17c9d8a68e72b8b41e396d34516e910d810bbc8fbc2b160/start HTTP/1.1
		[1] type=HTX_BLK_HDR       - size=18     - addr=114   	host: 127.0.0.1:2375
		[2] type=HTX_BLK_HDR       - size=38     - addr=132   	user-agent: Docker-Client/27.1.1 (linux)
		[3] type=HTX_BLK_HDR       - size=16     - addr=170   	content-length: 29
		[4] type=HTX_BLK_HDR       - size=17     - addr=186   	connection: Upgrade
		[5] type=HTX_BLK_HDR       - size=28     - addr=203   	content-type: application/json
		[6] type=HTX_BLK_EOH       - size=1      - addr=231   	<empty>
		[7] type=HTX_BLK_DATA      - size=29     - addr=232


# snip

[02|h1|1|mux_h1.c:2291] sending request headers : [B,RUN] [MSG_RQBEFORE, MSG_RPBEFORE] - req=(.fl=0x00001400 .curr_len=0 .body_len=0)  res=(.fl=0x00001404 .curr_len=0 .body_len=0) - "POST /v1.46/exec/239ed0d2f465f6fca17c9d8a68e72b8b41e396d34516e910d810bbc8fbc2b160/start HTTP/1.1" - h1c=0x7341fce5e4c0(0x80000000) conn=0x7341fce5e3d0(0x00000300) h1s=0x7341fce5e5b0(0x00100010) sd=0x7341fd80ec10(0x40400001) sc=0x7341fd515f00(0x00001c31) ibuf=0@0+0/0 obuf=0@0x7341fce85c20+0/16384
	htx=0x7341fce7ca00(size=16336,data=261,used=8,wrap=NO,flags=0x00000010,extra=0,first=0,head=0,tail=7,tail_addr=261,head_addr=0,end_addr=0)
		[0] type=HTX_BLK_REQ_SL    - size=114    - addr=0     	POST /v1.46/exec/239ed0d2f465f6fca17c9d8a68e72b8b41e396d34516e910d810bbc8fbc2b160/start HTTP/1.1
		[1] type=HTX_BLK_HDR       - size=18     - addr=114   	host: 127.0.0.1:2375
		[2] type=HTX_BLK_HDR       - size=38     - addr=132   	user-agent: Docker-Client/27.1.1 (linux)
		[3] type=HTX_BLK_HDR       - size=16     - addr=170   	content-length: 29
		[4] type=HTX_BLK_HDR       - size=17     - addr=186   	connection: Upgrade
		[5] type=HTX_BLK_HDR       - size=28     - addr=203   	content-type: application/json
		[6] type=HTX_BLK_EOH       - size=1      - addr=231   	<empty>
		[7] type=HTX_BLK_DATA      - size=29     - addr=232

# snip

[02|h1|1|mux_h1.c:2376] sending response headers : [F,RUN] [MSG_DONE, MSG_RPBEFORE] - req=(.fl=0x00001511 .curr_len=0 .body_len=29)  res=(.fl=0x00001404 .curr_len=0 .body_len=0) - "HTTP/1.1 200 OK" - h1c=0x7341fce5e1f0(0x00000000) conn=0x7341fce5e100(0x80000300) h1s=0x7341fce5e2e0(0x00100010) sd=0x7341fd80ec90(0x50404001) sc=0x7341fd515ea0(0x00001422) ibuf=0@0+0/0 obuf=0@0x7341fce8a530+0/16384
	htx=0x7341fce7ca00(size=16336,data=156,used=7,wrap=NO,flags=0x00000000,extra=18446744073709551615,first=0,head=0,tail=6,tail_addr=156,head_addr=0,end_addr=0)
		[0] type=HTX_BLK_RES_SL    - size=33     - addr=0     	HTTP/1.1 200 OK
		[1] type=HTX_BLK_HDR       - size=45     - addr=33    	content-type: application/vnd.docker.raw-stream
		[2] type=HTX_BLK_HDR       - size=15     - addr=78    	api-version: 1.46
		[3] type=HTX_BLK_HDR       - size=24     - addr=93    	docker-experimental: false
		[4] type=HTX_BLK_HDR       - size=11     - addr=117   	ostype: linux
		[5] type=HTX_BLK_HDR       - size=27     - addr=128   	server: Docker/27.1.1 (linux)
		[6] type=HTX_BLK_EOH       - size=1      - addr=155   	<empty>

How to reproduce

haproxy.cfg:

global
    log stdout len 4096 format raw daemon debug
    stats socket ipv4@:10000 level admin
    maxconn 100

defaults
    log global
    timeout connect 10s
    timeout client 10m
    timeout server 10m
    errorfile 400 /usr/local/etc/haproxy/errors/400.http
    errorfile 403 /usr/local/etc/haproxy/errors/403.http
    errorfile 408 /usr/local/etc/haproxy/errors/408.http
    errorfile 500 /usr/local/etc/haproxy/errors/500.http
    errorfile 502 /usr/local/etc/haproxy/errors/502.http
    errorfile 503 /usr/local/etc/haproxy/errors/503.http
    errorfile 504 /usr/local/etc/haproxy/errors/504.http

frontend dockerhttp
    bind :2375
    mode http
    option httplog
    default_backend dockerhttp

backend dockerhttp
    mode http
    server dockersocket /var/run/docker.sock

As an user that has access to /var/run/docker.sock (upgrade version as needed and make sure Docker daemon is up)

 docker run --rm --name haproxy \
    --user root --privileged \
    -v ./haproxy.cfg:/haproxy.cfg \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -p 127.0.0.1:2375:2375 \
    -p 127.0.0.1:10000:10000 \
    haproxy:2.2-alpine haproxy -f /haproxy.cfg

In another terminal:

export DOCKER_HOST=tcp://127.0.0.1:2375
docker exec haproxy ls

The affecting change is probably (based on the code that you found) this one:

I would suggest you file a bug at Github:

Do make sure to emphasize the docker docs regarding the connection hijacking.

edit: this has been filed as:

wow - finally someone else (that knew the right lingo) saw this :slight_smile: