1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3#*************************************************************************** 4# _ _ ____ _ 5# Project ___| | | | _ \| | 6# / __| | | | |_) | | 7# | (__| |_| | _ <| |___ 8# \___|\___/|_| \_\_____| 9# 10# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al. 11# 12# This software is licensed as described in the file COPYING, which 13# you should have received as part of this distribution. The terms 14# are also available at https://curl.se/docs/copyright.html. 15# 16# You may opt to use, copy, modify, merge, publish, distribute and/or sell 17# copies of the Software, and permit persons to whom the Software is 18# furnished to do so, under the terms of the COPYING file. 19# 20# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY 21# KIND, either express or implied. 22# 23# SPDX-License-Identifier: curl 24# 25########################################################################### 26# 27import logging 28import os 29import re 30import socket 31import subprocess 32import sys 33from configparser import ConfigParser, ExtendedInterpolation 34from datetime import timedelta 35from typing import Optional 36 37import pytest 38 39from .certs import CertificateSpec, TestCA, Credentials 40from .ports import alloc_ports 41 42 43log = logging.getLogger(__name__) 44 45 46def init_config_from(conf_path): 47 if os.path.isfile(conf_path): 48 config = ConfigParser(interpolation=ExtendedInterpolation()) 49 config.read(conf_path) 50 return config 51 return None 52 53 54TESTS_HTTPD_PATH = os.path.dirname(os.path.dirname(__file__)) 55DEF_CONFIG = init_config_from(os.path.join(TESTS_HTTPD_PATH, 'config.ini')) 56 57TOP_PATH = os.path.dirname(os.path.dirname(TESTS_HTTPD_PATH)) 58CURL = os.path.join(TOP_PATH, 'src/curl') 59 60 61class EnvConfig: 62 63 def __init__(self): 64 self.tests_dir = TESTS_HTTPD_PATH 65 self.gen_dir = os.path.join(self.tests_dir, 'gen') 66 self.project_dir = os.path.dirname(os.path.dirname(self.tests_dir)) 67 self.config = DEF_CONFIG 68 # check cur and its features 69 self.curl = CURL 70 if 'CURL' in os.environ: 71 self.curl = os.environ['CURL'] 72 self.curl_props = { 73 'version': None, 74 'os': None, 75 'fullname': None, 76 'features': [], 77 'protocols': [], 78 'libs': [], 79 'lib_versions': [], 80 } 81 self.curl_is_debug = False 82 self.curl_protos = [] 83 p = subprocess.run(args=[self.curl, '-V'], 84 capture_output=True, text=True) 85 if p.returncode != 0: 86 assert False, f'{self.curl} -V failed with exit code: {p.returncode}' 87 if p.stderr.startswith('WARNING:'): 88 self.curl_is_debug = True 89 for l in p.stdout.splitlines(keepends=False): 90 if l.startswith('curl '): 91 m = re.match(r'^curl (?P<version>\S+) (?P<os>\S+) (?P<libs>.*)$', l) 92 if m: 93 self.curl_props['fullname'] = m.group(0) 94 self.curl_props['version'] = m.group('version') 95 self.curl_props['os'] = m.group('os') 96 self.curl_props['lib_versions'] = [ 97 lib.lower() for lib in m.group('libs').split(' ') 98 ] 99 self.curl_props['libs'] = [ 100 re.sub(r'/.*', '', lib) for lib in self.curl_props['lib_versions'] 101 ] 102 if l.startswith('Features: '): 103 self.curl_props['features'] = [ 104 feat.lower() for feat in l[10:].split(' ') 105 ] 106 if l.startswith('Protocols: '): 107 self.curl_props['protocols'] = [ 108 prot.lower() for prot in l[11:].split(' ') 109 ] 110 111 self.ports = alloc_ports(port_specs={ 112 'ftp': socket.SOCK_STREAM, 113 'ftps': socket.SOCK_STREAM, 114 'http': socket.SOCK_STREAM, 115 'https': socket.SOCK_STREAM, 116 'proxy': socket.SOCK_STREAM, 117 'proxys': socket.SOCK_STREAM, 118 'h2proxys': socket.SOCK_STREAM, 119 'caddy': socket.SOCK_STREAM, 120 'caddys': socket.SOCK_STREAM, 121 'ws': socket.SOCK_STREAM, 122 }) 123 self.httpd = self.config['httpd']['httpd'] 124 self.apachectl = self.config['httpd']['apachectl'] 125 self.apxs = self.config['httpd']['apxs'] 126 if len(self.apxs) == 0: 127 self.apxs = None 128 self._httpd_version = None 129 130 self.examples_pem = { 131 'key': 'xxx', 132 'cert': 'xxx', 133 } 134 self.htdocs_dir = os.path.join(self.gen_dir, 'htdocs') 135 self.tld = 'http.curl.se' 136 self.domain1 = f"one.{self.tld}" 137 self.domain1brotli = f"brotli.one.{self.tld}" 138 self.domain2 = f"two.{self.tld}" 139 self.ftp_domain = f"ftp.{self.tld}" 140 self.proxy_domain = f"proxy.{self.tld}" 141 self.cert_specs = [ 142 CertificateSpec(domains=[self.domain1, self.domain1brotli, 'localhost', '127.0.0.1'], key_type='rsa2048'), 143 CertificateSpec(domains=[self.domain2], key_type='rsa2048'), 144 CertificateSpec(domains=[self.ftp_domain], key_type='rsa2048'), 145 CertificateSpec(domains=[self.proxy_domain, '127.0.0.1'], key_type='rsa2048'), 146 CertificateSpec(name="clientsX", sub_specs=[ 147 CertificateSpec(name="user1", client=True), 148 ]), 149 ] 150 151 self.nghttpx = self.config['nghttpx']['nghttpx'] 152 if len(self.nghttpx.strip()) == 0: 153 self.nghttpx = None 154 self._nghttpx_version = None 155 self.nghttpx_with_h3 = False 156 if self.nghttpx is not None: 157 p = subprocess.run(args=[self.nghttpx, '-v'], 158 capture_output=True, text=True) 159 if p.returncode != 0: 160 # not a working nghttpx 161 self.nghttpx = None 162 else: 163 self._nghttpx_version = re.sub(r'^nghttpx\s*', '', p.stdout.strip()) 164 self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip()) is not None 165 log.debug(f'nghttpx -v: {p.stdout}') 166 167 self.caddy = self.config['caddy']['caddy'] 168 self._caddy_version = None 169 if len(self.caddy.strip()) == 0: 170 self.caddy = None 171 if self.caddy is not None: 172 try: 173 p = subprocess.run(args=[self.caddy, 'version'], 174 capture_output=True, text=True) 175 if p.returncode != 0: 176 # not a working caddy 177 self.caddy = None 178 m = re.match(r'v?(\d+\.\d+\.\d+).*', p.stdout) 179 if m: 180 self._caddy_version = m.group(1) 181 else: 182 raise f'Unable to determine cadd version from: {p.stdout}' 183 except: 184 self.caddy = None 185 186 self.vsftpd = self.config['vsftpd']['vsftpd'] 187 self._vsftpd_version = None 188 if self.vsftpd is not None: 189 try: 190 p = subprocess.run(args=[self.vsftpd, '-v'], 191 capture_output=True, text=True) 192 if p.returncode != 0: 193 # not a working vsftpd 194 self.vsftpd = None 195 m = re.match(r'vsftpd: version (\d+\.\d+\.\d+)', p.stderr) 196 if m: 197 self._vsftpd_version = m.group(1) 198 elif len(p.stderr) == 0: 199 # vsftp does not use stdout or stderr for printing its version... -.- 200 self._vsftpd_version = 'unknown' 201 else: 202 raise Exception(f'Unable to determine VsFTPD version from: {p.stderr}') 203 except Exception as e: 204 self.vsftpd = None 205 206 @property 207 def httpd_version(self): 208 if self._httpd_version is None and self.apxs is not None: 209 try: 210 p = subprocess.run(args=[self.apxs, '-q', 'HTTPD_VERSION'], 211 capture_output=True, text=True) 212 if p.returncode != 0: 213 log.error(f'{self.apxs} failed to query HTTPD_VERSION: {p}') 214 else: 215 self._httpd_version = p.stdout.strip() 216 except Exception as e: 217 log.error(f'{self.apxs} failed to run: {e}') 218 return self._httpd_version 219 220 def versiontuple(self, v): 221 v = re.sub(r'(\d+\.\d+(\.\d+)?)(-\S+)?', r'\1', v) 222 return tuple(map(int, v.split('.'))) 223 224 def httpd_is_at_least(self, minv): 225 if self.httpd_version is None: 226 return False 227 hv = self.versiontuple(self.httpd_version) 228 return hv >= self.versiontuple(minv) 229 230 def caddy_is_at_least(self, minv): 231 if self.caddy_version is None: 232 return False 233 hv = self.versiontuple(self.caddy_version) 234 return hv >= self.versiontuple(minv) 235 236 def is_complete(self) -> bool: 237 return os.path.isfile(self.httpd) and \ 238 os.path.isfile(self.apachectl) and \ 239 self.apxs is not None and \ 240 os.path.isfile(self.apxs) 241 242 def get_incomplete_reason(self) -> Optional[str]: 243 if self.httpd is None or len(self.httpd.strip()) == 0: 244 return f'httpd not configured, see `--with-test-httpd=<path>`' 245 if not os.path.isfile(self.httpd): 246 return f'httpd ({self.httpd}) not found' 247 if not os.path.isfile(self.apachectl): 248 return f'apachectl ({self.apachectl}) not found' 249 if self.apxs is None: 250 return f"command apxs not found (commonly provided in apache2-dev)" 251 if not os.path.isfile(self.apxs): 252 return f"apxs ({self.apxs}) not found" 253 return None 254 255 @property 256 def nghttpx_version(self): 257 return self._nghttpx_version 258 259 @property 260 def caddy_version(self): 261 return self._caddy_version 262 263 @property 264 def vsftpd_version(self): 265 return self._vsftpd_version 266 267 268class Env: 269 270 CONFIG = EnvConfig() 271 272 @staticmethod 273 def setup_incomplete() -> bool: 274 return not Env.CONFIG.is_complete() 275 276 @staticmethod 277 def incomplete_reason() -> Optional[str]: 278 return Env.CONFIG.get_incomplete_reason() 279 280 @staticmethod 281 def have_nghttpx() -> bool: 282 return Env.CONFIG.nghttpx is not None 283 284 @staticmethod 285 def have_h3_server() -> bool: 286 return Env.CONFIG.nghttpx_with_h3 287 288 @staticmethod 289 def have_ssl_curl() -> bool: 290 return 'ssl' in Env.CONFIG.curl_props['features'] 291 292 @staticmethod 293 def have_h2_curl() -> bool: 294 return 'http2' in Env.CONFIG.curl_props['features'] 295 296 @staticmethod 297 def have_h3_curl() -> bool: 298 return 'http3' in Env.CONFIG.curl_props['features'] 299 300 @staticmethod 301 def curl_uses_lib(libname: str) -> bool: 302 return libname.lower() in Env.CONFIG.curl_props['libs'] 303 304 @staticmethod 305 def curl_uses_ossl_quic() -> bool: 306 if Env.have_h3_curl(): 307 return not Env.curl_uses_lib('ngtcp2') and Env.curl_uses_lib('nghttp3') 308 return False 309 310 @staticmethod 311 def curl_has_feature(feature: str) -> bool: 312 return feature.lower() in Env.CONFIG.curl_props['features'] 313 314 @staticmethod 315 def curl_has_protocol(protocol: str) -> bool: 316 return protocol.lower() in Env.CONFIG.curl_props['protocols'] 317 318 @staticmethod 319 def curl_lib_version(libname: str) -> str: 320 prefix = f'{libname.lower()}/' 321 for lversion in Env.CONFIG.curl_props['lib_versions']: 322 if lversion.startswith(prefix): 323 return lversion[len(prefix):] 324 return 'unknown' 325 326 @staticmethod 327 def curl_lib_version_at_least(libname: str, min_version) -> str: 328 lversion = Env.curl_lib_version(libname) 329 if lversion != 'unknown': 330 return Env.CONFIG.versiontuple(min_version) <= \ 331 Env.CONFIG.versiontuple(lversion) 332 return False 333 334 @staticmethod 335 def curl_os() -> str: 336 return Env.CONFIG.curl_props['os'] 337 338 @staticmethod 339 def curl_fullname() -> str: 340 return Env.CONFIG.curl_props['fullname'] 341 342 @staticmethod 343 def curl_version() -> str: 344 return Env.CONFIG.curl_props['version'] 345 346 @staticmethod 347 def curl_is_debug() -> bool: 348 return Env.CONFIG.curl_is_debug 349 350 @staticmethod 351 def have_h3() -> bool: 352 return Env.have_h3_curl() and Env.have_h3_server() 353 354 @staticmethod 355 def httpd_version() -> str: 356 return Env.CONFIG.httpd_version 357 358 @staticmethod 359 def nghttpx_version() -> str: 360 return Env.CONFIG.nghttpx_version 361 362 @staticmethod 363 def caddy_version() -> str: 364 return Env.CONFIG.caddy_version 365 366 @staticmethod 367 def caddy_is_at_least(minv) -> bool: 368 return Env.CONFIG.caddy_is_at_least(minv) 369 370 @staticmethod 371 def httpd_is_at_least(minv) -> bool: 372 return Env.CONFIG.httpd_is_at_least(minv) 373 374 @staticmethod 375 def has_caddy() -> bool: 376 return Env.CONFIG.caddy is not None 377 378 @staticmethod 379 def has_vsftpd() -> bool: 380 return Env.CONFIG.vsftpd is not None 381 382 @staticmethod 383 def vsftpd_version() -> str: 384 return Env.CONFIG.vsftpd_version 385 386 def __init__(self, pytestconfig=None): 387 self._verbose = pytestconfig.option.verbose \ 388 if pytestconfig is not None else 0 389 self._ca = None 390 self._test_timeout = 300.0 if self._verbose > 1 else 60.0 # seconds 391 392 def issue_certs(self): 393 if self._ca is None: 394 ca_dir = os.path.join(self.CONFIG.gen_dir, 'ca') 395 self._ca = TestCA.create_root(name=self.CONFIG.tld, 396 store_dir=ca_dir, 397 key_type="rsa2048") 398 self._ca.issue_certs(self.CONFIG.cert_specs) 399 400 def setup(self): 401 os.makedirs(self.gen_dir, exist_ok=True) 402 os.makedirs(self.htdocs_dir, exist_ok=True) 403 self.issue_certs() 404 405 def get_credentials(self, domain) -> Optional[Credentials]: 406 creds = self.ca.get_credentials_for_name(domain) 407 if len(creds) > 0: 408 return creds[0] 409 return None 410 411 @property 412 def verbose(self) -> int: 413 return self._verbose 414 415 @property 416 def test_timeout(self) -> Optional[float]: 417 return self._test_timeout 418 419 @test_timeout.setter 420 def test_timeout(self, val: Optional[float]): 421 self._test_timeout = val 422 423 @property 424 def gen_dir(self) -> str: 425 return self.CONFIG.gen_dir 426 427 @property 428 def project_dir(self) -> str: 429 return self.CONFIG.project_dir 430 431 @property 432 def ca(self): 433 return self._ca 434 435 @property 436 def htdocs_dir(self) -> str: 437 return self.CONFIG.htdocs_dir 438 439 @property 440 def domain1(self) -> str: 441 return self.CONFIG.domain1 442 443 @property 444 def domain1brotli(self) -> str: 445 return self.CONFIG.domain1brotli 446 447 @property 448 def domain2(self) -> str: 449 return self.CONFIG.domain2 450 451 @property 452 def ftp_domain(self) -> str: 453 return self.CONFIG.ftp_domain 454 455 @property 456 def proxy_domain(self) -> str: 457 return self.CONFIG.proxy_domain 458 459 @property 460 def http_port(self) -> int: 461 return self.CONFIG.ports['http'] 462 463 @property 464 def https_port(self) -> int: 465 return self.CONFIG.ports['https'] 466 467 @property 468 def h3_port(self) -> int: 469 return self.https_port 470 471 @property 472 def proxy_port(self) -> int: 473 return self.CONFIG.ports['proxy'] 474 475 @property 476 def proxys_port(self) -> int: 477 return self.CONFIG.ports['proxys'] 478 479 @property 480 def ftp_port(self) -> int: 481 return self.CONFIG.ports['ftp'] 482 483 @property 484 def ftps_port(self) -> int: 485 return self.CONFIG.ports['ftps'] 486 487 @property 488 def h2proxys_port(self) -> int: 489 return self.CONFIG.ports['h2proxys'] 490 491 def pts_port(self, proto: str = 'http/1.1') -> int: 492 # proxy tunnel port 493 return self.CONFIG.ports['h2proxys' if proto == 'h2' else 'proxys'] 494 495 @property 496 def caddy(self) -> str: 497 return self.CONFIG.caddy 498 499 @property 500 def caddy_https_port(self) -> int: 501 return self.CONFIG.ports['caddys'] 502 503 @property 504 def caddy_http_port(self) -> int: 505 return self.CONFIG.ports['caddy'] 506 507 @property 508 def vsftpd(self) -> str: 509 return self.CONFIG.vsftpd 510 511 @property 512 def ws_port(self) -> int: 513 return self.CONFIG.ports['ws'] 514 515 @property 516 def curl(self) -> str: 517 return self.CONFIG.curl 518 519 @property 520 def httpd(self) -> str: 521 return self.CONFIG.httpd 522 523 @property 524 def apachectl(self) -> str: 525 return self.CONFIG.apachectl 526 527 @property 528 def apxs(self) -> str: 529 return self.CONFIG.apxs 530 531 @property 532 def nghttpx(self) -> Optional[str]: 533 return self.CONFIG.nghttpx 534 535 @property 536 def slow_network(self) -> bool: 537 return "CURL_DBG_SOCK_WBLOCK" in os.environ or \ 538 "CURL_DBG_SOCK_WPARTIAL" in os.environ 539 540 @property 541 def ci_run(self) -> bool: 542 return "CURL_CI" in os.environ 543 544 def authority_for(self, domain: str, alpn_proto: Optional[str] = None): 545 if alpn_proto is None or \ 546 alpn_proto in ['h2', 'http/1.1', 'http/1.0', 'http/0.9']: 547 return f'{domain}:{self.https_port}' 548 if alpn_proto in ['h3']: 549 return f'{domain}:{self.h3_port}' 550 return f'{domain}:{self.http_port}' 551 552 def make_data_file(self, indir: str, fname: str, fsize: int) -> str: 553 fpath = os.path.join(indir, fname) 554 s10 = "0123456789" 555 s = (101 * s10) + s10[0:3] 556 with open(fpath, 'w') as fd: 557 for i in range(int(fsize / 1024)): 558 fd.write(f"{i:09d}-{s}\n") 559 remain = int(fsize % 1024) 560 if remain != 0: 561 i = int(fsize / 1024) + 1 562 s = f"{i:09d}-{s}\n" 563 fd.write(s[0:remain]) 564 return fpath 565 566 def make_clients(self): 567 client_dir = os.path.join(self.project_dir, 'tests/http/clients') 568 p = subprocess.run(['make'], capture_output=True, text=True, 569 cwd=client_dir) 570 if p.returncode != 0: 571 pytest.exit(f"`make`in {client_dir} failed:\n{p.stderr}") 572 return False 573 return True 574