Add Bolt TLS termination as optional feature
This commit is contained in:
1 parent
5f879fd9a4
commit
eb989c9155
12 files changed
+446
-53
No files matched your search
+12
-4
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -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=
|
||||
|
||||
Executable
+58
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user