Create draft plans for addressing each limitation
This commit is contained in:
1 parent
5784de249d
commit
5f879fd9a4
8 files changed
+954
-10
No files matched your search
+105
@@ -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.
|
||||
Reference in new issue
Block a user