Add Bolt TLS termination as optional feature

This commit is contained in:
Bart Broere committed 2026-11-04 14:12:10 +01:00
1 parent 5f879fd9a4
commit eb989c9155
12 files changed
+446 -53

No files matched your search

+12 -4
View File
@@ -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 \
+126 -1
View File
@@ -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
<https://localhost:7474/>; 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 <https://localhost:7687/> once, then open <https://localhost:7474/> 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)).
+34 -5
View File
@@ -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)
+3
View File
@@ -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
+63 -36
View File
@@ -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:
+8 -4
View File
@@ -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()
+118
View File
@@ -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()
+2 -2
View File
@@ -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)
+5
View File
@@ -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:
+1 -1
View File
@@ -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=
+58
View File
@@ -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 "$@"
+16
View File
@@ -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