|
| 1 | +"""HTTPS/TLS integration tests. |
| 2 | +
|
| 3 | +Covers the TLS support added via cpp-httplib's Mbed TLS backend: |
| 4 | +serving over HTTPS (--ssl-cert-file/--ssl-key-file), downloading models |
| 5 | +over HTTPS (-hf/--model-url), and rejection of untrusted certificates. |
| 6 | +
|
| 7 | +Toggle with the ``ssl`` marker: ``-m ssl`` / ``-m "not ssl"``. |
| 8 | +``test_download_model_over_https`` needs network access (huggingface.co); |
| 9 | +the other tests run fully offline. |
| 10 | +""" |
| 11 | + |
| 12 | +import os |
| 13 | +import platform |
| 14 | +import shutil |
| 15 | +import subprocess |
| 16 | + |
| 17 | +import pytest |
| 18 | +import requests |
| 19 | + |
| 20 | +from utils.llamafile import LlamafileRunner |
| 21 | + |
| 22 | +# Tiny model used for the network download test (~1 MiB) |
| 23 | +HF_TEST_REPO = "ggml-org/models" |
| 24 | +HF_TEST_FILE = "tinyllamas/stories260K.gguf" |
| 25 | + |
| 26 | +OPENSSL_CNF = """\ |
| 27 | +[req] |
| 28 | +distinguished_name = dn |
| 29 | +x509_extensions = v3_req |
| 30 | +prompt = no |
| 31 | +[dn] |
| 32 | +CN = localhost |
| 33 | +[v3_req] |
| 34 | +subjectAltName = DNS:localhost, IP:127.0.0.1 |
| 35 | +""" |
| 36 | + |
| 37 | + |
| 38 | +@pytest.fixture(scope="module") |
| 39 | +def ssl_cert(tmp_path_factory): |
| 40 | + """Generate a self-signed cert/key pair for localhost. |
| 41 | +
|
| 42 | + Returns (cert_path, key_path). Skips if no openssl binary is available. |
| 43 | + The SAN config file keeps this portable across OpenSSL and LibreSSL |
| 44 | + (macOS ships LibreSSL, where -addext support varies). |
| 45 | + """ |
| 46 | + if not shutil.which("openssl"): |
| 47 | + pytest.skip("openssl binary not available to generate a test certificate") |
| 48 | + |
| 49 | + tmp = tmp_path_factory.mktemp("ssl") |
| 50 | + cnf = tmp / "openssl.cnf" |
| 51 | + cnf.write_text(OPENSSL_CNF) |
| 52 | + cert, key = tmp / "cert.pem", tmp / "key.pem" |
| 53 | + |
| 54 | + subprocess.run( |
| 55 | + [ |
| 56 | + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-sha256", |
| 57 | + "-days", "2", "-nodes", |
| 58 | + "-keyout", str(key), "-out", str(cert), |
| 59 | + "-config", str(cnf), |
| 60 | + ], |
| 61 | + check=True, |
| 62 | + capture_output=True, |
| 63 | + ) |
| 64 | + return cert, key |
| 65 | + |
| 66 | + |
| 67 | +def wait_for_https_server(port, cert, timeout, poll_interval=1.0): |
| 68 | + """Poll https://127.0.0.1:port/health until 200, verifying against cert.""" |
| 69 | + import time |
| 70 | + |
| 71 | + url = f"https://127.0.0.1:{port}/health" |
| 72 | + start = time.time() |
| 73 | + while time.time() - start < timeout: |
| 74 | + try: |
| 75 | + if requests.get(url, timeout=2, verify=str(cert)).status_code == 200: |
| 76 | + return True |
| 77 | + except requests.RequestException: |
| 78 | + pass |
| 79 | + time.sleep(poll_interval) |
| 80 | + return False |
| 81 | + |
| 82 | + |
| 83 | +def base_exe_args(executable): |
| 84 | + """Mimic LlamafileRunner's sh-prefixed invocation for manual Popen.""" |
| 85 | + if platform.system() != "Windows": |
| 86 | + return ["sh", os.path.abspath(executable)] |
| 87 | + return [os.path.abspath(executable)] |
| 88 | + |
| 89 | + |
| 90 | +def fresh_cache_env(cache_dir): |
| 91 | + """Environment pointing the model download cache at a fresh directory.""" |
| 92 | + env = os.environ.copy() |
| 93 | + env["XDG_CACHE_HOME"] = str(cache_dir) |
| 94 | + return env |
| 95 | + |
| 96 | + |
| 97 | +@pytest.mark.ssl |
| 98 | +@pytest.mark.server |
| 99 | +class TestHttpsServing: |
| 100 | + """Serving over TLS with --ssl-cert-file/--ssl-key-file.""" |
| 101 | + |
| 102 | + def test_server_serves_https(self, llamafile, server_port, timeouts, ssl_cert): |
| 103 | + """Server comes up over HTTPS and a verifying client can connect.""" |
| 104 | + cert, key = ssl_cert |
| 105 | + proc = llamafile.start_server( |
| 106 | + port=server_port, |
| 107 | + extra_args=["--ssl-cert-file", str(cert), "--ssl-key-file", str(key)], |
| 108 | + ) |
| 109 | + try: |
| 110 | + assert wait_for_https_server( |
| 111 | + server_port, cert, timeout=timeouts.server_ready |
| 112 | + ), "Server did not become ready over HTTPS" |
| 113 | + |
| 114 | + # Chain + hostname verification against our cert succeeds |
| 115 | + r = requests.get( |
| 116 | + f"https://127.0.0.1:{server_port}/props", |
| 117 | + timeout=timeouts.http_request, |
| 118 | + verify=str(cert), |
| 119 | + ) |
| 120 | + assert r.status_code == 200 |
| 121 | + assert "default_generation_settings" in r.text |
| 122 | + finally: |
| 123 | + proc.terminate() |
| 124 | + proc.wait() |
| 125 | + |
| 126 | + def test_https_server_rejects_plaintext( |
| 127 | + self, llamafile, server_port, timeouts, ssl_cert |
| 128 | + ): |
| 129 | + """A plain-HTTP request to the TLS port must not get an HTTP 200.""" |
| 130 | + cert, key = ssl_cert |
| 131 | + proc = llamafile.start_server( |
| 132 | + port=server_port, |
| 133 | + extra_args=["--ssl-cert-file", str(cert), "--ssl-key-file", str(key)], |
| 134 | + ) |
| 135 | + try: |
| 136 | + assert wait_for_https_server( |
| 137 | + server_port, cert, timeout=timeouts.server_ready |
| 138 | + ), "Server did not become ready over HTTPS" |
| 139 | + |
| 140 | + with pytest.raises(requests.RequestException): |
| 141 | + requests.get(f"http://127.0.0.1:{server_port}/health", timeout=5) |
| 142 | + finally: |
| 143 | + proc.terminate() |
| 144 | + proc.wait() |
| 145 | + |
| 146 | + |
| 147 | +@pytest.mark.ssl |
| 148 | +class TestHttpsDownload: |
| 149 | + """Model downloads over HTTPS (the client side of TLS support).""" |
| 150 | + |
| 151 | + def test_download_model_over_https( |
| 152 | + self, executable, server_port, timeouts, tmp_path |
| 153 | + ): |
| 154 | + """Download a tiny model from Hugging Face over HTTPS and serve it. |
| 155 | +
|
| 156 | + Requires network access. Exercises the full client path including |
| 157 | + the hf.co -> CDN cross-host redirect. A fresh cache directory |
| 158 | + guarantees the download actually happens. |
| 159 | + """ |
| 160 | + cache = tmp_path / "cache" |
| 161 | + cache.mkdir() |
| 162 | + args = base_exe_args(executable) + [ |
| 163 | + "--server", |
| 164 | + "--port", str(server_port), |
| 165 | + "--hf-repo", HF_TEST_REPO, |
| 166 | + "--hf-file", HF_TEST_FILE, |
| 167 | + ] |
| 168 | + proc = subprocess.Popen( |
| 169 | + args, |
| 170 | + stdout=subprocess.DEVNULL, |
| 171 | + stderr=subprocess.DEVNULL, |
| 172 | + env=fresh_cache_env(cache), |
| 173 | + ) |
| 174 | + try: |
| 175 | + assert LlamafileRunner.wait_for_server( |
| 176 | + server_port, timeout=timeouts.server_ready |
| 177 | + ), "Server did not become ready (HTTPS download failed?)" |
| 178 | + |
| 179 | + if platform.system() != "Windows": |
| 180 | + # The downloaded weights landed in our fresh cache |
| 181 | + ggufs = [ |
| 182 | + p |
| 183 | + for p in cache.rglob("*") |
| 184 | + if p.is_file() and p.stat().st_size > 100_000 |
| 185 | + ] |
| 186 | + assert ggufs, "No downloaded model found in fresh cache" |
| 187 | + finally: |
| 188 | + proc.terminate() |
| 189 | + proc.wait() |
| 190 | + |
| 191 | + def test_download_rejects_untrusted_cert( |
| 192 | + self, llamafile, executable, server_port, timeouts, ssl_cert, tmp_path |
| 193 | + ): |
| 194 | + """Downloading from a server with an untrusted (self-signed) cert fails. |
| 195 | +
|
| 196 | + Runs fully offline: our own HTTPS server plays the untrusted host. |
| 197 | + Its /health endpoint would happily serve a body, so if the client's |
| 198 | + certificate verification were broken the download would 'succeed' |
| 199 | + and leave a file in the cache; instead the TLS handshake must fail |
| 200 | + and the cache must stay empty. |
| 201 | + """ |
| 202 | + cert, key = ssl_cert |
| 203 | + rogue_port = server_port + 1 |
| 204 | + server = llamafile.start_server( |
| 205 | + port=rogue_port, |
| 206 | + extra_args=["--ssl-cert-file", str(cert), "--ssl-key-file", str(key)], |
| 207 | + ) |
| 208 | + try: |
| 209 | + assert wait_for_https_server( |
| 210 | + rogue_port, cert, timeout=timeouts.server_ready |
| 211 | + ), "TLS server did not become ready" |
| 212 | + |
| 213 | + cache = tmp_path / "cache" |
| 214 | + cache.mkdir() |
| 215 | + args = base_exe_args(executable) + [ |
| 216 | + "--server", |
| 217 | + "--port", str(server_port), |
| 218 | + "--model-url", f"https://127.0.0.1:{rogue_port}/health", |
| 219 | + ] |
| 220 | + client = subprocess.run( |
| 221 | + args, |
| 222 | + capture_output=True, |
| 223 | + text=True, |
| 224 | + env=fresh_cache_env(cache), |
| 225 | + timeout=timeouts.server_ready, |
| 226 | + ) |
| 227 | + assert client.returncode != 0, "Download from untrusted cert host succeeded" |
| 228 | + leftovers = [p for p in cache.rglob("*") if p.is_file()] |
| 229 | + assert not leftovers, ( |
| 230 | + "Client fetched data from a server with an untrusted certificate: " |
| 231 | + f"{leftovers}" |
| 232 | + ) |
| 233 | + finally: |
| 234 | + server.terminate() |
| 235 | + server.wait() |
0 commit comments