Sven and the Art of Computer Maintenance

Sven and the Art of Computer Maintenance

11 Apr 2021

Integrating a Signal TLS Proxy in an existing site using HAProxy

Introduction

Due to censorship issues, the Signal messaging service client was given the option of using a proxy to connect to Signal’s servers. A reference implementation using NGINX with the name ‘Signal TLS Proxy’ is provided by the Signal Technology Foundation. An overview of normal Signal client-server connection:

Signal connection

And an overview of a connection through a Signal TLS Proxy:

Signal TLS Proxy connection

First the user must be made aware of the Signal TLS Proxy offered by innocentservice.example. This can be done by (ideally securely) sending a URL to the user out of band. When the user visits the URL on the device, the Signal client will interrupt the connection (no data is sent), configure the included hostname as a proxy, and test whether a connection can be made. If successful, the configuration is kept.

The Signal client on the user’s device creates a TLS in a TLS connection to innocentservice.example, which is not blocked by the government firewall. The server running NGINX is the endpoint of the outer TLS connection, but is unable to decrypt the inner TLS data. Instead, it forwards the inner TLS data as is to the relevant Signal server, based on the Server Name Indication (SNI) domain name. This domain name is part of the inner TLS connection, and cannot be read when the data passes through the censored internet since it is encapsulated in the outer TLS connection to innocentservice.example. Once the Signal server receives the data, a connection is established between the user’s Signal client and the Signal server, facilitated by the Signal TLS Proxy running on innocentservice.example.

The used web server, NGINX, is great. This site runs on it, so it must be good! Not only is NGINX a web server. As noted by Wikipedia, it can also be used as a reverse proxy, load balancer, mail proxy, and HTTP cache. This makes it quite versatile. To use NGINX as a Signal TLS Proxy, it is configured as a reverse TLS proxy.

An alternative to several functions of NGINX is HAProxy, specialized proxy software which is in some situations faster and in other situations slower compared to NGINX. Furthermore, HAProxy is available in some environments where NGINX is not. An example is pfSense, in which HAProxy is available as a package but NGINX is not.

HAProxy can also be configured as a reverse TLS proxy, to replace NGINX as a Signal TLS Proxy. It also has a function which NGINX cannot fulfill at this moment. HAProxy can split regular TLS website traffic and Signal TLS Proxy traffic. This allows integration of a Signal TLS Proxy in regular websites. An overview:

Signal TLS Proxy and site connection

The user’s device connects to innocentsite.example using TLS over TCP port 443, just like most secure web connections do. This is the outer TLS connection. It secures either a HTTP connection when the user visits https://innocentsite.example in a browser, or an inner TLS connection used by Signal.

HAProxy receives the the outer TLS connection and gains access to the data it secures. It is able to classify this data as either HTTP or (inner) TLS. The former is meant for the website, and is (internally) forwarded as is. The latter concerns data for a Signal server, and is forwarded as is, based on the SNI domain name. The phrase ‘as is’ is used a lot. Aside of being the endpoint for the outer TLS connection and examining data when a session is started, HAProxy only forwards data and does not modify it in any way.

A tricky part is that HAProxy acts as a man-in-the-middle for the web server, and in a default configuration the web server will be unable to know the user device’s IP address. This is often undesirable for web server/application functions, such as logging. HAProxy can provide the user device’s IP address without injecting HTTP headers, by using the PROXY protocol. A condition for using the PROXY protocol is that the receiving web server both supports it and is configured to receive it. It is supported by NGINX, which will be the web server of choice for this guide. Other software supporting the PROXY protocol can be found here.

Configuring HAProxy as a Signal TLS Proxy

As a first step, HAProxy will be configured as a Signal TLS Proxy.

Prepare TLS prerequisites

The outer TLS connection is like any other, as in that it needs a valid certificate to provide secure connections. How to get a (and maintain) valid certificate is out of scope of this guide. A good starting point for Arch Linux and NGINX can be found here. That guide uses Let’s Encrypt to get free TLS certificates, which is what is assumed to be used for this guide. Note that NGINX can directly be used to provide HTTP support for Let’s Encrypt’s ACME challenge using plain HTTP on TCP port 80. This does not conflict with HAProxy, which will only listen on TCP port 443 for TLS connections.

After retrieving a certificate, some preparation must be done to make it usable for HAProxy. Unlike NGINX, HAProxy is incapable of freely choosing filenames for separate certificates and private keys. The certificate and private key are allowed to be in separate files, but the filename of the private key must be based on the name of the certificate file, with an added ‘.key’ extension.

When using Certbot to get certificates from Let’s Encrypt, first see which certificates are available for which domains:

ls -al /etc/letsencrypt/live/

For the domain you want to use as a Signal TLS Proxy using HAProxy, run:

DOMAIN=innocentsite.example

ln -s privkey.pem /etc/letsencrypt/live/${DOMAIN}/fullchain.pem.key

Replace the value for DOMAIN with the primary domain name of the certificate. This will ensure that the private key’s filename is predictable to HAProxy, even when the certificate is renewed.

As recommended by Mozilla, download their Diffie-Hellman key agreement parameters:

curl https://ssl-config.mozilla.org/ffdhe2048.txt > /etc/ssl/misc/dhe2048.txt

If you do not trust the parameters of HAProxy itself or those provided by Mozilla (which is justified), generate your own:

openssl dhparam -out /etc/ssl/misc/dhe2048.txt 2048

Configure HAProxy

Now with a proper certificate, it is time to configure HAProxy. Replace DOMAIN and run:

DOMAIN=innocentsite.example
CERT=/etc/letsencrypt/live/${DOMAIN}/fullchain.pem
DH_PARAM=/etc/ssl/misc/dhe2048.txt
LISTEN=0.0.0.0:443
BIND_OPTIONS=""

RELAY_LISTEN=unix@/run/haproxy-relay.sock

cat << EOF > /etc/haproxy/haproxy.cfg
global
  ulimit-n 524288

  # TLS configuration based on: https://ssl-config.mozilla.org/#server=haproxy&version=2.1&config=intermediate&openssl=1.1.1d&guideline=5.6
  ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
  ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
  ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets

  ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
  ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
  ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets

  ssl-dh-param-file ${DH_PARAM}


defaults
  timeout client 60s
  timeout server 60s
  timeout connect 5s


# Stage 1 - Terminate
# Terminate (outer) TLS connections
frontend ft-haproxy-terminate
  bind ${LISTEN} ssl crt ${CERT} ${BIND_OPTIONS}
  default_backend bk-haproxy-terminate

backend bk-haproxy-terminate
  server srv-haproxy-relay ${RELAY_LISTEN} send-proxy-v2


# Stage 2 - Relay
# Filter (inner) TLS and HTTP/1.1 sessions
frontend ft-haproxy-relay
  bind ${RELAY_LISTEN} accept-proxy
  # Accept TLS data even if this frontend is not listening for a TLS connection
  tcp-request inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }
  default_backend bk-haproxy-relay-signal
 
backend bk-haproxy-relay-signal
  # Disable load balancing, only allow explicit traffic to the Signal servers
  default-server weight 0
  # Signal (TLS) connection settings
  # Based on: https://github.com/signalapp/Signal-TLS-Proxy/blob/master/data/nginx-relay/nginx.conf
  server signal-service textsecure-service.whispersystems.org:443
  use-server signal-service if { req_ssl_sni -i textsecure-service.whispersystems.org }
  server storage-service storage.signal.org:443
  use-server storage-service if { req_ssl_sni -i storage.signal.org }
  server signal-cdn cdn.signal.org:443
  use-server signal-cdn if { req_ssl_sni -i cdn.signal.org }
  server signal-cdn2 cdn2.signal.org:443
  use-server signal-cdn2 if { req_ssl_sni -i cdn2.signal.org }
  server directory api.directory.signal.org:443
  use-server directory if { req_ssl_sni -i api.directory.signal.org }
  server content-proxy contentproxy.signal.org:443
  use-server content-proxy if { req_ssl_sni -i contentproxy.signal.org }
  server backup api.backup.signal.org:443
  use-server backup if { req_ssl_sni -i api.backup.signal.org }
  server sfu sfu.voip.signal.org:443
  use-server sfu if { req_ssl_sni -i sfu.voip.signal.org }
  server updates updates.signal.org:443
  use-server updates if { req_ssl_sni -i updates.signal.org }
  server updates2 updates2.signal.org:443
  use-server updates2 if { req_ssl_sni -i updates2.signal.org }
EOF

If this HAProxy instance is located behind another proxy (HAProxy or otherwise) that supports PROXY protocol, it might be necessary/useful to add the accept-proxy option to BIND_OPTIONS. Of course, the upstream proxy must be configured to offer the PROXY protocol. Only add accept-proxy to BIND_OPTIONS if it is explicitly required.

A Unix domain socket is used to pass traffic from the first HAProxy stage to the second. If you use an environment that is not based on *nix to run HAProxy in, you might need to replace the Unix domain socket in LISTEN_RELAY with a locally listening IP address and TCP port (e.g. 127.0.0.1:12345).

Ensure that all other standard web and network configuration (DNS, firewalls, NAT port forwardings, …) are configured to allow a connection to HAProxy on TCP port 443 using the provided domain name on the internet. After that, (re)start HAProxy (e.g. by running systemctl restart haproxy).

Test the Signal TLS Proxy

Open this URL on a smartphone with Signal installed: https://signal.tube/#innocentsite.example

Of course, replace innocentsite.example with the proper (sub)domain name on which HAProxy is available.

After the URL is opened, Signal will ask whether you want to configure the (sub)domain as a proxy server. Accept. After this, Signal will report the status of the proxy server. It should report ‘Connected to proxy’. If it does, your HAProxy-powered Signal TLS Proxy is operational.

Configuring HAProxy to separate site and Signal sessions

Separating inner TLS traffic and plain HTTP traffic cannot be done in a single step if HTTP/2 is supported and the (outer) TLS terminating server is not the same as the web server (as is the case here).

An extension of modern TLS implementations is Application-Layer Protocol Negotiation (ALPN), with which a server advertises which application layer protocols are supported. Once a server offers HTTP/2 connections through ALPN, the client uses ALPN to let the server know whether a HTTP/2 session will be established. Based on this, the server knows which HTTP protocol version must be used.

HAProxy and the web server are separate from each other. HAProxy terminates the TLS session, and therefore performs ALPN with the client. The web server will only have a plain text HTTP connection with HAProxy, and therefore cannot know which protocol to expect since ALPN is not performed without TLS. If the web server expects an HTTP/1.1 session and the client initiates an HTTP/2 connection based on what HAProxy advertises (or vice versa), the connection will fail due to that these protocols are incompatible with each other.

A possible solution is to let HAProxy start the communication as HTTP/1.1, and inject the HTTP Upgrade header with the value ‘h2c’. This will let the web server upgrade an existing cleartext connection to HTTP/2. Unfortunately, this approach has several issues:

A practical and more universal solution is to let HAProxy separate HTTP/2 and HTTP/1.1 traffic based on what the client offers with ALPN, and have the web server listen explicitly for HTTP/1.1 and HTTP/2 traffic on separate sockets:

HAProxy and web server

Web server traffic does not conflict with Signal traffic. The latter concerns the encrypted (‘inner’) TLS session of the Signal TLS Proxy, and its traffic is therefore easy to separate.

The HAProxy configuration can be created using:

DOMAIN=innocentsite.example
CERT=/etc/letsencrypt/live/${DOMAIN}/fullchain.pem
DH_PARAM=/etc/ssl/misc/dhe2048.txt
LISTEN=0.0.0.0:443
BIND_OPTIONS="alpn h2,http/1.1"

RELAY_LISTEN=unix@/run/haproxy-relay.sock

SITE_ADDRESS=unix@/run/nginx-site.sock
SITE_ADDRESS_HTTP2=unix@/run/nginx-site-http2.sock
SITE_OPTIONS="send-proxy-v2"

cat << EOF > /etc/haproxy/haproxy.cfg
global
  ulimit-n 524288

  # TLS configuration based on: https://ssl-config.mozilla.org/#server=haproxy&version=2.1&config=intermediate&openssl=1.1.1d&guideline=5.6
  ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
  ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
  ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets

  ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
  ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
  ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets

  ssl-dh-param-file ${DH_PARAM}


defaults
  timeout client 60s
  timeout server 60s
  timeout connect 5s


# Stage 1 - Terminate
# Terminate (outer) TLS connections, and filter HTTP/2 sessions
frontend ft-haproxy-terminate
  bind ${LISTEN} ssl crt ${CERT} ${BIND_OPTIONS}
  # Forward HTTP/2 sessions directly to the proper backend
  use_backend bk-haproxy-relay-site-http2 if { ssl_fc_alpn -i h2 }
  default_backend bk-haproxy-terminate

backend bk-haproxy-terminate
  server srv-haproxy-relay ${LISTEN_RELAY} send-proxy-v2


# Stage 2 - Relay
# Filter (inner) TLS and HTTP/1.1 sessions
frontend ft-haproxy-relay
  bind ${LISTEN_RELAY} accept-proxy
  # Accept TLS connections even if this frontend is not listening for them
  tcp-request inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }
  # Forward TLS and HTTP/1.1 sessions to the proper backends
  use_backend bk-haproxy-relay-signal if { req_ssl_hello_type 1 }
  default_backend bk-haproxy-relay-site

# Site (HTTP/1.1 and HTTP/2) connection settings
backend bk-haproxy-relay-site
  server site ${SITE_ADDRESS} ${SITE_OPTIONS}
backend bk-haproxy-relay-site-http2
  server site ${SITE_ADDRESS_HTTP2} ${SITE_OPTIONS}

backend bk-haproxy-relay-signal
  # Disable load balancing, only allow explicit traffic to the Signal servers
  default-server weight 0
  # Signal (TLS) connection settings
  # Based on: https://github.com/signalapp/Signal-TLS-Proxy/blob/master/data/nginx-relay/nginx.conf
  server signal-service textsecure-service.whispersystems.org:443
  use-server signal-service if { req_ssl_sni -i textsecure-service.whispersystems.org }
  server storage-service storage.signal.org:443
  use-server storage-service if { req_ssl_sni -i storage.signal.org }
  server signal-cdn cdn.signal.org:443
  use-server signal-cdn if { req_ssl_sni -i cdn.signal.org }
  server signal-cdn2 cdn2.signal.org:443
  use-server signal-cdn2 if { req_ssl_sni -i cdn2.signal.org }
  server directory api.directory.signal.org:443
  use-server directory if { req_ssl_sni -i api.directory.signal.org }
  server content-proxy contentproxy.signal.org:443
  use-server content-proxy if { req_ssl_sni -i contentproxy.signal.org }
  server backup api.backup.signal.org:443
  use-server backup if { req_ssl_sni -i api.backup.signal.org }
  server sfu sfu.voip.signal.org:443
  use-server sfu if { req_ssl_sni -i sfu.voip.signal.org }
  server updates updates.signal.org:443
  use-server updates if { req_ssl_sni -i updates.signal.org }
  server updates2 updates2.signal.org:443
  use-server updates2 if { req_ssl_sni -i updates2.signal.org }
EOF

Some things you might want to change:

  • As mentioned earlier, add accept-proxy to BIND_OPTIONS if HAProxy is located downstream from another proxy that is configured to use the PROXY protocol. This allows HAProxy to log client IP addresses instead of the upstream proxy IP address.
  • The chosen web server addresses in the SITE_ADDRESS variables are UNIX socket file locations. These will either be configured in the next section for NGINX to listen on, or they can point to any other HTTP server. Either use UNIX domain sockets or standard host+port notation (e.g. 127.0.0.1:80 and 127.0.0.1:81 for HTTP/1.1 and HTTP/2 respectively). These values should not be equal. If they are, traffic with different HTTP versions cannot be separated, resulting in errors.
  • If the web server is not configured to use the PROXY protocol, send-proxy-v2 option in SITE_OPTIONS must be removed.

Restart HAProxy. The Signal proxy should still work. Test the connection.

Configuring nginx to receive HAProxy connections

There are many ways to configure your web server. This section notes how to configure NGINX to receive separate HTTP/1.1 and HTTP/2 streams, and gives some pointers if you proxy connections further downstream from NGINX (e.g. for PHP support). Anything else, such as offering secure HTTP headers (including HSTS support), is outside the scope of this guide.

An example of /etc/nginx/nginx.conf:

events {
    worker_connections  1024;
}

http {
  server {
    # Listen on two sockets, for HTTP/1.1 and HTTP/2 respectively
    listen unix:/run/nginx-site.sock proxy_protocol;
    listen unix:/run/nginx-site-http2.sock proxy_protocol http2;
    set_real_ip_from unix:;
    real_ip_header proxy_protocol;
	
    server_name innocentsite.example;

    root /srv/http/;

    # Some kind of proxied web application which needs client connection info.
    location ^~ /myapp/ {
      # Downstream server
      proxy_pass http://mybackend;

      # Use the PROXY protocol to connect to the downstream server
      # The PROXY protocol forwards the client's IP address to the server
      # Note that the downstream server must explicitly support this, and
      # have it enabled in its configuration.
      #
      # This option is recommended for backend web servers.
      proxy_protocol on;

      # De facto standard and RFC 7239 implementation, which provides data in
      # HTTP headers:
      # - Sets the client's IP address based on upstream PROXY protocol
      # - Sets 'https' as protocol due to the use of an upstream TLS proxy
      # - Sets request host based on the client's HTTP request
      #
      # This option is recommended for interpreters, such as PHP.
      proxy_set_header X-Forwarded-For $proxy_protocol_addr;
      proxy_set_header X-Forwarded-Proto https;
      proxy_set_header X-Forwarded-Host $host;
      proxy_set_header Forwarded "for=$proxy_protocol_addr;proto=https;host=$host";

      # Inform Microsoft load balancers and applications of an upstream TLS proxy
      #
      # Only use this with Microsoft products (SharePoint, Outlook Web Access, ...).
      proxy_set_header Front-End-Https on;
    }
  }
}

Change server_name to the relevant domain name of the web server.

The proxied ‘myapp’ web application is a placeholder showing which options might be relevant. Part of the configuration can for example be integrated in a location that forwards data to PHP-FPM. Then, PHP applications will be notified by injected HTTP headers of the client’s IP address and the used URL, including the used protocol. Only use options that are relevant for the used backend.

Concluding remarks

Integrating a Signal TLS Proxy in an existing website brings some advantages. It leverages existing infrastructure to bypass censorship with minimal effort, which is preferable to configuring an entire separate (sub)domain and supporting infrastructure. It also somewhat masks the Signal traffic since a legitimate site is hosted on the used web address.

Note that all Signal traffic will go through the proxy once configured on a client, including file up- and downloads. In terms of resources, expect to use more bandwidth on the used internet connection. HAProxy does not require much CPU cycles or RAM capacity. Smaller employments should be possible on almost any platform, from the smallest Linux-running Raspberry Pi to the largest server cluster. The biggest impact on how many users can be facilitated is probably the CPU, which needs to perform TLS termination for both the Signal TLS Proxy and the hosted site. For smaller user bases, this should hardly be noticeable with modern processors.