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