Create draft plans for addressing each limitation

This commit is contained in:
Bart Broere committed 2026-11-03 07:54:11 +01:00
1 parent 5784de249d
commit 5f879fd9a4
8 files changed
+954 -10

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
# TLS (`bolt+s://`, HTTPS)
## 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
`MemoryBIO`s, 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.
## 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.
- `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.