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.SSLObjectandssl.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
- Build an
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)at start-up, minimum TLS 1.2, and pass it inConfig. - In
handle_client, read the first byte.0x16is a TLS handshake record:disabled: close the connection.- otherwise wrap the stream in a new
TlsTransport(anSSLObjectover twoMemoryBIOs, 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
requiredmode: close (for an HTTP request, first answer with a plain400explaining that TLS is required, so the error is understandable in a browser).
- Refactor the transports so they stack:
RawTransportreads from the asyncio stream,TlsTransportreads from a lower transport, andWebSocketTransportreads from either. TodayWebSocketTransportsubclassesRawTransportand readsself.readerdirectly; change it to take a lower transport. - Handshake errors (client does not trust the certificate, wrong protocol) are
logged at
DEBUGand 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
- Pass
ssl_certfile/ssl_keyfiletouvicorn.Configwhen HTTPS is on. - The discovery document (
boltage/discovery.py) advertisesneo4j+s://andbolt+s://when Bolt TLS isrequired, and the plain schemes otherwise. neo4j-browser pickswss://itself when the page is served over HTTPS. - Optionally redirect plain HTTP to HTTPS; this needs a second port, so leave it out at first.
Container images
- Mount a certificate and key and set
BOLTAGE_TLS_CERT/BOLTAGE_TLS_KEY. - For a quick start, let the entrypoint generate a self-signed certificate when
BOLTAGE_TLS=self-signedis set (withopenssl, which the full image has; check the slim image), stored in a volume so it survives restarts. Clients then usebolt+ssc:///neo4j+ssc://, and the browser needs a one-time exception. - 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.
Related: TLS to PostgreSQL
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. requiredmode rejectsbolt://;disabledmode rejectsbolt+ssc://;optionalaccepts both on one port.- Manual check: neo4j-browser over
https://localhost:7474/connects overwss://.
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.