Skip to content

Commit d745046

Browse files
committed
http-binary-cache-store: add tests for tls substitution
1 parent 368352d commit d745046

File tree

3 files changed

+204
-0
lines changed

3 files changed

+204
-0
lines changed

tests/functional/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ suites = [
105105
'build-remote-trustless-should-pass-3.sh',
106106
'build-remote-trustless-should-fail-0.sh',
107107
'build-remote-with-mounted-ssh-ng.sh',
108+
'substituter-ssl-client-cert.sh',
108109
'nar-access.sh',
109110
'impure-eval.sh',
110111
'pure-eval.sh',
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
import http.server
3+
import ssl
4+
import socketserver
5+
import sys
6+
import os
7+
import argparse
8+
from typing import Any
9+
10+
class NixCacheHandler(http.server.BaseHTTPRequestHandler):
11+
protocol_version: str = 'HTTP/1.1'
12+
13+
def do_GET(self) -> None:
14+
# Get client certificate information
15+
try:
16+
client_cert: dict[str, Any] | None = self.request.getpeercert()
17+
except Exception as e:
18+
print(f"Error getting client certificate: {e}", file=sys.stderr)
19+
self.send_error(403, "Invalid client certificate")
20+
return
21+
22+
if not client_cert:
23+
self.send_error(403, "No client certificate provided")
24+
return
25+
26+
# Additional validation - check if certificate chain is valid
27+
subject: tuple[tuple[tuple[str, str], ...], ...] | None = client_cert.get('subject')
28+
if not subject:
29+
self.send_error(403, "Invalid client certificate: No subject")
30+
return
31+
32+
# Log client info
33+
print(f"Client connected: {subject}", file=sys.stderr)
34+
print(f"Path requested: {self.path}", file=sys.stderr)
35+
36+
# Handle nix-cache-info endpoint
37+
if self.path == '/nix-cache-info':
38+
self.send_response(200)
39+
self.send_header('Content-Type', 'text/plain')
40+
self.send_header('Connection', 'close') # Explicitly close after response
41+
test_root: str | None = os.environ.get('TEST_ROOT')
42+
if not test_root:
43+
store_root: str = '/nix/store'
44+
else:
45+
store_root = os.path.join(test_root, 'store')
46+
47+
# Nix cache info format
48+
cache_info: str = f"""StoreDir: {store_root}
49+
WantMassQuery: 1
50+
Priority: 30
51+
"""
52+
self.send_header('Content-Length', str(len(cache_info)))
53+
self.end_headers()
54+
self.wfile.write(cache_info.encode())
55+
self.wfile.flush() # Ensure data is sent
56+
57+
# Handle .narinfo requests
58+
elif self.path.endswith('.narinfo'):
59+
# Return 404 for all narinfo requests (empty cache)
60+
self.send_response(404)
61+
self.send_header('Content-Length', '0')
62+
self.send_header('Connection', 'close')
63+
self.end_headers()
64+
65+
else:
66+
self.send_response(404)
67+
self.send_header('Content-Length', '0')
68+
self.send_header('Connection', 'close')
69+
self.end_headers()
70+
71+
def log_message(self, format: str, *args: Any) -> None:
72+
# Suppress standard logging
73+
pass
74+
75+
def run_server(port: int, certfile: str, keyfile: str, ca_certfile: str) -> None:
76+
# Create SSL context
77+
context: ssl.SSLContext = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
78+
context.load_cert_chain(certfile=certfile, keyfile=keyfile)
79+
context.verify_mode = ssl.VerifyMode.CERT_REQUIRED
80+
context.check_hostname = False # We're not checking hostnames for client certs
81+
context.load_verify_locations(cafile=ca_certfile)
82+
83+
# Create and start server
84+
httpd: socketserver.TCPServer = socketserver.TCPServer(('localhost', port), NixCacheHandler)
85+
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
86+
87+
print(f"Server running on port {port}", file=sys.stderr)
88+
89+
try:
90+
httpd.serve_forever()
91+
except KeyboardInterrupt:
92+
httpd.shutdown()
93+
94+
if __name__ == "__main__":
95+
parser: argparse.ArgumentParser = argparse.ArgumentParser(description='Nix binary cache server with SSL client verification')
96+
parser.add_argument('--port', type=int, default=8443, help='Port to listen on')
97+
parser.add_argument('--cert', required=True, help='Server certificate file')
98+
parser.add_argument('--key', required=True, help='Server private key file')
99+
parser.add_argument('--ca-cert', required=True, help='CA certificate for client verification')
100+
101+
args: argparse.Namespace = parser.parse_args()
102+
103+
run_server(args.port, args.cert, args.key, args.ca_cert)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env bash
2+
3+
# shellcheck source=common.sh
4+
source common.sh
5+
6+
# Generate test certificates using EC keys for faster generation
7+
8+
# Generate CA with EC key
9+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/ca.key" 2>/dev/null
10+
openssl req -new -x509 -days 1 -key "$TEST_ROOT/ca.key" -out "$TEST_ROOT/ca.crt" \
11+
-subj "/C=US/ST=Test/L=Test/O=TestCA/CN=Test CA" 2>/dev/null
12+
13+
# Generate server certificate with EC key
14+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/server.key" 2>/dev/null
15+
openssl req -new -key "$TEST_ROOT/server.key" -out "$TEST_ROOT/server.csr" \
16+
-subj "/C=US/ST=Test/L=Test/O=TestServer/CN=localhost" 2>/dev/null
17+
openssl x509 -req -days 1 -in "$TEST_ROOT/server.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \
18+
-set_serial 01 -out "$TEST_ROOT/server.crt" 2>/dev/null
19+
20+
# Generate client certificate with EC key
21+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/client.key" 2>/dev/null
22+
openssl req -new -key "$TEST_ROOT/client.key" -out "$TEST_ROOT/client.csr" \
23+
-subj "/C=US/ST=Test/L=Test/O=TestClient/CN=Nix Test Client" 2>/dev/null
24+
openssl x509 -req -days 1 -in "$TEST_ROOT/client.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \
25+
-set_serial 02 -out "$TEST_ROOT/client.crt" 2>/dev/null
26+
27+
# Find a free port
28+
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()')
29+
30+
# Start the SSL cache server
31+
python3 "${_NIX_TEST_SOURCE_DIR}/nix-binary-cache-ssl-server.py" \
32+
--port "$PORT" \
33+
--cert "$TEST_ROOT/server.crt" \
34+
--key "$TEST_ROOT/server.key" \
35+
--ca-cert "$TEST_ROOT/ca.crt" &
36+
SERVER_PID=$!
37+
38+
# Function to stop server on exit
39+
stopServer() {
40+
kill "$SERVER_PID" 2>/dev/null || true
41+
wait "$SERVER_PID" 2>/dev/null || true
42+
}
43+
trap stopServer EXIT
44+
45+
tries=0
46+
while ! curl -v -s -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \
47+
"https://localhost:$PORT/nix-cache-info"; do
48+
if (( tries++ >= 50 )); then
49+
if kill -0 "$SERVER_PID" 2>/dev/null; then
50+
echo "Server started but did not respond in time" >&2
51+
else
52+
echo "Server failed to start" >&2
53+
fi
54+
exit 1
55+
fi
56+
sleep 0.1
57+
done
58+
59+
# Test 1: Verify server rejects connections without client certificate
60+
echo "Testing connection without client certificate (should fail)..." >&2
61+
if curl -s -k "https://localhost:$PORT/nix-cache-info" 2>&1 | grep -q "certificate required"; then
62+
echo "FAIL: Server should have rejected connection" >&2
63+
exit 1
64+
fi
65+
66+
# Test 2: Verify server accepts connections with client certificate
67+
echo "Testing connection with client certificate..." >&2
68+
RESPONSE=$(curl -v -s -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \
69+
"https://localhost:$PORT/nix-cache-info")
70+
71+
if ! echo "$RESPONSE" | grepQuiet "StoreDir: "; then
72+
echo "FAIL: Server should have accepted client certificate: $RESPONSE" >&2
73+
exit 1
74+
fi
75+
76+
# Test 3: Test Nix with SSL client certificate parameters
77+
# Set up substituter URL with SSL parameters
78+
sslCache="https://localhost:$PORT?ssl-cert=$TEST_ROOT/client.crt&ssl-key=$TEST_ROOT/client.key"
79+
80+
# Configure Nix to trust our CA
81+
export NIX_SSL_CERT_FILE="$TEST_ROOT/ca.crt"
82+
83+
# Test nix store info
84+
nix store info --store "$sslCache" --json | jq -e '.url' | grepQuiet "https://localhost:$PORT"
85+
86+
# Test 4: Verify incorrect client certificate is rejected
87+
# Generate a different client cert not signed by our CA (also using EC)
88+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/wrong.key" 2>/dev/null
89+
openssl req -new -x509 -days 1 -key "$TEST_ROOT/wrong.key" -out "$TEST_ROOT/wrong.crt" \
90+
-subj "/C=US/ST=Test/L=Test/O=Wrong/CN=Wrong Client" 2>/dev/null
91+
92+
wrongCache="https://localhost:$PORT?ssl-cert=$TEST_ROOT/wrong.crt&ssl-key=$TEST_ROOT/wrong.key"
93+
94+
rm -rf "$TEST_HOME"
95+
96+
# This should fail
97+
if nix store info --download-attempts 0 --store "$wrongCache"; then
98+
echo "FAIL: Should have rejected wrong certificate" >&2
99+
exit 1
100+
fi

0 commit comments

Comments
 (0)