diff --git a/.github/workflows/decision-trajectories.yml b/.github/workflows/decision-trajectories.yml index 60ace39..dc5af1c 100644 --- a/.github/workflows/decision-trajectories.yml +++ b/.github/workflows/decision-trajectories.yml @@ -3,9 +3,9 @@ name: Private decision trajectories on: push: branches: [master] - paths: [src/bitworld/decision_trajectory.nim, src/bitworld/runtime.nim, src/bitworld/native_http.nim, src/bitworld/native_stop.nim, src/bitworld/artifact_runtime.nim, tests/test_artifact_http.py, tests/support/artifact_http_probe.nim, tests/test_decision_trajectory.nim, tests/test_native_http.py, tests/support/native_http_probe.nim, .github/workflows/decision-trajectories.yml] + paths: [src/bitworld/decision_trajectory.nim, src/bitworld/runtime.nim, src/bitworld/native_http.nim, src/bitworld/native_stop.nim, src/bitworld/artifact_runtime.nim, tests/test_artifact_http.py, tests/support/artifact_http_probe.nim, tests/test_native_https.py, tests/support/native_https_probe.nim, tests/test_decision_trajectory.nim, tests/test_native_http.py, tests/support/native_http_probe.nim, .github/workflows/decision-trajectories.yml] pull_request: - paths: [src/bitworld/decision_trajectory.nim, src/bitworld/runtime.nim, src/bitworld/native_http.nim, src/bitworld/native_stop.nim, src/bitworld/artifact_runtime.nim, tests/test_artifact_http.py, tests/support/artifact_http_probe.nim, tests/test_decision_trajectory.nim, tests/test_native_http.py, tests/support/native_http_probe.nim, .github/workflows/decision-trajectories.yml] + paths: [src/bitworld/decision_trajectory.nim, src/bitworld/runtime.nim, src/bitworld/native_http.nim, src/bitworld/native_stop.nim, src/bitworld/artifact_runtime.nim, tests/test_artifact_http.py, tests/support/artifact_http_probe.nim, tests/test_native_https.py, tests/support/native_https_probe.nim, tests/test_decision_trajectory.nim, tests/test_native_http.py, tests/support/native_http_probe.nim, .github/workflows/decision-trajectories.yml] jobs: test: @@ -41,3 +41,7 @@ jobs: run: | nim c -d:release --threads:on --mm:orc --path:src -o:/tmp/artifact-http-probe tests/support/artifact_http_probe.nim python3 tests/test_artifact_http.py /tmp/artifact-http-probe + - name: Verify configured HTTPS trust and hostname validation + run: | + nim c -d:release --threads:on --mm:orc --path:src -o:/tmp/native-https-probe tests/support/native_https_probe.nim + python3 tests/test_native_https.py /tmp/native-https-probe diff --git a/src/bitworld/native_http.nim b/src/bitworld/native_http.nim index 0f56e27..3bb59ab 100644 --- a/src/bitworld/native_http.nim +++ b/src/bitworld/native_http.nim @@ -2,7 +2,7 @@ ## No provider parsing or game acceptance occurs here. All received bytes survive ## timeout/interruption; the handle is cleaned before the result can be sealed. -import std/[monotimes, options, posix, times] +import std/[monotimes, options, os, posix, times] import libcurl except Option import webby/httpheaders import native_stop @@ -99,6 +99,8 @@ proc performOwnedRequest(url: string, httpMethod: ArtifactHttpMethod, requireCurl(handle.easy_setopt(OPT_FOLLOWLOCATION, clong(0))) requireCurl(handle.easy_setopt(OptProtocols, clong(3))) # HTTP and HTTPS only. requireCurl(handle.easy_setopt(OPT_NOSIGNAL, clong(1))) + if existsEnv("SSL_CERT_FILE"): + requireCurl(handle.easy_setopt(OPT_CAINFO, getEnv("SSL_CERT_FILE").cstring)) requireCurl(handle.easy_setopt(OPT_HEADERDATA, transfer.addr)) requireCurl(handle.easy_setopt(OPT_HEADERFUNCTION, receiveHeaders)) requireCurl(handle.easy_setopt(OPT_WRITEDATA, transfer.addr)) diff --git a/tests/support/native_https_probe.nim b/tests/support/native_https_probe.nim new file mode 100644 index 0000000..f8895aa --- /dev/null +++ b/tests/support/native_https_probe.nim @@ -0,0 +1,12 @@ +import std/[json, monotimes, options, os, times] +import bitworld/native_http + +let args = commandLineParams() +let deadline = getMonoTime() + initDuration(seconds = 3) +let response = if args[1] == "inference": + performNativePost(args[0], @[], "private fixture", deadline) +else: + performArtifactUpload(args[0], ahPut, @[], "private fixture", deadline) +echo $(%*{"kind": $response.kind, "complete": response.transferComplete, + "status": (if response.httpStatus.isSome: %response.httpStatus.get() else: newJNull()), + "body": response.bodyBytes}) diff --git a/tests/test_native_https.py b/tests/test_native_https.py new file mode 100644 index 0000000..d7c088a --- /dev/null +++ b/tests/test_native_https.py @@ -0,0 +1,67 @@ +"""Verify configured private CA trust without changing global certificate stores.""" +import http.server +import json +import os +import ssl +import subprocess +import sys +import tempfile +import threading +from pathlib import Path + +probe = Path(sys.argv[1]).resolve() +with tempfile.TemporaryDirectory(prefix="bitworld-https-") as directory: + root = Path(directory) + subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-nodes", "-days", "1", "-subj", "/CN=Owned fixture CA", "-keyout", str(root / "ca.key"), + "-out", str(root / "ca.pem")], check=True, capture_output=True) + subprocess.run(["openssl", "req", "-new", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-nodes", "-subj", "/CN=localhost", "-keyout", str(root / "server.key"), + "-out", str(root / "server.csr")], check=True, capture_output=True) + (root / "extensions").write_text("subjectAltName=DNS:localhost\n") + subprocess.run(["openssl", "x509", "-req", "-in", str(root / "server.csr"), "-CA", str(root / "ca.pem"), + "-CAkey", str(root / "ca.key"), "-CAcreateserial", "-days", "1", "-extfile", str(root / "extensions"), + "-out", str(root / "server.pem")], check=True, capture_output=True) + observed = [] + + class Fixture(http.server.BaseHTTPRequestHandler): + def upload(self): + observed.append((self.command, self.rfile.read(int(self.headers["Content-Length"])))) + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + do_POST = upload + do_PUT = upload + + def log_message(self, *_args): + pass + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(root / "server.pem", root / "server.key") + with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fixture) as server: + server.socket = context.wrap_socket(server.socket, server_side=True) + owner = threading.Thread(target=server.serve_forever) + owner.start() + try: + for transport in ("inference", "artifact"): + for trust in ("untrusted", "trusted", "wrong-host"): + env = {**os.environ, "NO_PROXY": "localhost,127.0.0.1"} + env.pop("SSL_CERT_FILE", None) + if trust != "untrusted": env["SSL_CERT_FILE"] = str(root / "ca.pem") + host = "127.0.0.1" if trust == "wrong-host" else "localhost" + process = subprocess.run([str(probe), f"https://{host}:{server.server_port}/", transport], + env=env, capture_output=True, text=True, timeout=4) + assert process.returncode == 0, process.stderr + result = json.loads(process.stdout) + if trust == "trusted": + assert result == {"kind": "nhComplete", "complete": True, "status": 200, "body": "ok"} + else: + assert result == {"kind": "nhTransportFailure", "complete": False, "status": None, "body": ""} + print(transport, trust, result["kind"], flush=True) + assert observed == [("POST", b"private fixture"), ("PUT", b"private fixture")] + finally: + server.shutdown() + owner.join(timeout=4) + assert not owner.is_alive()