Files

5.9 KiB

TLS (bolt+s://, HTTPS)

Status

Done: the configuration variables, the three Bolt TLS modes with sniffing on one port (boltage/streams.py, handle_client in boltage/bolt.py), stacked transports (raw Bolt and WebSocket over TCP or TLS), HTTPS through uvicorn, +s schemes in the discovery document when TLS is required, and the README documentation, including libpq's variables for TLS to PostgreSQL. The container images generate a self-signed certificate with BOLTAGE_TLS_SELF_SIGNED=true (container/boltage-start.sh), off by default, and the README explains mounting certificates and using Podman and compose secrets.

Still open: reloading the certificate on SIGHUP, and adding the TLS checks to the tests in the repository (they were run by hand against all three modes, and tests/smoke_test.py passes unchanged over bolt+ssc:// and neo4j+ssc://).

Problem

The Bolt port (asyncio.start_server in boltage/__main__.py:46) and the neo4j-browser HTTP server (uvicorn) only speak plain text. Drivers configured with bolt+s:// / neo4j+s://, and neo4j-browser served over HTTPS (which must use wss://, since browsers block plain ws:// from an HTTPS page), cannot connect. Credentials are sent in the clear, which matters because they are PostgreSQL passwords.

Design constraints

  • The Bolt port serves raw Bolt, Bolt over WebSocket and plain HTTP (discovery, CORS preflight) on one port, by sniffing the first four bytes (handle_client, boltage/bolt.py:349). TLS has to fit in front of that sniffing.
  • Neo4j has three modes for Bolt: TLS disabled, optional (both on one port), and required. Offering the same makes migration easy.
  • The WebSocket transport already uses a Sans-I/O protocol over the raw stream. TLS can be added the same way, with ssl.SSLObject and ssl.MemoryBIO, which avoids needing to "un-read" the sniffed bytes.

Approach

Configuration

Variable Default Purpose
BOLTAGE_TLS_CERT (unset) PEM certificate chain.
BOLTAGE_TLS_KEY (unset) PEM private key.
BOLTAGE_BOLT_TLS optional when a certificate is set, else disabled disabled, optional or required.
BOLTAGE_HTTPS true when a certificate is set Serve neo4j-browser over HTTPS on BOLTAGE_HTTP_PORT.

Fail at start-up when only one of cert/key is set, or the files cannot be loaded.

Bolt port

  1. Build an ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) at start-up, minimum TLS 1.2, and pass it in Config.
  2. In handle_client, read the first byte. 0x16 is a TLS handshake record:
    • disabled: close the connection.
    • otherwise wrap the stream in a new TlsTransport (an SSLObject over two MemoryBIOs, fed with the bytes already read), complete the handshake, then read the first four decrypted bytes and continue with the existing sniffing: MAGIC → raw Bolt over TLS, GET → WebSocket over TLS (wss://), and so on.
    • Anything else in required mode: close (for an HTTP request, first answer with a plain 400 explaining that TLS is required, so the error is understandable in a browser).
  3. Refactor the transports so they stack: RawTransport reads from the asyncio stream, TlsTransport reads from a lower transport, and WebSocketTransport reads from either. Today WebSocketTransport subclasses RawTransport and reads self.reader directly; change it to take a lower transport.
  4. Handshake errors (client does not trust the certificate, wrong protocol) are logged at DEBUG and the connection closed, like other connection errors.

An alternative is loop.start_tls() on the accepted connection, which uses asyncio's own TLS. It cannot be given the byte already read for sniffing, so it only works for required mode without sniffing. The SSLObject approach covers all three modes with one code path.

HTTP port

  1. Pass ssl_certfile / ssl_keyfile to uvicorn.Config when HTTPS is on.
  2. The discovery document (boltage/discovery.py) advertises neo4j+s:// and bolt+s:// when Bolt TLS is required, and the plain schemes otherwise. neo4j-browser picks wss:// itself when the page is served over HTTPS.
  3. Optionally redirect plain HTTP to HTTPS; this needs a second port, so leave it out at first.

Container images

  1. Mount a certificate and key and set BOLTAGE_TLS_CERT / BOLTAGE_TLS_KEY.
  2. For a quick start, let the entrypoint generate a self-signed certificate when BOLTAGE_TLS=self-signed is set (with openssl, which the full image has; check the slim image), stored in a volume so it survives restarts. Clients then use bolt+ssc:// / neo4j+ssc://, and the browser needs a one-time exception.
  3. pgAdmin in the full image has its own TLS settings; document them rather than wiring them up.

Certificate reload

Read the certificate again on SIGHUP by building a new context, used for new connections, so certificates from e.g. Let's Encrypt can be renewed without restarting. Optional; do it after the basics.

boltage to PostgreSQL already supports TLS through libpq's environment variables (PGSSLMODE, PGSSLROOTCERT, ...), since psycopg uses libpq. That is not documented; add it to the Configuration section of the README in the same change.

Tests

  • Smoke test against an instance with a self-signed certificate: python tests/smoke_test.py bolt+ssc://localhost:7687 ... runs the full suite unchanged, which covers raw Bolt over TLS.
  • required mode rejects bolt://; disabled mode rejects bolt+ssc://; optional accepts both on one port.
  • Manual check: neo4j-browser over https://localhost:7474/ connects over wss://.

README changes

Remove the TLS bullet. Add the variables to the Configuration table and a short "TLS" section: modes, client URI schemes (+s vs +ssc), the container's self-signed option, and libpq's variables for the PostgreSQL side.