mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 02:57:44 +01:00
* test/s3vectors: a hardened cluster, with TLS volume servers, read JWTs and encrypted chunks Three switches on the test cluster: VECTOR_TLS=1 puts the volume servers behind HTTPS requiring client certificates and enables https.client for every process; VECTOR_READ_JWT=1 gives every process a jwt.signing.read.key, so volume reads need a token; VECTOR_ENCRYPT_VOLUMES=1 starts the gateway with -encryptVolumeData. The integration matrix gains a hardened leg per volume server with all three on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: the hardened cluster proves its enforcement Once the gateway is up, the harness checks that the hardening holds, not only that it is configured: a TLS volume server refuses plain HTTP (Go with HTTP 400, Rust with a TLS alert) and a connection without the client certificate, and answers with it; under a read key an unsigned read of a real chunk, written through the gateway and located through the filer, gets 401. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: the hardened cluster proves its chunks are encrypted With VECTOR_ENCRYPT_VOLUMES the probe reads the stored chunk straight from the volume server, signing the read when a read key is set, and requires a recorded cipher key and bytes that differ from the plaintext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: the signed probe read must return the chunk An empty 200 passed both branches; an unencrypted read now has to return the bytes written, and an encrypted one has to return something else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: a read key in the caller's environment does not leak into the cluster Only VECTOR_READ_JWT turns read JWTs on; an inherited WEED_JWT_SIGNING_READ_KEY made the volume servers refuse the probe's unsigned read in an encrypt-only run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: quote certificate paths as TOML strings in security.toml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: keep non-ASCII certificate paths as UTF-8 in security.toml TOML has no surrogate-pair escapes, and the Rust volume server reads the quoted value without decoding escapes at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: an encrypted probe chunk must not be a readable gzip stream The gateway may gzip the body before encrypting it, so a chunk uploaded compressed but unencrypted would differ from the body and still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
292 lines
14 KiB
Python
292 lines
14 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
|
|
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")
|
|
pem = "".join(f'{key} = {json.dumps(str(directory / name), ensure_ascii=False)}\n' for key, name in (("cert", "cert.pem"), ("key", "key.pem"), ("ca", "ca.pem")))
|
|
(directory / "security.toml").write_text(f"[https.volume]\n{pem}\n[https.client]\nenabled = true\n{pem}")
|
|
|
|
|
|
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": 16, "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")
|
|
|
|
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")
|
|
}
|