envoy, docker and websockets – debugging and configuration
Websockets are an exciting technology, allowing you to upgrade a HTTP connection to a long-running persistent binary connection, which you can use to send bi-directional messages.
As an aside, the MQTT protocol can be transported using websockets – which is the only (?) way for a JavaScript client delivered by the website, for instance.
In any case, as websockets run on the same ports as the normal HTTP & HTTPS traffic (80 / 443), corporate networks are more likely to let them pass.
Envoy and websockets
Envoy supports websockets. There are some gotchas:
Unable to parse JSON as proto (INVALID_ARGUMENT:(route_config.virtual_hosts[3].routes[0].route) use_websocket: Cannot find field.):
Envoy used to support websockets with an old directive, “use_websocket”. On a current envoy installation (e.g. I currently use envoy 1.10.0-dev for testing purposes) this directive is gone and it has been replaced.
The idea is to have more generic upgrade requests / possibilities.
The correct syntax is now using upgrade_configs. Have a look at my envoy.yaml:
static_resources:
listeners:
– address:
socket_address:
address: 0.0.0.0
port_value: 80
filter_chains:
– filters:
– name: envoy.http_connection_manager
config:
upgrade_configs:
– upgrade_type: websocket
codec_type: auto
stat_prefix: ingress_http
use_remote_address: true
xff_num_trusted_hops: 0
route_config:
virtual_hosts:
– name: debug
domains: [“debug.local:80”]
routes:
– match: { prefix: “/” }
route:
cluster: target_dwebsocket
– name: backend
domains: [“morpheus.local”]
routes:
– match: { prefix: “/” }
redirect:
path_redirect: “/”
https_redirect: true(snip)
– address:
socket_address:
address: 0.0.0.0
port_value: 443
filter_chains:
– tls_context:
common_tls_context:
tls_certificates:
– certificate_chain: { filename: “/etc/envoy/example.crt” }
private_key: { filename: “/etc/envoy/example.key” }
alpn_protocols: [ “h2,http/1.1” ]
filters:
– name: envoy.http_connection_manager
config:
upgrade_configs:
– upgrade_type: websocket
stat_prefix: ingress_https
use_remote_address: true
xff_num_trusted_hops: 0
route_config:
virtual_hosts:
– name: debug
domains: [“debug.local:443”]
routes:
– match: { prefix: “/” }
route:
cluster: target_dwebsocket
(snip)clusters:
– name: target_dwebsocket
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
hosts:
– socket_address:
address: dwse
port_value: 8080
You can use this upgrade_configs directive in two places, according to the documentation. on http_connection_manager (as in my example above), and on individual routes.
upgrade_configs:
– upgrade_type: websocket
Routes not matching
The other important thing to note, if you get “404s” ( error: Unexpected server response: 404 ): the :authority header will be, for some strange reason, set including the port – and will therefore not match if you supply just the domain.
Look at this route:
[“debug.local:80”]
The port is specified after the domain in this instance. In my tests – only if the port was specified, was I able to connect through envoy to the websocket server I have set up. This is a major stumbling block.
Next up, I will discuss, how to debug envoy in cases like this.
Debugging envoy, websockets, Docker:
Making envoy output verbose
Here’s my docker-compose.yaml:
version: ‘3.7’
services:
envoy:
build:
context: ./
dockerfile: Dockerfile
container_name: penvoyage-morpheus-envoy
ports:
– “80:80”
– “443:443”
volumes:
– type: volume
source: penvoyage_volume
target: /etc/envoy
networks:
– envoy_net
#user: “2000:2000”
user: “root:root”
restart: unless-stopped
environment:
loglevel: debugvolumes:
penvoyage_volume:
external:
name: penvoyage_volumenetworks:
envoy_net:
external:
name: my-bridge-network
Note that an environment variable “loglevel: debug” is set.
loglevel can be one of:
- trace
- debug
- info
- warn
Starting the container will now yield a lot more output:
docker-compose up
Note that we do not deattach from the container, to directly see it’s output. (Once you’re satisfied, you can run it using docker-compose up –d)
Your output should now look something like this:
penvoyage-morpheus-envoy | [2019-05-18 14:20:32.093][1][debug][main] [source/server/server.cc:143] flushing stats
on a connection from a websocket client, you will get the following output:
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.915][23][debug][main] [source/server/connection_handler_impl.cc:257] [C0] new connection
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.916][23][debug][http] [source/common/http/conn_manager_impl.cc:210] [C0] new stream
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.918][23][debug][http] [source/common/http/conn_manager_impl.cc:548] [C0][S4222457489265630919] request headers complete (end_stream=false):
penvoyage-morpheus-envoy | ‘:authority’, ‘debug.local:80’
penvoyage-morpheus-envoy | ‘:path’, ‘/mqtt_drinks_are_free’
penvoyage-morpheus-envoy | ‘:method’, ‘GET’
penvoyage-morpheus-envoy | ‘sec-websocket-version’, ’13’
penvoyage-morpheus-envoy | ‘sec-websocket-key’, ‘HQFiCTWiFMktGDPFXwzrjQ==’
penvoyage-morpheus-envoy | ‘connection’, ‘Upgrade’
penvoyage-morpheus-envoy | ‘upgrade’, ‘websocket’
penvoyage-morpheus-envoy | ‘sec-websocket-extensions’, ‘permessage-deflate; client_max_window_bits’
penvoyage-morpheus-envoy |
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.918][23][debug][router] [source/common/router/router.cc:321] [C0][S4222457489265630919] cluster ‘target_dwebsocket’ match for URL ‘/mqtt_drinks_are_free’
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.918][23][debug][router] [source/common/router/router.cc:379] [C0][S4222457489265630919] router decoding headers:
penvoyage-morpheus-envoy | ‘:authority’, ‘debug.local:80’
penvoyage-morpheus-envoy | ‘:path’, ‘/mqtt_drinks_are_free’
penvoyage-morpheus-envoy | ‘:method’, ‘GET’
penvoyage-morpheus-envoy | ‘:scheme’, ‘http’
penvoyage-morpheus-envoy | ‘sec-websocket-version’, ’13’
penvoyage-morpheus-envoy | ‘sec-websocket-key’, ‘HQFiCTWiFMktGDPFXwzrjQ==’
penvoyage-morpheus-envoy | ‘connection’, ‘Upgrade’
penvoyage-morpheus-envoy | ‘upgrade’, ‘websocket’
penvoyage-morpheus-envoy | ‘sec-websocket-extensions’, ‘permessage-deflate; client_max_window_bits’
penvoyage-morpheus-envoy | ‘content-length’, ‘0’
penvoyage-morpheus-envoy | ‘x-forwarded-for’, ‘192.168.1.2’
penvoyage-morpheus-envoy | ‘x-forwarded-proto’, ‘http’
penvoyage-morpheus-envoy | ‘x-envoy-internal’, ‘true’
penvoyage-morpheus-envoy | ‘x-request-id’, ‘ca8a765c-e549-4c45-988c-58b6c853df7b’
penvoyage-morpheus-envoy | ‘x-envoy-expected-rq-timeout-ms’, ‘15000’
penvoyage-morpheus-envoy |
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.918][23][debug][pool] [source/common/http/http1/conn_pool.cc:82] creating a new connection
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][client] [source/common/http/codec_client.cc:26] [C1] connecting
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][connection] [source/common/network/connection_impl.cc:638] [C1] connecting to 172.18.0.8:8080
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][connection] [source/common/network/connection_impl.cc:647] [C1] connection in progress
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][pool] [source/common/http/conn_pool_base.cc:20] queueing request due to no available connections
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][connection] [source/common/network/connection_impl.cc:516] [C1] connected
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][client] [source/common/http/codec_client.cc:64] [C1] connected
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][pool] [source/common/http/http1/conn_pool.cc:236] [C1] attaching to next request
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.919][23][debug][router] [source/common/router/router.cc:1122] [C0][S4222457489265630919] pool ready
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.920][23][debug][router] [source/common/router/router.cc:669] [C0][S4222457489265630919] upstream headers complete: end_stream=false
penvoyage-morpheus-envoy | [2019-05-18 14:21:14.920][23][debug][http] [source/common/http/conn_manager_impl.cc:1234] [C0][S4222457489265630919] encoding headers via codec (end_stream=false):
penvoyage-morpheus-envoy | ‘:status’, ‘101’
penvoyage-morpheus-envoy | ‘upgrade’, ‘websocket’
penvoyage-morpheus-envoy | ‘connection’, ‘Upgrade’
penvoyage-morpheus-envoy | ‘sec-websocket-accept’, ‘2W9caJQU0JKW3MhWV6T8FHychjk=’
penvoyage-morpheus-envoy | ‘date’, ‘Sat, 18 May 2019 14:21:14 GMT’
penvoyage-morpheus-envoy | ‘server’, ‘envoy’
penvoyage-morpheus-envoy | ‘content-length’, ‘0’
penvoyage-morpheus-envoy |
Note the :authority header as I mentioned above:
‘:authority’, ‘debug.local:80’
Here’s an authority header for a normal website request:
penvoyage-morpheus-envoy | ‘:method’, ‘GET’
penvoyage-morpheus-envoy | ‘:authority’, ‘morpheus.local’
penvoyage-morpheus-envoy | ‘:scheme’, ‘https’
Notice the presence of the port in the first case (request via websocket), and the absence of the port in the second case.
Testcontainer
Please note, that https://hub.docker.com/r/dataferret/websocket-echo the dataferret/websocket-echo image seems to be broken!
There seems to be some incompatibility in twisted / Autobahn for the particular version which was used, and I therefore decided to just roll my own:
We’re going to build a test container which will echo the input which we send to it as a websocket.
Here’s the Dockerfile:
FROM python:stretch
COPY assets/websocketd /usr/bin/websocketd
RUN chmod +x /usr/bin/websocketd
COPY assets/run.py /opt/run.py
WORKDIR /optEXPOSE 8080
ENTRYPOINT [“websocketd”, “–port=8080”, “python3”, “/opt/run.py”]
Note that using ENTRYPOINT, when the container is started with CMD from docker-compose.yml, the CMD value will be passed as a parameter to the ENTRYPOINT command.
websocketd is a websocket daemon, you can obtain the standalone executable from here:
I downloaded https://github.com/joewalnes/websocketd/releases/download/v0.3.0/websocketd-0.3.0-linux_amd64.zip and unzipped it into the assets folder I created for my docker container.
The general idea of this nifty application is to provide a wrapper around your application to be able to serve up websockets.
All your application has to do is listen on stdin, and write the responses / any communication on stdout.
run.py – this is a simple script which will echo everything which is written to it:
#!/usr/bin/python
import syswhile True:
read_co = sys.stdin.readline()
print(read_co, end=””)
sys.stdout.flush()
Notice the usage of readline(), which will return the string immediately upon a newline. Otherwise you have to deal with buffers / wait for the input to accumulate.
Also notice the sys.stdout.flush()
Finally, here’s the docker-compose.yml:
version: ‘3.6’
services:
dwse:
build:
context: ./
dockerfile: Dockerfile
container_name: dwse
hostname: dwse
ports:
– “8078:8080”
networks:
– envoy_net
restart: always
command: “80”
#user: “root:root”networks:
envoy_net:
external:
name: my-bridge-network
Notice that I put it in the same network “my-bridge-network” as envoy above.
the command here is a random “80” which is not interpreted by the application, but you could create more elaborate command lines, etc. using this.
debugging
Finally, we want a client to connect and be able to see the messages we send to our test websocket service, which is proxied through envoy.
I suggest wscat for this.
wscat is an npm (node.js) package. Therefore you have to install npm first:
apt install npm
Then you can install wscat:
npm install -g wscat
Test wscat with a public websocket echo service:
wscat -c ws://echo.websocket.org
get a list of command line options for wscat:
wscat –help
To check a secure websocket connection through envoy with self-signed certificates do:
wscat -c ws://debug.local/mqtt_drinks_are_free –no-check
Hints
Unexpected server response
error: Unexpected server response: 404
Have a look at your route: do you put the port in the domain to match, as discussed above? Apparently it seems to be necessary for websocket clients.
When in doubt, include a match all domain at the end of your virtual hosts, to make debugging easier. I suggest to set a fixed path matching on this, instead of prefix – by changing the path you use in wscat, this way, you can verify which rule was matched:
– name: matcheverything
domains: [“*”]
routes:
– match: { path: “/mqtt” }
route: { cluster: target_dwebsocket }
Unable to parse JSON / cannot find field:
Unable to parse JSON as proto (INVALID_ARGUMENT:(route_config.virtual_hosts[3].routes[0].route) use_websocket: Cannot find field.):
As discussed above, this is a legacy style of configuration which has been superseded by the new configuration:
upgrade_configs:
– upgrade_type: websocket
no cluster match for URL
[source/common/router/router.cc:278] [C0][S5067510418337690596] no cluster match for URL ‘/’
See above – the URL is matched including the port for Websockets apparently ( in my tests), therefore include a rule to match all domains, and debug from there / use the syntax I have provided above:
route_config:
virtual_hosts:
– name: debug
domains: [“debug.local:80”]
routes:
– match: { prefix: “/” }
route:
cluster: target_dwebsocket
self signed certificate
error: self signed certificate
Run wscat with the –no-check command line parameter (this is a double dash which WordPress will mess up):
Websocket connection failed:
pcp-code.js:15 WebSocket connection to ‘wss://key:[email protected]/mqtt’ failed: Error during WebSocket handshake: Unexpected response code: 403
See information above: most likely, this javascript error message is due to a misconfiguration of the domain match (see above).
autobahn error:
“To use txaio, you must first select a framework ” exceptions.RuntimeError: To use txaio, you must first select a framework with .use_twisted() or .use_txaio()
This is the error I got when running the dataferret/websocket-echo image. I suggest to use my code which I provided above as an alternative.
References:
- https://hub.docker.com/r/dataferret/websocket-echo does not work for me! I have provided alternative code in this blogpost for a simple Docker websocket echo container
- https://github.com/vi/websocat an alternative to wscat
- https://github.com/websockets/wscat
- https://github.com/erebe/wstunnel allows you to tunnel connections over websockets, including wireguard – interesting stuff!
- ws://echo.websocket.org – echo service on a websocket
- http://websocketd.com/ takes care of handling the WebSocket connections, and launching your applications to handle the WebSockets (isolated from each other)
- https://github.com/envoyproxy/envoy/issues/3301 here the new style configuration upgrade is discussed
- https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/websocket.html – official envoy documentation
- https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/route/route.proto#envoy-api-field-route-routeaction-upgrade-configs
- https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/route/route.proto#envoy-api-msg-route-routeaction-upgradeconfig
- https://github.com/envoyproxy/envoy/issues/4740 – I saw the correct way to configure here, and am elaborating on it
- https://www.envoyproxy.io/docs/envoy/latest/operations/cli envoy command line information