Files
seaweedfs/test/s3vectors/conftest.py
T
Chris LuandClaude Opus 5.5 eeb8aefe70 weed-vector: gRPC TLS to the masters and for the gateway (#12368)
* weed-vector: read the cluster's security.toml

The engine finds security.toml where weed processes do (., ~/.seaweedfs,
/usr/local/etc/seaweedfs, /etc/seaweedfs) with WEED_* environment
variables over it, and builds gRPC client and server credentials by the
rules of security.LoadClientTLS and LoadServerTLS: a dedicated client
certificate is preferred, both halves of a pair must be set, and grpc.ca
is required once either is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* weed-vector: gRPC TLS to the masters and from the gateway

The masters are dialed with [grpc.client]'s credentials when the cluster
has them, the master's certificate checked against the host dialed or
--tls-server-name. The engine's own listener takes its identity from
[grpc.vector] and then requires a client certificate chaining to grpc.ca,
which is how the gateway, dialing with [grpc.client], reaches it. The
rustls provider is pinned before the first TLS use, as the other binaries
linking both aws-lc-rs and ring do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* scaffold: document [grpc.vector]

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* test/s3vectors: put every gRPC hop under mutual TLS in the hardened leg

VECTOR_TLS=1 now also writes [grpc.master], [grpc.volume], [grpc.filer],
[grpc.s3], [grpc.client] and [grpc.vector], and probes the master's and
the engine's gRPC ports: plaintext HTTP/2 and a client without a
certificate are refused, the client certificate gets a SETTINGS frame.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* test/s3vectors: give the test volume server 64 volume slots

Every collection grows 7 volumes and test_vector_bucket_encryption opens
two buckets while the previous test's bucket is still being torn down, so
16 slots were marginal: the Go legs failed CreateIndex now and then with
"failed to find writable volumes" and master.log showing "create 7
volume, created 4: Not enough data nodes found!".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* weed-vector: reload the listener's certificates and enforce allowed_commonNames

The listener now runs the way weed servers run theirs. The certificate, key
and CA files are re-read every WEED_TLS_CERT_REFRESH_INTERVAL (weed's
default of 5h) and on SIGHUP, and new handshakes use the changed files
without a restart; a file that no longer parses is logged and the working
configuration stays. [grpc.vector] allowed_commonNames and
grpc.allowed_wildcard_domain restrict which client certificates are served,
checked against the subject common name as security.Authenticator and the
Rust volume server's CommonNameVerifier do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* scaffold: allowed_commonNames and the refresh cadence for [grpc.vector]

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* test/s3vectors: the engine's allowlist keeps an outsider certificate out

The hardened leg sets [grpc.vector] allowed_commonNames = "localhost" and
probes the engine with a second certificate the same CA signed for
CN=outsider: the handshake must be refused, while the gateway's certificate
still gets an HTTP/2 SETTINGS frame.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* weed-vector: take the master's TLS name from the parsed endpoint

A master given without a port (https://master.example) had the whole URL
checked against its certificate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: bound the master TLS handshake

connect_timeout covers only the TCP connect, so a master that accepted the
connection and never answered the handshake held up the other masters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: look up security.toml keys the way viper does

An empty WEED_* variable no longer hides the file's value, and keys match
regardless of case, as for weed processes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: refuse master allowlists it cannot enforce

weed checks a dialed server against allowed_server_commonNames and
allowed_server_wildcard_domain on top of grpc.ca. The engine's master dial
accepted every certificate the CA signed even when one was set; it now
fails at startup and names the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test/s3vectors: read the whole frame header in the gRPC TLS probe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test/s3vectors: probe the volume, filer and S3 gRPC listeners too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: read the master dial credentials only when given masters

A standalone engine never dials with grpc.client, so its settings no longer
stop it from starting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: run each listener TLS handshake on its own task

The accept loop awaited every handshake before accepting the next
connection, so a peer that connected and sent nothing stopped every
gateway connection after it. Handshakes now run concurrently, each under a
10s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: read the master dial's TLS files at every dial

The client certificate, key and CA were read once at startup, so after the
files rotated a reconnect still presented the old pair. Each dial now reads
them, as the listener re-reads its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: read every unit Go durations accept in the refresh interval

WEED_TLS_CERT_REFRESH_INTERVAL values in ns, us or a leading + fell back
to five hours in the engine while weed servers honored them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: fall back on a refresh interval too long to hold

An oversized WEED_TLS_CERT_REFRESH_INTERVAL panicked the engine at
startup; weed servers reject it and keep the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: refuse allowed_commonNames without the listener's certificate

With [grpc.vector] allowed_commonNames set but no cert and key, the
listener served plaintext and checked no client at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: read the master dial's credentials inside its connect timeout

The CA, certificate and key were read synchronously before the timeout
started, so a stalled credential path held up every other master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-vector: let a set key win over an empty spelling of it

With allowed_server_commonNames = "" beside allowed_server_commonnames
= "master", the lookup stopped at the empty exact match and the
allowlist went unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 11:42:49 -08:00

347 lines
18 KiB
Python

import base64
import contextlib
import gzip
import hashlib
import hmac
import http.client
import json
import os
from pathlib import Path
import socket
import ssl
import subprocess
import time
import urllib.error
import urllib.request
import uuid
import zlib
import boto3
from botocore.config import Config
import pytest
HERE = Path(__file__).resolve().parent
def combinations():
volumes = os.environ.get("VOLUME_SERVER_IMPL", "go,rust").split(",")
default_modes = "single,distributed" if (HERE / "../../rust/sw-vector-distributed/Cargo.toml").is_file() else "single"
modes = os.environ.get("VECTOR_QUERY_MODE", default_modes).split(",")
if not set(volumes) <= {"go", "rust"} or not set(modes) <= {"single", "distributed"}:
raise ValueError("invalid VOLUME_SERVER_IMPL or VECTOR_QUERY_MODE")
return [(volume, mode) for volume in volumes for mode in modes]
def binary(variable, name):
path = Path(os.environ.get(variable, str(HERE / name))).resolve()
if not path.is_file() or not os.access(path, os.X_OK):
pytest.fail(f"Missing executable {path}; run make build or set {variable}")
return str(path)
def wait_for(check, processes, message):
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
for name, process in processes.items():
if process.poll() is not None:
pytest.fail(f"{name} exited with {process.returncode}; {message}")
try:
if check():
return
except (OSError, urllib.error.URLError):
pass
time.sleep(0.2)
pytest.fail(message)
def listening(port):
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return True
def http_ready(url):
try:
with urllib.request.urlopen(url, timeout=0.5):
return True
except urllib.error.HTTPError:
return True
def stop(process):
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
@pytest.fixture(scope="module", params=combinations(), ids=lambda p: "-".join(p))
def cluster(request, tmp_path_factory):
yield from run_cluster(request.param, tmp_path_factory, identities=True)
@pytest.fixture(scope="module", params=combinations(), ids=lambda p: "-".join(p))
def open_cluster(request, tmp_path_factory):
"""A cluster with no S3 identities, and an engine given no credentials."""
yield from run_cluster(request.param, tmp_path_factory, identities=False)
def write_tls(directory):
"""A CA and one certificate for localhost. Volume servers serve HTTPS and
every gRPC listener (master, volume, filer, gateway, engine) serves TLS,
all requiring client certificates, and every client presents the same one."""
def openssl(*args):
subprocess.run(["openssl", *args], check=True, capture_output=True, cwd=directory)
openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "ca.key", "-out", "ca.pem",
"-subj", "/CN=s3vectors test CA", "-days", "2",
"-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign")
openssl("req", "-newkey", "rsa:2048", "-nodes", "-keyout", "key.pem", "-out", "req.csr", "-subj", "/CN=localhost")
(directory / "ext.cnf").write_text(
"subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth,clientAuth\nbasicConstraints=CA:FALSE\n")
openssl("x509", "-req", "-in", "req.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial",
"-out", "cert.pem", "-days", "2", "-extfile", "ext.cnf")
# A second identity the same CA signed, which the engine's allowlist
# must keep out.
openssl("req", "-newkey", "rsa:2048", "-nodes", "-keyout", "outsider.key", "-out", "outsider.csr", "-subj", "/CN=outsider")
openssl("x509", "-req", "-in", "outsider.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial",
"-out", "outsider.pem", "-days", "2", "-extfile", "ext.cnf")
cert, key, ca = (f'{key} = {json.dumps(str(directory / name), ensure_ascii=False)}\n' for key, name in (("cert", "cert.pem"), ("key", "key.pem"), ("ca", "ca.pem")))
pem = cert + key + ca
grpc = "".join(f"[grpc.{component}]\n{cert}{key}\n" for component in ("master", "volume", "filer", "s3", "client"))
grpc += f"[grpc.vector]\n{cert}{key}allowed_commonNames = \"localhost\"\n"
(directory / "security.toml").write_text(f"[https.volume]\n{pem}\n[https.client]\nenabled = true\n{pem}\n[grpc]\n{ca}\n{grpc}")
def probe_grpc_tls(name, port, directory, outsider=False):
"""A TLS gRPC listener must refuse plaintext HTTP/2 and a connection
without the client certificate, and answer the HTTP/2 preface with it.
With `outsider`, a certificate the CA signed for a name outside the
listener's allowed_commonNames must be refused too."""
preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
sock.sendall(preface)
with contextlib.suppress(ConnectionError, TimeoutError):
answer = sock.recv(16)
# Nothing, or a TLS alert record; a plaintext server sends SETTINGS.
assert not answer or answer[0] == 0x15, f"{name} gRPC answered plaintext HTTP/2: {answer!r}"
context = ssl.create_default_context(cafile=directory / "ca.pem")
context.set_alpn_protocols(["h2"])
with contextlib.suppress(ConnectionError, TimeoutError, ssl.SSLError):
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
with context.wrap_socket(sock, server_hostname="localhost") as tls:
tls.sendall(preface)
assert not tls.recv(16), f"{name} gRPC served a client without a certificate"
if outsider:
other = ssl.create_default_context(cafile=directory / "ca.pem")
other.set_alpn_protocols(["h2"])
other.load_cert_chain(directory / "outsider.pem", directory / "outsider.key")
with contextlib.suppress(ConnectionError, TimeoutError, ssl.SSLError):
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
with other.wrap_socket(sock, server_hostname="localhost") as tls:
tls.sendall(preface)
assert not tls.recv(16), f"{name} gRPC served a certificate outside allowed_commonNames"
context.load_cert_chain(directory / "cert.pem", directory / "key.pem")
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
with context.wrap_socket(sock, server_hostname="localhost") as tls:
tls.sendall(preface)
header = b""
while len(header) < 9 and (chunk := tls.recv(9 - len(header))):
header += chunk
# The first server frame is SETTINGS (type 4).
assert len(header) == 9 and header[3] == 4, \
f"{name} gRPC did not answer the HTTP/2 preface over TLS: {header!r}"
def read_token(key, fid):
def encode(data):
return base64.urlsafe_b64encode(data).rstrip(b"=")
signed = encode(b'{"alg":"HS256","typ":"JWT"}') + b"." + encode(json.dumps({"fid": fid}).encode())
return (signed + b"." + encode(hmac.new(key.encode(), signed, hashlib.sha256).digest())).decode()
def probe_hardening(endpoint, filer, vol, directory, read_jwt_key):
"""The hardened switches must be enforced, not only configured: a TLS
volume server refuses plain HTTP and a connection without the client
certificate, a volume server under a read key refuses an unsigned
read of a real chunk, and an encrypted chunk is stored as ciphertext."""
scheme = "https" if os.environ.get("VECTOR_TLS") else "http"
context = None
if scheme == "https":
context = ssl.create_default_context(cafile=directory / "ca.pem")
for url, kwargs in ((f"http://127.0.0.1:{vol}/status", {}),
(f"https://127.0.0.1:{vol}/status", {"context": context})):
try:
with urllib.request.urlopen(url, timeout=5, **kwargs) as response:
assert response.status != 200, f"{url} answered without the client certificate"
# Go answers plaintext with HTTP 400, Rust with a TLS alert.
except (urllib.error.URLError, ConnectionError, ssl.SSLError, http.client.HTTPException):
pass
context.load_cert_chain(directory / "cert.pem", directory / "key.pem")
with urllib.request.urlopen(f"https://127.0.0.1:{vol}/status", timeout=5, context=context) as response:
assert response.status == 200
encrypted = os.environ.get("VECTOR_ENCRYPT_VOLUMES")
if not (read_jwt_key or encrypted):
return
s3 = boto3.client("s3", endpoint_url=endpoint, aws_access_key_id="test", aws_secret_access_key="test",
region_name="us-east-1", config=Config(s3={"addressing_style": "path"}))
bucket = "hardening-" + uuid.uuid4().hex[:12]
body = b"x" * 1024
s3.create_bucket(Bucket=bucket)
try:
s3.put_object(Bucket=bucket, Key="chunk", Body=body)
with urllib.request.urlopen(f"http://127.0.0.1:{filer}/buckets/{bucket}/chunk?metadata=true", timeout=5) as response:
chunk = json.load(response)["chunks"][0]
url = f"{scheme}://127.0.0.1:{vol}/{chunk['file_id']}"
headers = {}
if read_jwt_key:
try:
with urllib.request.urlopen(url, timeout=5, context=context):
raise AssertionError("the volume server served an unsigned read")
except urllib.error.HTTPError as error:
assert error.code == 401, error
headers["Authorization"] = "Bearer " + read_token(read_jwt_key, chunk["file_id"])
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=5, context=context) as response:
stored = response.read()
if encrypted:
assert chunk.get("cipher_key"), "the gateway stored a chunk without a cipher key"
assert stored and stored != body, "the volume server holds the chunk in plaintext or not at all"
with contextlib.suppress(OSError, EOFError, zlib.error):
assert gzip.decompress(stored) != body, "the volume server holds the chunk gzipped, not encrypted"
else:
assert stored == body, "the signed read did not return the chunk"
finally:
s3.delete_object(Bucket=bucket, Key="chunk")
s3.delete_bucket(Bucket=bucket)
def run_cluster(param, tmp_path_factory, identities):
volume, mode = param
directory = tmp_path_factory.mktemp(f"vectors-{volume}-{mode}" + ("" if identities else "-open"))
print(f"\nVector integration logs: {directory}")
weed = binary("WEED_BINARY", "weed")
engine = binary("VECTOR_BINARY", "weed-vector")
worker = binary("VECTOR_WORKER_BINARY", "weed-vector-worker") if mode == "distributed" else None
volume_binary = binary("RUST_VOLUME_SERVER_BINARY", "weed-volume") if volume == "rust" else weed
processes = {}
with contextlib.ExitStack() as stack:
with contextlib.ExitStack() as reservations:
ports = []
for _ in range(12):
sock = reservations.enter_context(socket.socket())
sock.bind(("127.0.0.1", 0))
ports.append(sock.getsockname()[1])
master, master_grpc, vol, vol_grpc, filer, filer_grpc, s3, s3_grpc, lance, vector, worker1, worker2 = ports
master_addr = f"127.0.0.1:{master}.{master_grpc}"
filer_addr = f"127.0.0.1:{filer}.{filer_grpc}"
# VECTOR_READ_JWT=1 makes volume reads need a JWT; every weed process
# and the workers sign with the key.
read_jwt_key = os.environ.get("VECTOR_READ_JWT") and uuid.uuid4().hex
# VECTOR_TLS=1 puts the volume servers behind HTTPS with client
# certificates; every process finds security.toml in its cwd.
if os.environ.get("VECTOR_TLS"):
write_tls(directory)
def start(name, args, unset=()):
log = stack.enter_context((directory / f"{name}.log").open("w"))
env = dict(os.environ, AWS_ACCESS_KEY_ID="test", AWS_SECRET_ACCESS_KEY="test",
AWS_DEFAULT_REGION="us-east-1", AWS_EC2_METADATA_DISABLED="true",
WEED_S3_SSE_KEY="s3vectors-test",
**({"WEED_JWT_SIGNING_READ_KEY": read_jwt_key} if read_jwt_key else {}))
env.pop("WEED_S3_SSE_KEK", None)
if not read_jwt_key:
env.pop("WEED_JWT_SIGNING_READ_KEY", None)
for key in unset:
env.pop(key)
process = subprocess.Popen(args, cwd=directory, stdout=log, stderr=log, env=env)
processes[name] = process
stack.callback(stop, process)
return process
for name in ("master", "volume", "filer"):
(directory / name).mkdir()
start("master", [weed, "-v=1", "master", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
f"-port={master}", f"-port.grpc={master_grpc}",
f"-mdir={directory / 'master'}", "-peers=none", "-volumeSizeLimitMB=32"])
wait_for(lambda: http_ready(f"http://127.0.0.1:{master}/dir/status"), processes, "master not ready")
prefix = [volume_binary] if volume == "rust" else [weed, "volume"]
flag = "--" if volume == "rust" else "-"
start("volume", prefix + [f"{flag}{key}={value}" for key, value in {
"master": master_addr, "ip": "127.0.0.1", "ip.bind": "127.0.0.1",
"port": vol, "port.grpc": vol_grpc, "dir": directory / "volume",
"max": 64, "preStopSeconds": 0,
}.items()])
wait_for(lambda: listening(vol_grpc), processes, "volume not ready")
start("filer", [weed, "filer", f"-master={master_addr}", "-ip=127.0.0.1",
"-ip.bind=127.0.0.1", f"-port={filer}", f"-port.grpc={filer_grpc}",
f"-defaultStoreDir={directory / 'filer'}"])
wait_for(lambda: listening(filer_grpc), processes, "filer not ready")
endpoint = f"http://127.0.0.1:{s3}"
# weed s3 turns AWS_ACCESS_KEY_ID into an admin identity, and the engine
# would find it as a credential.
if identities:
config, unset = [f"-config={HERE / 's3.json'}"], ()
credentials = ["--storage-option", "aws_access_key_id=test",
"--storage-option", "aws_secret_access_key=test"]
else:
config, unset, credentials = [], ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"), []
# VECTOR_ENCRYPT_VOLUMES=1 has the gateway encrypt every chunk it
# stores, which direct reads must undo.
encrypt = ["-encryptVolumeData"] if os.environ.get("VECTOR_ENCRYPT_VOLUMES") else []
start("s3", [weed, "-v=1", "s3", f"-filer={filer_addr}", *config, *encrypt,
"-ip=127.0.0.1", "-ip.bind=127.0.0.1", f"-port={s3}",
f"-port.grpc={s3_grpc}", "-port.iceberg=0", f"-port.lance={lance}"], unset)
wait_for(lambda: http_ready(f"http://127.0.0.1:{lance}/v1/namespace/root/list"), processes, "catalog not ready")
if os.environ.get("VECTOR_TLS") or read_jwt_key or encrypt:
probe_hardening(endpoint, filer, vol, directory, read_jwt_key)
start("vector", [engine, "--namespace", f"http://127.0.0.1:{lance}",
"--masters", master_addr, "--listen", f"127.0.0.1:{vector}", *credentials], unset)
wait_for(lambda: listening(vector), processes, "vector engine not ready")
if os.environ.get("VECTOR_TLS"):
wait_for(lambda: listening(s3_grpc), processes, "s3 gRPC not ready")
for name, port in (("master", master_grpc), ("volume", vol_grpc), ("filer", filer_grpc),
("s3", s3_grpc)):
probe_grpc_tls(name, port, directory)
probe_grpc_tls("engine", vector, directory, outsider=True)
wait_for(
lambda: f"Cluster: vector node 127.0.0.1:{vector} added to group ''"
in (directory / "master.log").read_text(),
processes,
"vector engine never registered with the master",
)
if worker:
for i, port in enumerate((worker1, worker2)):
start(f"worker-{i}", [worker, "--masters", master_addr, "--listen", f"127.0.0.1:{port}"])
wait_for(lambda: listening(port), processes, "vector worker not ready")
wait_for(
lambda: all(
f"S3 Vectors: discovered search worker 127.0.0.1:{port}"
in (directory / "s3.log").read_text()
for port in (worker1, worker2)
),
processes,
"gateway did not discover both search workers",
)
yield {"endpoint": endpoint, "mode": mode, "processes": processes, "directory": directory}
@pytest.fixture(scope="module")
def vectors(cluster):
return boto3.client("s3vectors", endpoint_url=cluster["endpoint"],
aws_access_key_id="test", aws_secret_access_key="test", region_name="us-east-1",
config=Config(connect_timeout=3, read_timeout=20, retries={"max_attempts": 0}))
@pytest.fixture(scope="module")
def vector_clients(vectors):
return {
name: boto3.client("s3vectors", endpoint_url=vectors.meta.endpoint_url,
aws_access_key_id=name, aws_secret_access_key=name + "-secret",
region_name="us-east-1", config=vectors.meta.config)
for name in ("reader", "writer")
}