Skip to content

Commit 3762831

Browse files
committed
Rewrite OCSP SCGI test from bash to Python, drop nginx dependency
Replace nginx + bash with a pure-Python HTTP-to-SCGI proxy using stdlib http.server and raw sockets for the SCGI netstring protocol. No external dependencies needed. Remove nginx from CI apt-get installs since it is no longer required for testing.
1 parent 1e7be45 commit 3762831

6 files changed

Lines changed: 297 additions & 509 deletions

File tree

.github/workflows/fsanitize-check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ jobs:
6767
# Don't prompt for anything
6868
export DEBIAN_FRONTEND=noninteractive
6969
sudo apt-get update
70-
# openssl and nginx used for ocsp testing
71-
sudo apt-get install -y openssl nginx
70+
# openssl used for ocsp interop testing
71+
sudo apt-get install -y openssl
7272
7373
- name: Checking cache for wolfssl
7474
uses: actions/cache@v4

.github/workflows/ubuntu-check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ jobs:
1717
# Don't prompt for anything
1818
export DEBIAN_FRONTEND=noninteractive
1919
sudo apt-get update
20-
# openssl and nginx used for ocsp testing
21-
sudo apt-get install -y openssl nginx
20+
# openssl used for ocsp interop testing
21+
sudo apt-get install -y openssl
2222
- uses: actions/checkout@master
2323
with:
2424
repository: wolfssl/wolfssl

tests/ocsp-scgi/include.am

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,5 @@
33
# All paths should be given relative to the root
44

55
# SCGI tests are run as part of make check
6-
# Tests will be skipped if nginx or openssl are not available
7-
dist_noinst_SCRIPTS += tests/ocsp-scgi/ocsp-scgi-test.sh
8-
9-
EXTRA_DIST += tests/ocsp-scgi/scgi_params
6+
# Tests will be skipped if openssl is not available
7+
dist_noinst_SCRIPTS += tests/ocsp-scgi/ocsp-scgi-test.py

tests/ocsp-scgi/ocsp-scgi-test.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
#!/usr/bin/env python3
2+
"""OCSP SCGI integration tests for wolfCLU.
3+
4+
Replaces nginx with a minimal Python HTTP-to-SCGI proxy, eliminating
5+
the nginx dependency. The SCGI protocol is simple enough to implement
6+
inline (netstring header + body).
7+
8+
Test flow:
9+
openssl ocsp (HTTP) -> Python proxy -> wolfssl ocsp -scgi (SCGI)
10+
"""
11+
12+
import http.server
13+
import os
14+
import shutil
15+
import socket
16+
import subprocess
17+
import sys
18+
import tempfile
19+
import threading
20+
import time
21+
import unittest
22+
23+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
24+
from wolfclu_test import WOLFSSL_BIN, CERTS_DIR
25+
26+
HAS_OPENSSL = shutil.which("openssl") is not None
27+
28+
SCGI_PORT = 6961
29+
HTTP_PORT = 8089
30+
31+
INDEX_VALID = (
32+
"V\t991231235959Z\t\t01\tunknown\t"
33+
"/C=US/ST=Montana/L=Bozeman/O=wolfSSL/OU=Support"
34+
"/CN=www.wolfssl.com/emailAddress=info@wolfssl.com\n"
35+
)
36+
INDEX_REVOKED = (
37+
"R\t991231235959Z\t200101000000Z\t01\tunknown\t"
38+
"/C=US/ST=Montana/L=Bozeman/O=wolfSSL/OU=Support"
39+
"/CN=www.wolfssl.com/emailAddress=info@wolfssl.com\n"
40+
)
41+
42+
43+
def _scgi_request(host, port, body, path="/ocsp"):
44+
"""Send an SCGI request and return the raw response body."""
45+
headers = (
46+
"CONTENT_LENGTH\x00" + str(len(body)) + "\x00"
47+
"SCGI\x001\x00"
48+
"REQUEST_METHOD\x00POST\x00"
49+
"REQUEST_URI\x00" + path + "\x00"
50+
"CONTENT_TYPE\x00application/ocsp-request\x00"
51+
)
52+
header_bytes = headers.encode("ascii")
53+
# Netstring: <length>:<data>,
54+
netstring = str(len(header_bytes)).encode() + b":" + header_bytes + b","
55+
56+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
57+
sock.settimeout(10)
58+
try:
59+
sock.connect((host, port))
60+
sock.sendall(netstring + body)
61+
# Read full response
62+
chunks = []
63+
while True:
64+
data = sock.recv(4096)
65+
if not data:
66+
break
67+
chunks.append(data)
68+
return b"".join(chunks)
69+
finally:
70+
sock.close()
71+
72+
73+
class _SCGIProxyHandler(http.server.BaseHTTPRequestHandler):
74+
"""HTTP handler that proxies POST requests to an SCGI backend."""
75+
76+
scgi_host = "127.0.0.1"
77+
scgi_port = SCGI_PORT
78+
79+
def do_POST(self):
80+
length = int(self.headers.get("Content-Length", 0))
81+
body = self.rfile.read(length) if length > 0 else b""
82+
83+
try:
84+
raw = _scgi_request(self.scgi_host, self.scgi_port,
85+
body, self.path)
86+
except Exception as e:
87+
self.send_error(502, str(e))
88+
return
89+
90+
# The SCGI response may include HTTP-style headers followed by
91+
# \r\n\r\n then the body, or it may be raw body only.
92+
if b"\r\n\r\n" in raw:
93+
header_part, resp_body = raw.split(b"\r\n\r\n", 1)
94+
else:
95+
resp_body = raw
96+
self.send_response(200)
97+
self.send_header("Content-Type", "application/ocsp-response")
98+
self.send_header("Content-Length", str(len(resp_body)))
99+
self.end_headers()
100+
self.wfile.write(resp_body)
101+
102+
def log_message(self, format, *args):
103+
pass # suppress request logging
104+
105+
106+
class _HTTPProxy:
107+
"""Runs the HTTP-to-SCGI proxy in a background thread."""
108+
109+
def __init__(self, http_port, scgi_port):
110+
_SCGIProxyHandler.scgi_port = scgi_port
111+
self.server = http.server.HTTPServer(
112+
("127.0.0.1", http_port), _SCGIProxyHandler)
113+
self.thread = threading.Thread(target=self.server.serve_forever,
114+
daemon=True)
115+
116+
def start(self):
117+
self.thread.start()
118+
119+
def stop(self):
120+
self.server.shutdown()
121+
self.thread.join(timeout=5)
122+
123+
124+
@unittest.skipUnless(HAS_OPENSSL, "openssl not available")
125+
class TestOCSPScgi(unittest.TestCase):
126+
127+
@classmethod
128+
def setUpClass(cls):
129+
if not os.path.isdir(CERTS_DIR):
130+
raise unittest.SkipTest("certs directory not found")
131+
132+
# Check OCSP support
133+
r = subprocess.run([WOLFSSL_BIN, "ocsp", "-help"],
134+
capture_output=True, timeout=5)
135+
if r.returncode != 0:
136+
raise unittest.SkipTest("OCSP not supported")
137+
138+
cls._tmpdir = tempfile.mkdtemp()
139+
cls._wolfclu_proc = None
140+
cls._wolfclu_log = None
141+
cls._proxy = _HTTPProxy(HTTP_PORT, SCGI_PORT)
142+
cls._proxy.start()
143+
144+
@classmethod
145+
def tearDownClass(cls):
146+
if cls._wolfclu_proc:
147+
cls._wolfclu_proc.terminate()
148+
try:
149+
cls._wolfclu_proc.wait(timeout=5)
150+
except subprocess.TimeoutExpired:
151+
cls._wolfclu_proc.kill()
152+
if cls._wolfclu_log:
153+
cls._wolfclu_log.close()
154+
if hasattr(cls, "_proxy"):
155+
cls._proxy.stop()
156+
if hasattr(cls, "_tmpdir") and os.path.isdir(cls._tmpdir):
157+
shutil.rmtree(cls._tmpdir, ignore_errors=True)
158+
159+
def _write_index(self, content):
160+
path = os.path.join(self._tmpdir, "index.txt")
161+
with open(path, "w") as f:
162+
f.write(content)
163+
return path
164+
165+
def _start_responder(self, index_content,
166+
rsigner=None, rkey=None):
167+
"""Start wolfssl OCSP SCGI responder."""
168+
if self._wolfclu_proc and self._wolfclu_proc.poll() is None:
169+
self._wolfclu_proc.terminate()
170+
self._wolfclu_proc.wait(timeout=5)
171+
if self._wolfclu_log:
172+
self._wolfclu_log.close()
173+
174+
index = self._write_index(index_content)
175+
if rsigner is None:
176+
rsigner = os.path.join(CERTS_DIR, "ca-cert.pem")
177+
if rkey is None:
178+
rkey = os.path.join(CERTS_DIR, "ca-key.pem")
179+
180+
log_path = os.path.join(self._tmpdir, "scgi.log")
181+
log_file = open(log_path, "w")
182+
proc = subprocess.Popen(
183+
[WOLFSSL_BIN, "ocsp", "-scgi",
184+
"-port", str(SCGI_PORT),
185+
"-index", index,
186+
"-rsigner", rsigner,
187+
"-rkey", rkey,
188+
"-CA", os.path.join(CERTS_DIR, "ca-cert.pem")],
189+
stdout=log_file, stderr=subprocess.STDOUT,
190+
stdin=subprocess.DEVNULL,
191+
)
192+
time.sleep(0.5)
193+
if proc.poll() is not None:
194+
log_file.close()
195+
with open(log_path) as f:
196+
self.fail(f"SCGI responder exited early: {f.read()}")
197+
self.__class__._wolfclu_proc = proc
198+
self.__class__._wolfclu_log = log_file
199+
self._log_path = log_path
200+
201+
def _ocsp_query(self):
202+
"""Run openssl ocsp via the HTTP proxy."""
203+
r = subprocess.run(
204+
["openssl", "ocsp",
205+
"-issuer", os.path.join(CERTS_DIR, "ca-cert.pem"),
206+
"-cert", os.path.join(CERTS_DIR, "server-cert.pem"),
207+
"-CAfile", os.path.join(CERTS_DIR, "ca-cert.pem"),
208+
"-url", f"http://127.0.0.1:{HTTP_PORT}/ocsp"],
209+
capture_output=True, text=True,
210+
stdin=subprocess.DEVNULL, timeout=30,
211+
)
212+
return r.returncode, r.stdout + r.stderr
213+
214+
def test_01_valid_cert(self):
215+
"""Valid certificate should return good status."""
216+
self._start_responder(INDEX_VALID)
217+
rc, out = self._ocsp_query()
218+
self.assertEqual(rc, 0, out)
219+
self.assertIn("good", out.lower())
220+
221+
def test_02_revoked_cert(self):
222+
"""Revoked certificate should return revoked status."""
223+
self._start_responder(INDEX_REVOKED)
224+
rc, out = self._ocsp_query()
225+
self.assertIn("revoked", out.lower())
226+
227+
def test_03_valid_after_revoked(self):
228+
"""Valid cert after revoked index (stateless)."""
229+
self._start_responder(INDEX_VALID)
230+
rc, out = self._ocsp_query()
231+
self.assertEqual(rc, 0, out)
232+
self.assertIn("good", out.lower())
233+
234+
def test_04_multiple_requests(self):
235+
"""Multiple sequential requests should all succeed."""
236+
self._start_responder(INDEX_VALID)
237+
for i in range(3):
238+
with self.subTest(request=i + 1):
239+
rc, out = self._ocsp_query()
240+
self.assertEqual(rc, 0, f"request {i+1} failed: {out}")
241+
242+
def test_05_delegated_responder(self):
243+
"""Valid cert with authorized/delegated responder."""
244+
self._start_responder(
245+
INDEX_VALID,
246+
rsigner=os.path.join(CERTS_DIR, "ocsp-responder-cert.pem"),
247+
rkey=os.path.join(CERTS_DIR, "ocsp-responder-key.pem"))
248+
rc, out = self._ocsp_query()
249+
self.assertEqual(rc, 0, out)
250+
self.assertIn("good", out.lower())
251+
252+
def test_06_delegated_revoked(self):
253+
"""Revoked cert with authorized/delegated responder."""
254+
self._start_responder(
255+
INDEX_REVOKED,
256+
rsigner=os.path.join(CERTS_DIR, "ocsp-responder-cert.pem"),
257+
rkey=os.path.join(CERTS_DIR, "ocsp-responder-key.pem"))
258+
rc, out = self._ocsp_query()
259+
self.assertIn("revoked", out.lower())
260+
261+
def test_07_delegated_multiple(self):
262+
"""Multiple requests with delegated responder."""
263+
self._start_responder(
264+
INDEX_VALID,
265+
rsigner=os.path.join(CERTS_DIR, "ocsp-responder-cert.pem"),
266+
rkey=os.path.join(CERTS_DIR, "ocsp-responder-key.pem"))
267+
for i in range(3):
268+
with self.subTest(request=i + 1):
269+
rc, out = self._ocsp_query()
270+
self.assertEqual(rc, 0, f"request {i+1} failed: {out}")
271+
272+
@unittest.skipIf(sys.platform == "win32",
273+
"TerminateProcess on Windows prevents graceful shutdown")
274+
def test_08_graceful_shutdown(self):
275+
"""Responder should log graceful exit."""
276+
self._start_responder(INDEX_VALID)
277+
self._ocsp_query() # at least one request
278+
279+
self._wolfclu_proc.terminate()
280+
self._wolfclu_proc.wait(timeout=5)
281+
self._wolfclu_log.close()
282+
self.__class__._wolfclu_proc = None
283+
self.__class__._wolfclu_log = None
284+
285+
with open(self._log_path) as f:
286+
log = f.read()
287+
self.assertIn("wolfssl exiting gracefully", log)
288+
289+
290+
if __name__ == "__main__":
291+
unittest.main()

0 commit comments

Comments
 (0)