diff --git a/Containerfile b/Containerfile index 8cc7fbc..839a66a 100644 --- a/Containerfile +++ b/Containerfile @@ -53,7 +53,7 @@ RUN python3 -m build --sdist --outdir /dist /src \ FROM ${SLIM_BASE_IMAGE} AS slim RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 python3-psycopg python3-venv \ + && apt-get install -y --no-install-recommends openssl python3 python3-psycopg python3-venv \ && rm -rf /var/lib/apt/lists/* # boltage itself, using Debian's psycopg and the other dependencies from PyPI. License @@ -70,8 +70,15 @@ COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/boltage/ COPY --from=browser /src/LICENSE /src/NOTICE.txt /src/LICENSES.txt /neo4j-browser-source.tar.gz \ /usr/share/doc/boltage/neo4j-browser/ COPY --from=browser /src/dist /opt/boltage/browser +COPY container/boltage-start.sh /usr/local/bin/boltage-start.sh -RUN useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin boltage +# uid and gid 999, the same as the postgres user that runs boltage in the full image, +# so mounted certificates need the same permissions in both. /var/lib/boltage holds +# the self-signed certificate, when there is one. +RUN groupadd --system --gid 999 boltage \ + && useradd --system --uid 999 --gid boltage --no-create-home --home-dir /nonexistent \ + --shell /usr/sbin/nologin boltage \ + && install -d -o boltage -g boltage -m 0700 /var/lib/boltage USER boltage ENV BOLTAGE_BROWSER_DIR=/opt/boltage/browser \ @@ -81,7 +88,7 @@ ENV BOLTAGE_BROWSER_DIR=/opt/boltage/browser \ # 7474: neo4j-browser (HTTP), 7687: Bolt EXPOSE 7474 7687 -CMD ["/opt/boltage/venv/bin/boltage"] +CMD ["/usr/local/bin/boltage-start.sh"] FROM ${AGE_IMAGE} AS full @@ -124,7 +131,8 @@ ADD https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/LICENSE \ COPY --from=browser /src/dist /opt/boltage/browser COPY container/initdb/ /docker-entrypoint-initdb.d/ COPY container/pgadmin/config_distro.py /opt/boltage/pgadmin/config_distro.py -COPY container/boltage-entrypoint.sh /usr/local/bin/boltage-entrypoint.sh +COPY container/boltage-entrypoint.sh container/boltage-start.sh /usr/local/bin/ +RUN install -d -o postgres -g postgres -m 0700 /var/lib/boltage ENV BOLTAGE_DEFAULT_GRAPH=neo4j \ BOLTAGE_BROWSER_DIR=/opt/boltage/browser \ diff --git a/README.md b/README.md index 9fcab91..0c2fd8a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,38 @@ boltage is configured through environment variables: | `BOLTAGE_ADVERTISED_BOLT_PORT` | `$BOLTAGE_BOLT_PORT` | Bolt port advertised to the browser; set it when clients reach the Bolt port on another port. | | `BOLTAGE_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. | | `BOLTAGE_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. | +| `BOLTAGE_TLS_CERT` | (unset) | PEM certificate (chain) for TLS, see [TLS](#tls). | +| `BOLTAGE_TLS_KEY` | (unset) | PEM private key of that certificate. | +| `BOLTAGE_BOLT_TLS` | `optional` with a certificate, else `disabled` | TLS on the Bolt port: `disabled`, `optional` or `required`. | +| `BOLTAGE_HTTPS` | `true` with a certificate, else `false` | Serve neo4j-browser over HTTPS instead of HTTP. | + +### TLS + +With `BOLTAGE_TLS_CERT` and `BOLTAGE_TLS_KEY` set, boltage accepts TLS on the Bolt +port and serves neo4j-browser over HTTPS: + +```sh +BOLTAGE_TLS_CERT=/etc/boltage/cert.pem BOLTAGE_TLS_KEY=/etc/boltage/key.pem boltage +``` + +`BOLTAGE_BOLT_TLS` works like Neo4j's TLS level for Bolt. `optional`, the default +when a certificate is set, accepts TLS and plain connections on the same port, so +`bolt://` and `bolt+s://` both work. `required` only accepts TLS and makes the +discovery document advertise `neo4j+s://`. `disabled` ignores the certificate for +Bolt but still uses it for HTTPS unless `BOLTAGE_HTTPS=false`. + +Clients connect with `bolt+s://` or `neo4j+s://` when they trust the certificate, and +with `bolt+ssc://` or `neo4j+ssc://` for a self-signed one. neo4j-browser served over +HTTPS connects to the Bolt port over a secure WebSocket (`wss://`), which needs the +Bolt port to accept TLS and the browser to trust its certificate; with a self-signed +certificate, open `https://localhost:7687/` once and accept it. The certificate is +read at start-up, so restart boltage after renewing it. For the container images, +see [TLS in the containers](#tls-in-the-containers). + +The connection from boltage to PostgreSQL uses libpq, which takes its TLS settings +from [its environment +variables](https://www.postgresql.org/docs/current/libpq-envars.html), such as +`PGSSLMODE=verify-full` and `PGSSLROOTCERT`. ## How it works @@ -375,6 +407,98 @@ is initialised, and pgAdmin comes with the server already registered as "boltage in `.env` too. Data is kept in the `postgres-data` and `pgadmin-data` volumes; `podman compose down -v` removes them. +### TLS in the containers + +Both images serve plain Bolt and HTTP unless you turn on [TLS](#tls), either with a +self-signed certificate that the container generates, or with your own certificate. +In both cases the Bolt port keeps accepting plain connections as well, unless you +also set `BOLTAGE_BOLT_TLS=required`, and neo4j-browser moves to +; set `BOLTAGE_HTTPS=false` to keep serving it over HTTP. + +#### A self-signed certificate + +Set `BOLTAGE_TLS_SELF_SIGNED=true`, and mount a volume on `/var/lib/boltage` to keep +the certificate when the container is re-created: + +```sh +podman run -d --name boltage -e POSTGRES_PASSWORD=secret -e BOLTAGE_TLS_SELF_SIGNED=true \ + -p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \ + -v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin \ + -v boltage-tls:/var/lib/boltage boltage +``` + +With compose, put `BOLTAGE_TLS_SELF_SIGNED=true` in `.env`; the certificate is kept +in the `boltage-tls` volume. + +The certificate is valid for `localhost`, `127.0.0.1`, `::1` and +`BOLTAGE_ADVERTISED_HOST`. List other host names and addresses, comma-separated, in +`BOLTAGE_TLS_HOSTNAMES`. A new certificate is generated when that list changes and +30 days before the old one expires (it is valid for 825 days). Its SHA-256 +fingerprint is logged at every start, so you can compare it with the one your +browser shows. + +Drivers connect with `neo4j+ssc://localhost:7687`. In a web browser, accept the +certificate at once, then open and +accept it there too. + +#### Your own certificate + +Mount the certificate and key, and point `BOLTAGE_TLS_CERT` and `BOLTAGE_TLS_KEY` at +them: + +```sh +podman run -d --name boltage -e POSTGRES_PASSWORD=secret \ + -v ./tls:/etc/boltage/tls:ro \ + -e BOLTAGE_TLS_CERT=/etc/boltage/tls/cert.pem -e BOLTAGE_TLS_KEY=/etc/boltage/tls/key.pem \ + -p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \ + -v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin boltage +``` + +Both images run boltage as user and group 999, which must be able to read the key. +Keep the key private on the host and give it to that user: with rootless Podman, +`podman unshare chown 999:999 tls/key.pem`, otherwise `sudo chown 999:999 tls/key.pem`; +then `chmod 0400 tls/key.pem`. On hosts with SELinux (Fedora, RHEL), mount with +`:ro,Z` instead of `:ro`. + +#### Secrets + +Podman secrets keep the key out of the host's file system and set its owner for you: + +```sh +podman secret create boltage-tls-cert tls/cert.pem +podman secret create boltage-tls-key tls/key.pem +podman run -d --name boltage -e POSTGRES_PASSWORD=secret \ + --secret boltage-tls-cert,uid=999,gid=999,mode=0444 \ + --secret boltage-tls-key,uid=999,gid=999,mode=0400 \ + -e BOLTAGE_TLS_CERT=/run/secrets/boltage-tls-cert \ + -e BOLTAGE_TLS_KEY=/run/secrets/boltage-tls-key \ + -p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \ + -v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin boltage +``` + +With compose, add the secrets in a `compose.override.yaml` next to `compose.yaml`, +which compose reads automatically: + +```yaml +services: + boltage: + environment: + BOLTAGE_TLS_CERT: /run/secrets/boltage-tls-cert + BOLTAGE_TLS_KEY: /run/secrets/boltage-tls-key + secrets: + - boltage-tls-cert + - boltage-tls-key + +secrets: + boltage-tls-cert: + file: ./tls/cert.pem + boltage-tls-key: + file: ./tls/key.pem +``` + +Compose mounts file secrets from the host as they are, so the key needs the same +owner as above. + ### Build arguments `AGE_IMAGE` (base of the full image, default `apache/age:release_PG18_1.8.0`), @@ -396,7 +520,8 @@ addressing it in [`plans/`](plans/README.md): ([plan](plans/03-age-unsupported-features.md)). 4. User and role management, `SHOW TRANSACTIONS`, `:sysinfo` ([plan](plans/04-users-transactions-sysinfo.md)). -5. TLS (`bolt+s://`, HTTPS) ([plan](plans/05-tls.md)). +5. [TLS](#tls) certificates are only read at start-up, so renewing one needs a + restart ([plan](plans/05-tls.md)). 6. Each query's full result is fetched from PostgreSQL before it is streamed to the client, so very large results use memory in boltage ([plan](plans/06-streaming-results.md)). diff --git a/boltage/__main__.py b/boltage/__main__.py index 14f5295..dbadc70 100644 --- a/boltage/__main__.py +++ b/boltage/__main__.py @@ -10,12 +10,36 @@ import sys from .backend import Config from .bolt import handle_client +from .streams import TLS_MODES, TlsConfigError, server_context def env(name, default): return os.environ.get(name) or default +def tls_settings(): + """TLS mode of the Bolt port, its context, and whether to serve HTTPS, from the environment.""" + cert, key = env("BOLTAGE_TLS_CERT", ""), env("BOLTAGE_TLS_KEY", "") + if bool(cert) != bool(key): + sys.exit("boltage: set both BOLTAGE_TLS_CERT and BOLTAGE_TLS_KEY, or neither") + mode = env("BOLTAGE_BOLT_TLS", "optional" if cert else "disabled").lower() + if mode not in TLS_MODES: + sys.exit(f"boltage: BOLTAGE_BOLT_TLS must be one of {', '.join(TLS_MODES)}, not {mode!r}") + https = env("BOLTAGE_HTTPS", "true" if cert else "false").lower() + if https not in ("true", "false"): + sys.exit(f"boltage: BOLTAGE_HTTPS must be true or false, not {https!r}") + https = https == "true" + if (mode != "disabled" or https) and not cert: + sys.exit("boltage: TLS needs a certificate: set BOLTAGE_TLS_CERT and BOLTAGE_TLS_KEY") + context = None + if cert: + try: + context = server_context(cert, key) + except TlsConfigError as e: + sys.exit(f"boltage: {e}") + return mode, (context if mode != "disabled" else None), https, cert, key + + async def main(): logging.basicConfig(level=env("BOLTAGE_LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -32,6 +56,7 @@ async def main(): sys.exit(f"boltage: BOLTAGE_BROWSER_DIR is set, but serving neo4j-browser needs the browser extra " f"(pip install 'boltage[browser]'): {e}") + tls, tls_context, https, cert, key = tls_settings() bolt_port = int(env("BOLTAGE_BOLT_PORT", "7687")) http_port = int(env("BOLTAGE_HTTP_PORT", "7474")) advertised_port = env("BOLTAGE_ADVERTISED_BOLT_PORT", str(bolt_port)) @@ -41,19 +66,23 @@ async def main(): pg_database=env("BOLTAGE_PG_DATABASE", env("POSTGRES_DB", env("POSTGRES_USER", "postgres"))), default_graph=env("BOLTAGE_DEFAULT_GRAPH", "neo4j"), advertised_bolt=f"{env('BOLTAGE_ADVERTISED_HOST', 'localhost')}:{advertised_port}", + tls=tls, + tls_context=tls_context, ) bolt = await asyncio.start_server(functools.partial(handle_client, config=config), "0.0.0.0", bolt_port) tasks = [bolt.serve_forever()] - log.info("Bolt listening on :%d (PostgreSQL %s:%d/%s, default graph %r)", bolt_port, + log.info("Bolt listening on :%d, TLS %s (PostgreSQL %s:%d/%s, default graph %r)", bolt_port, tls, config.pg_host, config.pg_port, config.pg_database, config.default_graph) if browser_dir: - http = uvicorn.Server(uvicorn.Config(create_app(browser_dir, bolt_port), host="0.0.0.0", - port=http_port, log_config=None, log_level="warning", - access_log=False, lifespan="off")) + app = create_app(browser_dir, bolt_port, tls_required=tls == "required") + tls_files = {"ssl_certfile": cert, "ssl_keyfile": key} if https else {} + http = uvicorn.Server(uvicorn.Config(app, host="0.0.0.0", port=http_port, log_config=None, + log_level="warning", access_log=False, lifespan="off", + **tls_files)) tasks.append(http.serve()) - log.info("neo4j-browser on http://localhost:%d/browser/", http_port) + log.info("neo4j-browser on %s://localhost:%d/browser/", "https" if https else "http", http_port) # Stop when either server stops, e.g. uvicorn after a signal. done, pending = await asyncio.wait(map(asyncio.ensure_future, tasks), return_when=asyncio.FIRST_COMPLETED) diff --git a/boltage/backend.py b/boltage/backend.py index 8b47455..72bf0e1 100644 --- a/boltage/backend.py +++ b/boltage/backend.py @@ -9,6 +9,7 @@ only answers the administrative queries that neo4j-browser issues. import logging import re import secrets +import ssl import time from dataclasses import dataclass, field @@ -61,6 +62,8 @@ class Config: pg_database: str default_graph: str advertised_bolt: str + tls: str = "disabled" # TLS on the Bolt port: disabled, optional or required + tls_context: ssl.SSLContext | None = None @dataclass diff --git a/boltage/bolt.py b/boltage/bolt.py index cec05c7..e54b8cb 100644 --- a/boltage/bolt.py +++ b/boltage/bolt.py @@ -6,9 +6,9 @@ Supports Bolt 4.4 and 5.0-5.4, which covers the neo4j-javascript-driver used by neo4j-browser as well as current Python/Java drivers and cypher-shell. """ -import asyncio import itertools import logging +import ssl import struct from websockets.frames import CloseCode, Opcode @@ -19,6 +19,7 @@ from . import packstream from .backend import BoltFailure, Config, Session from .discovery import discovery_document from .packstream import Structure +from .streams import TLS_HANDSHAKE, ConnectionClosed, TcpStream, TlsStream log = logging.getLogger("boltage.bolt") @@ -31,35 +32,30 @@ DISCARD, PULL, TELEMETRY, ROUTE, LOGON, LOGOFF = 0x2F, 0x3F, 0x54, 0x66, 0x6A, 0 SUCCESS, RECORD, IGNORED, FAILURE = 0x70, 0x71, 0x7E, 0x7F _connection_ids = itertools.count(1) - - -class ConnectionClosed(Exception): - pass +HTTP_METHODS = (b"GET ", b"HEAD", b"OPTI", b"POST") # -- transports ----------------------------------------------------------------- class RawTransport: - def __init__(self, reader, writer, prefix=b""): - self.reader, self.writer, self.buffer = reader, writer, bytearray(prefix) + """Bolt bytes directly on a stream (``TcpStream`` or ``TlsStream``).""" + + def __init__(self, stream, prefix=b""): + self.stream, self.buffer = stream, bytearray(prefix) async def read(self, n): while len(self.buffer) < n: - chunk = await self.reader.read(65536) - if not chunk: - raise ConnectionClosed() - self.buffer += chunk + self.buffer += await self.stream.recv() data = bytes(self.buffer[:n]) del self.buffer[:n] return data async def write(self, data): - self.writer.write(data) - await self.writer.drain() + await self.stream.send(data) async def close(self): - self.writer.close() + await self.stream.close() class WebSocketTransport(RawTransport): @@ -68,16 +64,19 @@ class WebSocketTransport(RawTransport): Uses the Sans-I/O protocol of the websockets library, as this port also speaks raw Bolt. """ + def __init__(self, stream, prefix=b"", tls_required=False): + super().__init__(stream, prefix) + self.tls_required = tls_required + async def handshake(self): # Read the request head here rather than in websockets, which only accepts GET: # neo4j-browser also sends plain HTTP requests (discovery, CORS preflight) to this port. request = bytearray(self.buffer) self.buffer.clear() while b"\r\n\r\n" not in request: - chunk = await self.reader.read(4096) - if not chunk or len(request) > 65536: + if len(request) > 65536: raise ConnectionClosed() - request += chunk + request += await self.stream.recv() head = bytes(request).partition(b"\r\n\r\n")[0] headers = {} for line in head.decode("latin-1").split("\r\n")[1:]: @@ -106,19 +105,17 @@ class WebSocketTransport(RawTransport): if method == b"OPTIONS": response = b"HTTP/1.1 204 No Content\r\n" + cors + b"\r\n" elif method in (b"GET", b"HEAD"): - body = discovery_document(headers, self.writer.get_extra_info("sockname")[1]) + body = discovery_document(headers, self.stream.local_port, self.tls_required) response = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + cors + b"Content-Length: %d\r\n\r\n" % len(body) + (body if method == b"GET" else b"")) else: response = b"HTTP/1.1 405 Method Not Allowed\r\n" + cors + b"Content-Length: 0\r\n\r\n" - self.writer.write(response) - await self.writer.drain() + await self.stream.send(response) async def _flush(self): - for data in self.protocol.data_to_send(): - if data: - self.writer.write(data) - await self.writer.drain() + data = b"".join(self.protocol.data_to_send()) + if data: + await self.stream.send(data) async def read(self, n): while len(self.buffer) < n: @@ -130,10 +127,7 @@ class WebSocketTransport(RawTransport): break if self.protocol.state is not State.OPEN: raise ConnectionClosed() - chunk = await self.reader.read(65536) - if not chunk: - raise ConnectionClosed() - self.protocol.receive_data(chunk) + self.protocol.receive_data(await self.stream.recv()) data = bytes(self.buffer[:n]) del self.buffer[:n] return data @@ -146,7 +140,7 @@ class WebSocketTransport(RawTransport): if self.protocol.state is State.OPEN: self.protocol.send_close(CloseCode.NORMAL_CLOSURE) await self._flush() - self.writer.close() + await self.stream.close() # -- protocol ----------------------------------------------------------------------- @@ -346,15 +340,46 @@ class BoltConnection: await self.success(metadata) +async def _sniff(stream, n): + """At least the first ``n`` bytes of a stream (possibly more).""" + data = b"" + while len(data) < n: + data += await stream.recv() + return data + + +_TLS_REQUIRED = b"This server only accepts connections over TLS.\n" +TLS_REQUIRED_RESPONSE = (b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nConnection: close\r\n" + b"Content-Length: %d\r\n\r\n" % len(_TLS_REQUIRED) + _TLS_REQUIRED) + + async def handle_client(reader, writer, config: Config): + """Serve one connection on the Bolt port. + + The port carries raw Bolt, Bolt over WebSocket and plain HTTP (discovery), each + either directly or over TLS, told apart by the first bytes the client sends. + """ peer = writer.get_extra_info("peername") + stream = TcpStream(reader, writer) try: - first = await reader.readexactly(4) - log.debug("connection from %s starts with %r", peer, first) - if first == MAGIC: - transport = RawTransport(reader, writer) - elif first in (b"GET ", b"HEAD", b"OPTI", b"POST"): - transport = WebSocketTransport(reader, writer, first) + data = await _sniff(stream, 4) + if data[0] == TLS_HANDSHAKE: + if config.tls_context is None: + log.debug("connection from %s attempts TLS, which is disabled", peer) + return + stream = TlsStream(stream, config.tls_context, data) + await stream.handshake() + data = await _sniff(stream, 4) + elif config.tls == "required": + log.debug("connection from %s without TLS, which is required", peer) + if data[:4] in HTTP_METHODS: + await stream.send(TLS_REQUIRED_RESPONSE) + return + log.debug("connection from %s starts with %r", peer, data[:4]) + if data[:4] == MAGIC: + transport = RawTransport(stream, data[4:]) + elif data[:4] in HTTP_METHODS: + transport = WebSocketTransport(stream, data, tls_required=config.tls == "required") await transport.handshake() if await transport.read(4) != MAGIC: return @@ -362,8 +387,10 @@ async def handle_client(reader, writer, config: Config): return await BoltConnection(transport, config).serve() await transport.close() - except (ConnectionClosed, asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError): + except (ConnectionClosed, ConnectionResetError, BrokenPipeError): pass + except ssl.SSLError as e: + log.debug("TLS connection from %s failed: %s", peer, e) except Exception: # noqa: BLE001 log.exception("connection from %s failed", peer) finally: diff --git a/boltage/discovery.py b/boltage/discovery.py index 96d942b..5bc207c 100644 --- a/boltage/discovery.py +++ b/boltage/discovery.py @@ -8,17 +8,21 @@ import os from .backend import NEO4J_VERSION -def discovery_document(headers, bolt_port) -> bytes: - """The JSON Neo4j serves at ``/`` so clients can find the Bolt endpoint.""" +def discovery_document(headers, bolt_port, tls_required=False) -> bytes: + """The JSON Neo4j serves at ``/`` so clients can find the Bolt endpoint. + + When the Bolt port only accepts TLS, it advertises the ``+s`` URI schemes. + """ host = headers.get("host") or "localhost" if host.startswith("["): # IPv6 literal, e.g. [::1]:7474 host = host[:host.index("]") + 1] else: host = host.split(":", 1)[0] port = os.environ.get("BOLTAGE_ADVERTISED_BOLT_PORT", str(bolt_port)) + secure = "+s" if tls_required else "" return json.dumps({ - "bolt_routing": f"neo4j://{host}:{port}", - "bolt_direct": f"bolt://{host}:{port}", + "bolt_routing": f"neo4j{secure}://{host}:{port}", + "bolt_direct": f"bolt{secure}://{host}:{port}", "neo4j_version": NEO4J_VERSION, "neo4j_edition": "community", }).encode() diff --git a/boltage/streams.py b/boltage/streams.py new file mode 100644 index 0000000..9a68fa5 --- /dev/null +++ b/boltage/streams.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: 2026 Bart Broere +# SPDX-License-Identifier: GPL-3.0-or-later +"""Byte streams that Bolt (directly or over WebSocket) runs on: TCP, and TLS over TCP. + +TLS runs on ``ssl.SSLObject`` and memory buffers rather than asyncio's own TLS, so +a connection can be sniffed before deciding whether it is TLS at all: when TLS is +optional, the Bolt port accepts TLS and plain connections side by side. +""" + +import ssl + +TLS_MODES = ("disabled", "optional", "required") +# The first byte of every TLS connection: a handshake record carrying the ClientHello. +TLS_HANDSHAKE = 0x16 + + +class ConnectionClosed(Exception): + pass + + +class TlsConfigError(ValueError): + pass + + +def server_context(cert_file: str, key_file: str) -> ssl.SSLContext: + """A server-side TLS context for a PEM certificate chain and private key.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + try: + context.load_cert_chain(cert_file, key_file) + except (OSError, ssl.SSLError) as e: + raise TlsConfigError(f"cannot load TLS certificate {cert_file!r} and key {key_file!r}: {e}") from e + return context + + +class TcpStream: + def __init__(self, reader, writer): + self.reader, self.writer = reader, writer + + @property + def local_port(self) -> int: + return self.writer.get_extra_info("sockname")[1] + + async def recv(self) -> bytes: + chunk = await self.reader.read(65536) + if not chunk: + raise ConnectionClosed() + return chunk + + async def send(self, data: bytes): + self.writer.write(data) + await self.writer.drain() + + async def close(self): + self.writer.close() + + +class TlsStream: + """The server side of a TLS connection over a lower stream (``TcpStream``).""" + + def __init__(self, lower, context: ssl.SSLContext, prefix=b""): + self.lower = lower + self.incoming = ssl.MemoryBIO() + self.outgoing = ssl.MemoryBIO() + self.tls = context.wrap_bio(self.incoming, self.outgoing, server_side=True) + self.incoming.write(prefix) + + @property + def local_port(self) -> int: + return self.lower.local_port + + async def _flush(self): + data = self.outgoing.read() + if data: + await self.lower.send(data) + + async def handshake(self): + while True: + try: + self.tls.do_handshake() + break + except ssl.SSLWantReadError: + await self._flush() + self.incoming.write(await self.lower.recv()) + except ssl.SSLError: + await self._flush() # the alert, so the client learns why + raise + await self._flush() # the last handshake messages and session tickets + + async def recv(self) -> bytes: + while True: + try: + data = self.tls.read(65536) + except ssl.SSLWantReadError: + await self._flush() + self.incoming.write(await self.lower.recv()) + continue + except ssl.SSLZeroReturnError: + raise ConnectionClosed() from None + await self._flush() + if not data: + raise ConnectionClosed() + return data + + async def send(self, data: bytes): + self.tls.write(data) + await self._flush() + + async def close(self): + try: + self.tls.unwrap() # sends close_notify; the client's reply is not awaited + except ssl.SSLError: + pass + try: + await self._flush() + except OSError: + pass + await self.lower.close() diff --git a/boltage/web.py b/boltage/web.py index 9efb4fa..06f4f44 100644 --- a/boltage/web.py +++ b/boltage/web.py @@ -35,11 +35,11 @@ def nosniff(app): return wrapped -def create_app(browser_dir: str, bolt_port: int) -> Starlette: +def create_app(browser_dir: str, bolt_port: int, tls_required=False) -> Starlette: async def root(request): if "text/html" in request.headers.get("accept", ""): return RedirectResponse("/browser/", status_code=302) - return Response(discovery_document(request.headers, bolt_port), media_type="application/json") + return Response(discovery_document(request.headers, bolt_port, tls_required), media_type="application/json") async def browser(request): return RedirectResponse("/browser/", status_code=301) diff --git a/compose.yaml b/compose.yaml index 312ae0b..e0b807e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -41,6 +41,10 @@ services: BOLTAGE_PG_DATABASE: postgres BOLTAGE_DEFAULT_GRAPH: ${BOLTAGE_DEFAULT_GRAPH:-neo4j} BOLTAGE_LOG_LEVEL: ${BOLTAGE_LOG_LEVEL:-INFO} + # true: TLS with a self-signed certificate, kept in the boltage-tls volume. + BOLTAGE_TLS_SELF_SIGNED: ${BOLTAGE_TLS_SELF_SIGNED:-false} + volumes: + - boltage-tls:/var/lib/boltage ports: - "7474:7474" - "7687:7687" @@ -67,3 +71,4 @@ services: volumes: postgres-data: pgadmin-data: + boltage-tls: diff --git a/container/boltage-entrypoint.sh b/container/boltage-entrypoint.sh index 978b0a5..b3bb0cb 100755 --- a/container/boltage-entrypoint.sh +++ b/container/boltage-entrypoint.sh @@ -40,7 +40,7 @@ EOF docker-entrypoint.sh "$@" & pg_pid=$! -gosu postgres /opt/boltage/venv/bin/boltage & +gosu postgres /usr/local/bin/boltage-start.sh & bridge_pid=$! pgadmin_pid= diff --git a/container/boltage-start.sh b/container/boltage-start.sh new file mode 100755 index 0000000..9f4c817 --- /dev/null +++ b/container/boltage-start.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Bart Broere +# SPDX-License-Identifier: GPL-3.0-or-later +# Starts boltage. With BOLTAGE_TLS_SELF_SIGNED=true, it first generates a self-signed +# TLS certificate, or reuses the one from an earlier start, and points boltage at it. +set -eu + +if [ "${BOLTAGE_TLS_SELF_SIGNED:-false}" = "true" ]; then + if [ -n "${BOLTAGE_TLS_CERT:-}${BOLTAGE_TLS_KEY:-}" ]; then + echo "boltage: BOLTAGE_TLS_CERT or BOLTAGE_TLS_KEY is set, not using a self-signed certificate" >&2 + else + dir="${BOLTAGE_TLS_DIR:-/var/lib/boltage/tls}" + cert="$dir/self-signed.crt" + key="$dir/self-signed.key" + + # Host names and addresses the certificate is valid for. + names="localhost,127.0.0.1,::1" + [ -n "${BOLTAGE_ADVERTISED_HOST:-}" ] && names="$names,$BOLTAGE_ADVERTISED_HOST" + [ -n "${BOLTAGE_TLS_HOSTNAMES:-}" ] && names="$names,$BOLTAGE_TLS_HOSTNAMES" + is_ip() { + case "$1" in + *[!0-9.]*) ;; # not IPv4 + *) return 0 ;; + esac + case "$1" in + *:*) case "$1" in *[!0-9a-fA-F:.]*) return 1 ;; *) return 0 ;; esac ;; # IPv6 + esac + return 1 + } + san="" + for name in $(echo "$names" | tr ', ' '\n\n' | awk 'NF && !seen[$0]++'); do + if is_ip "$name"; then + san="${san:+$san,}IP:$name" + else + san="${san:+$san,}DNS:$name" + fi + done + + # Generate a new certificate on first start, when the names changed, or when the + # stored one expires within 30 days. + if [ ! -s "$key" ] || [ "$(cat "$dir/self-signed.names" 2>/dev/null)" != "$san" ] \ + || ! openssl x509 -in "$cert" -noout -checkend 2592000 >/dev/null 2>&1; then + (umask 077 && mkdir -p "$dir") + (umask 077 && openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ + -days 825 -subj "/CN=boltage" -addext "subjectAltName=$san" \ + -addext "extendedKeyUsage=serverAuth" \ + -keyout "$key.new" -out "$cert.new" 2>/dev/null) + mv "$key.new" "$key" + mv "$cert.new" "$cert" + echo "$san" > "$dir/self-signed.names" + echo "boltage: generated a self-signed TLS certificate for $san in $dir" + fi + echo "boltage: self-signed TLS certificate, $(openssl x509 -in "$cert" -noout -fingerprint -sha256)" + export BOLTAGE_TLS_CERT="$cert" BOLTAGE_TLS_KEY="$key" + fi +fi + +exec /opt/boltage/venv/bin/boltage "$@" diff --git a/plans/05-tls.md b/plans/05-tls.md index c9038b0..15cb0b9 100644 --- a/plans/05-tls.md +++ b/plans/05-tls.md @@ -1,5 +1,21 @@ # 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