env.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  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. #
  27. import logging
  28. import os
  29. import re
  30. import shutil
  31. import socket
  32. import subprocess
  33. import tempfile
  34. from configparser import ConfigParser, ExtendedInterpolation
  35. from datetime import timedelta
  36. from typing import Optional
  37. from .certs import CertificateSpec, Credentials, TestCA
  38. from .ports import alloc_ports
  39. log = logging.getLogger(__name__)
  40. def init_config_from(conf_path):
  41. if os.path.isfile(conf_path):
  42. config = ConfigParser(interpolation=ExtendedInterpolation())
  43. config.read(conf_path)
  44. return config
  45. return None
  46. TESTS_HTTPD_PATH = os.path.dirname(os.path.dirname(__file__))
  47. TOP_PATH = os.path.join(os.getcwd(), os.path.pardir)
  48. DEF_CONFIG = init_config_from(os.path.join(TOP_PATH, 'tests', 'http', 'config.ini'))
  49. CURL = os.path.join(TOP_PATH, 'src', 'curl')
  50. class EnvConfig:
  51. def __init__(self):
  52. self.tests_dir = TESTS_HTTPD_PATH
  53. self.gen_dir = os.path.join(self.tests_dir, 'gen')
  54. self.project_dir = os.path.dirname(os.path.dirname(self.tests_dir))
  55. self.build_dir = TOP_PATH
  56. self.config = DEF_CONFIG
  57. # check cur and its features
  58. self.curl = CURL
  59. if 'CURL' in os.environ:
  60. self.curl = os.environ['CURL']
  61. self.curl_props = {
  62. 'version_string': '',
  63. 'version': '',
  64. 'os': '',
  65. 'fullname': '',
  66. 'features_string': '',
  67. 'features': set(),
  68. 'protocols_string': '',
  69. 'protocols': set(),
  70. 'libs': set(),
  71. 'lib_versions': set(),
  72. }
  73. self.curl_is_debug = False
  74. self.curl_protos = []
  75. p = subprocess.run(args=[self.curl, '-V'],
  76. capture_output=True, text=True)
  77. if p.returncode != 0:
  78. raise RuntimeError(f'{self.curl} -V failed with exit code: {p.returncode}')
  79. if p.stderr.startswith('WARNING:'):
  80. self.curl_is_debug = True
  81. for line in p.stdout.splitlines(keepends=False):
  82. if line.startswith('curl '):
  83. self.curl_props['version_string'] = line
  84. m = re.match(r'^curl (?P<version>\S+) (?P<os>\S+) (?P<libs>.*)$', line)
  85. if m:
  86. self.curl_props['fullname'] = m.group(0)
  87. self.curl_props['version'] = m.group('version')
  88. self.curl_props['os'] = m.group('os')
  89. self.curl_props['lib_versions'] = {
  90. lib.lower() for lib in m.group('libs').split(' ')
  91. }
  92. self.curl_props['libs'] = {
  93. re.sub(r'/[a-z0-9.-]*', '', lib) for lib in self.curl_props['lib_versions']
  94. }
  95. if line.startswith('Features: '):
  96. self.curl_props['features_string'] = line[10:]
  97. self.curl_props['features'] = {
  98. feat.lower() for feat in line[10:].split(' ')
  99. }
  100. if line.startswith('Protocols: '):
  101. self.curl_props['protocols_string'] = line[11:]
  102. self.curl_props['protocols'] = {
  103. prot.lower() for prot in line[11:].split(' ')
  104. }
  105. self.ports = alloc_ports(port_specs={
  106. 'ftp': socket.SOCK_STREAM,
  107. 'ftps': socket.SOCK_STREAM,
  108. 'http': socket.SOCK_STREAM,
  109. 'https': socket.SOCK_STREAM,
  110. 'nghttpx_https': socket.SOCK_STREAM,
  111. 'proxy': socket.SOCK_STREAM,
  112. 'proxys': socket.SOCK_STREAM,
  113. 'h2proxys': socket.SOCK_STREAM,
  114. 'caddy': socket.SOCK_STREAM,
  115. 'caddys': socket.SOCK_STREAM,
  116. 'ws': socket.SOCK_STREAM,
  117. })
  118. self.httpd = self.config['httpd']['httpd']
  119. self.apachectl = self.config['httpd']['apachectl']
  120. self.apxs = self.config['httpd']['apxs']
  121. if len(self.apxs) == 0:
  122. self.apxs = None
  123. self._httpd_version = None
  124. self.examples_pem = {
  125. 'key': 'xxx',
  126. 'cert': 'xxx',
  127. }
  128. self.htdocs_dir = os.path.join(self.gen_dir, 'htdocs')
  129. self.tld = 'http.curl.se'
  130. self.domain1 = f"one.{self.tld}"
  131. self.domain1brotli = f"brotli.one.{self.tld}"
  132. self.domain2 = f"two.{self.tld}"
  133. self.ftp_domain = f"ftp.{self.tld}"
  134. self.proxy_domain = f"proxy.{self.tld}"
  135. self.expired_domain = f"expired.{self.tld}"
  136. self.cert_specs = [
  137. CertificateSpec(domains=[self.domain1, self.domain1brotli, 'localhost', '127.0.0.1'], key_type='rsa2048'),
  138. CertificateSpec(domains=[self.domain2], key_type='rsa2048'),
  139. CertificateSpec(domains=[self.ftp_domain], key_type='rsa2048'),
  140. CertificateSpec(domains=[self.proxy_domain, '127.0.0.1'], key_type='rsa2048'),
  141. CertificateSpec(domains=[self.expired_domain], key_type='rsa2048',
  142. valid_from=timedelta(days=-100), valid_to=timedelta(days=-10)),
  143. CertificateSpec(name="clientsX", sub_specs=[
  144. CertificateSpec(name="user1", client=True),
  145. ]),
  146. ]
  147. self.nghttpx = self.config['nghttpx']['nghttpx']
  148. if len(self.nghttpx.strip()) == 0:
  149. self.nghttpx = None
  150. self._nghttpx_version = None
  151. self.nghttpx_with_h3 = False
  152. if self.nghttpx is not None:
  153. p = subprocess.run(args=[self.nghttpx, '-v'],
  154. capture_output=True, text=True)
  155. if p.returncode != 0:
  156. # not a working nghttpx
  157. self.nghttpx = None
  158. else:
  159. self._nghttpx_version = re.sub(r'^nghttpx\s*', '', p.stdout.strip())
  160. self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip()) is not None
  161. log.debug(f'nghttpx -v: {p.stdout}')
  162. self.caddy = self.config['caddy']['caddy']
  163. self._caddy_version = None
  164. if len(self.caddy.strip()) == 0:
  165. self.caddy = None
  166. if self.caddy is not None:
  167. try:
  168. p = subprocess.run(args=[self.caddy, 'version'],
  169. capture_output=True, text=True)
  170. if p.returncode != 0:
  171. # not a working caddy
  172. self.caddy = None
  173. m = re.match(r'v?(\d+\.\d+\.\d+).*', p.stdout)
  174. if m:
  175. self._caddy_version = m.group(1)
  176. else:
  177. raise RuntimeError(f'Unable to determine cadd version from: {p.stdout}')
  178. # TODO: specify specific exceptions here
  179. except: # noqa: E722
  180. self.caddy = None
  181. self.vsftpd = self.config['vsftpd']['vsftpd']
  182. self._vsftpd_version = None
  183. if self.vsftpd is not None:
  184. try:
  185. with tempfile.TemporaryFile('w+') as tmp:
  186. p = subprocess.run(args=[self.vsftpd, '-v'],
  187. capture_output=True, text=True, stdin=tmp)
  188. if p.returncode != 0:
  189. # not a working vsftpd
  190. self.vsftpd = None
  191. if p.stderr:
  192. ver_text = p.stderr
  193. else:
  194. # Oddly, some versions of vsftpd write to stdin (!)
  195. # instead of stderr, which is odd but works. If there
  196. # is nothing on stderr, read the file on stdin and use
  197. # any data there instead.
  198. tmp.seek(0)
  199. ver_text = tmp.read()
  200. m = re.match(r'vsftpd: version (\d+\.\d+\.\d+)', ver_text)
  201. if m:
  202. self._vsftpd_version = m.group(1)
  203. elif len(p.stderr) == 0:
  204. # vsftp does not use stdout or stderr for printing its version... -.-
  205. self._vsftpd_version = 'unknown'
  206. else:
  207. raise Exception(f'Unable to determine VsFTPD version from: {p.stderr}')
  208. except Exception:
  209. self.vsftpd = None
  210. self._tcpdump = shutil.which('tcpdump')
  211. @property
  212. def httpd_version(self):
  213. if self._httpd_version is None and self.apxs is not None:
  214. try:
  215. p = subprocess.run(args=[self.apxs, '-q', 'HTTPD_VERSION'],
  216. capture_output=True, text=True)
  217. if p.returncode != 0:
  218. log.error(f'{self.apxs} failed to query HTTPD_VERSION: {p}')
  219. else:
  220. self._httpd_version = p.stdout.strip()
  221. except Exception:
  222. log.exception(f'{self.apxs} failed to run')
  223. return self._httpd_version
  224. def versiontuple(self, v):
  225. v = re.sub(r'(\d+\.\d+(\.\d+)?)(-\S+)?', r'\1', v)
  226. return tuple(map(int, v.split('.')))
  227. def httpd_is_at_least(self, minv):
  228. if self.httpd_version is None:
  229. return False
  230. hv = self.versiontuple(self.httpd_version)
  231. return hv >= self.versiontuple(minv)
  232. def caddy_is_at_least(self, minv):
  233. if self.caddy_version is None:
  234. return False
  235. hv = self.versiontuple(self.caddy_version)
  236. return hv >= self.versiontuple(minv)
  237. def is_complete(self) -> bool:
  238. return os.path.isfile(self.httpd) and \
  239. os.path.isfile(self.apachectl) and \
  240. self.apxs is not None and \
  241. os.path.isfile(self.apxs)
  242. def get_incomplete_reason(self) -> Optional[str]:
  243. if self.httpd is None or len(self.httpd.strip()) == 0:
  244. return '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 "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. @property
  255. def nghttpx_version(self):
  256. return self._nghttpx_version
  257. @property
  258. def caddy_version(self):
  259. return self._caddy_version
  260. @property
  261. def vsftpd_version(self):
  262. return self._vsftpd_version
  263. @property
  264. def tcpdmp(self) -> Optional[str]:
  265. return self._tcpdump
  266. class Env:
  267. CONFIG = EnvConfig()
  268. @staticmethod
  269. def setup_incomplete() -> bool:
  270. return not Env.CONFIG.is_complete()
  271. @staticmethod
  272. def incomplete_reason() -> Optional[str]:
  273. return Env.CONFIG.get_incomplete_reason()
  274. @staticmethod
  275. def have_nghttpx() -> bool:
  276. return Env.CONFIG.nghttpx is not None
  277. @staticmethod
  278. def have_h3_server() -> bool:
  279. return Env.CONFIG.nghttpx_with_h3
  280. @staticmethod
  281. def have_ssl_curl() -> bool:
  282. return Env.curl_has_feature('ssl') or Env.curl_has_feature('multissl')
  283. @staticmethod
  284. def have_h2_curl() -> bool:
  285. return 'http2' in Env.CONFIG.curl_props['features']
  286. @staticmethod
  287. def have_h3_curl() -> bool:
  288. return 'http3' in Env.CONFIG.curl_props['features']
  289. @staticmethod
  290. def curl_uses_lib(libname: str) -> bool:
  291. return libname.lower() in Env.CONFIG.curl_props['libs']
  292. @staticmethod
  293. def curl_uses_ossl_quic() -> bool:
  294. if Env.have_h3_curl():
  295. return not Env.curl_uses_lib('ngtcp2') and Env.curl_uses_lib('nghttp3')
  296. return False
  297. @staticmethod
  298. def curl_version_string() -> str:
  299. return Env.CONFIG.curl_props['version_string']
  300. @staticmethod
  301. def curl_features_string() -> str:
  302. return Env.CONFIG.curl_props['features_string']
  303. @staticmethod
  304. def curl_has_feature(feature: str) -> bool:
  305. return feature.lower() in Env.CONFIG.curl_props['features']
  306. @staticmethod
  307. def curl_protocols_string() -> str:
  308. return Env.CONFIG.curl_props['protocols_string']
  309. @staticmethod
  310. def curl_has_protocol(protocol: str) -> bool:
  311. return protocol.lower() in Env.CONFIG.curl_props['protocols']
  312. @staticmethod
  313. def curl_lib_version(libname: str) -> str:
  314. prefix = f'{libname.lower()}/'
  315. for lversion in Env.CONFIG.curl_props['lib_versions']:
  316. if lversion.startswith(prefix):
  317. return lversion[len(prefix):]
  318. return 'unknown'
  319. @staticmethod
  320. def curl_lib_version_at_least(libname: str, min_version) -> bool:
  321. lversion = Env.curl_lib_version(libname)
  322. if lversion != 'unknown':
  323. return Env.CONFIG.versiontuple(min_version) <= \
  324. Env.CONFIG.versiontuple(lversion)
  325. return False
  326. @staticmethod
  327. def curl_os() -> str:
  328. return Env.CONFIG.curl_props['os']
  329. @staticmethod
  330. def curl_fullname() -> str:
  331. return Env.CONFIG.curl_props['fullname']
  332. @staticmethod
  333. def curl_version() -> str:
  334. return Env.CONFIG.curl_props['version']
  335. @staticmethod
  336. def curl_is_debug() -> bool:
  337. return Env.CONFIG.curl_is_debug
  338. @staticmethod
  339. def have_h3() -> bool:
  340. return Env.have_h3_curl() and Env.have_h3_server()
  341. @staticmethod
  342. def httpd_version() -> str:
  343. return Env.CONFIG.httpd_version
  344. @staticmethod
  345. def nghttpx_version() -> str:
  346. return Env.CONFIG.nghttpx_version
  347. @staticmethod
  348. def caddy_version() -> str:
  349. return Env.CONFIG.caddy_version
  350. @staticmethod
  351. def caddy_is_at_least(minv) -> bool:
  352. return Env.CONFIG.caddy_is_at_least(minv)
  353. @staticmethod
  354. def httpd_is_at_least(minv) -> bool:
  355. return Env.CONFIG.httpd_is_at_least(minv)
  356. @staticmethod
  357. def has_caddy() -> bool:
  358. return Env.CONFIG.caddy is not None
  359. @staticmethod
  360. def has_vsftpd() -> bool:
  361. return Env.CONFIG.vsftpd is not None
  362. @staticmethod
  363. def vsftpd_version() -> str:
  364. return Env.CONFIG.vsftpd_version
  365. @staticmethod
  366. def tcpdump() -> Optional[str]:
  367. return Env.CONFIG.tcpdmp
  368. def __init__(self, pytestconfig=None):
  369. self._verbose = pytestconfig.option.verbose \
  370. if pytestconfig is not None else 0
  371. self._ca = None
  372. self._test_timeout = 300.0 if self._verbose > 1 else 60.0 # seconds
  373. def issue_certs(self):
  374. if self._ca is None:
  375. ca_dir = os.path.join(self.CONFIG.gen_dir, 'ca')
  376. self._ca = TestCA.create_root(name=self.CONFIG.tld,
  377. store_dir=ca_dir,
  378. key_type="rsa2048")
  379. self._ca.issue_certs(self.CONFIG.cert_specs)
  380. def setup(self):
  381. os.makedirs(self.gen_dir, exist_ok=True)
  382. os.makedirs(self.htdocs_dir, exist_ok=True)
  383. self.issue_certs()
  384. def get_credentials(self, domain) -> Optional[Credentials]:
  385. creds = self.ca.get_credentials_for_name(domain)
  386. if len(creds) > 0:
  387. return creds[0]
  388. return None
  389. @property
  390. def verbose(self) -> int:
  391. return self._verbose
  392. @property
  393. def test_timeout(self) -> Optional[float]:
  394. return self._test_timeout
  395. @test_timeout.setter
  396. def test_timeout(self, val: Optional[float]):
  397. self._test_timeout = val
  398. @property
  399. def gen_dir(self) -> str:
  400. return self.CONFIG.gen_dir
  401. @property
  402. def project_dir(self) -> str:
  403. return self.CONFIG.project_dir
  404. @property
  405. def build_dir(self) -> str:
  406. return self.CONFIG.build_dir
  407. @property
  408. def ca(self):
  409. return self._ca
  410. @property
  411. def htdocs_dir(self) -> str:
  412. return self.CONFIG.htdocs_dir
  413. @property
  414. def tld(self) -> str:
  415. return self.CONFIG.tld
  416. @property
  417. def domain1(self) -> str:
  418. return self.CONFIG.domain1
  419. @property
  420. def domain1brotli(self) -> str:
  421. return self.CONFIG.domain1brotli
  422. @property
  423. def domain2(self) -> str:
  424. return self.CONFIG.domain2
  425. @property
  426. def ftp_domain(self) -> str:
  427. return self.CONFIG.ftp_domain
  428. @property
  429. def proxy_domain(self) -> str:
  430. return self.CONFIG.proxy_domain
  431. @property
  432. def expired_domain(self) -> str:
  433. return self.CONFIG.expired_domain
  434. @property
  435. def http_port(self) -> int:
  436. return self.CONFIG.ports['http']
  437. @property
  438. def https_port(self) -> int:
  439. return self.CONFIG.ports['https']
  440. @property
  441. def nghttpx_https_port(self) -> int:
  442. return self.CONFIG.ports['nghttpx_https']
  443. @property
  444. def h3_port(self) -> int:
  445. return self.https_port
  446. @property
  447. def proxy_port(self) -> int:
  448. return self.CONFIG.ports['proxy']
  449. @property
  450. def proxys_port(self) -> int:
  451. return self.CONFIG.ports['proxys']
  452. @property
  453. def ftp_port(self) -> int:
  454. return self.CONFIG.ports['ftp']
  455. @property
  456. def ftps_port(self) -> int:
  457. return self.CONFIG.ports['ftps']
  458. @property
  459. def h2proxys_port(self) -> int:
  460. return self.CONFIG.ports['h2proxys']
  461. def pts_port(self, proto: str = 'http/1.1') -> int:
  462. # proxy tunnel port
  463. return self.CONFIG.ports['h2proxys' if proto == 'h2' else 'proxys']
  464. @property
  465. def caddy(self) -> str:
  466. return self.CONFIG.caddy
  467. @property
  468. def caddy_https_port(self) -> int:
  469. return self.CONFIG.ports['caddys']
  470. @property
  471. def caddy_http_port(self) -> int:
  472. return self.CONFIG.ports['caddy']
  473. @property
  474. def vsftpd(self) -> str:
  475. return self.CONFIG.vsftpd
  476. @property
  477. def ws_port(self) -> int:
  478. return self.CONFIG.ports['ws']
  479. @property
  480. def curl(self) -> str:
  481. return self.CONFIG.curl
  482. @property
  483. def httpd(self) -> str:
  484. return self.CONFIG.httpd
  485. @property
  486. def apachectl(self) -> str:
  487. return self.CONFIG.apachectl
  488. @property
  489. def apxs(self) -> str:
  490. return self.CONFIG.apxs
  491. @property
  492. def nghttpx(self) -> Optional[str]:
  493. return self.CONFIG.nghttpx
  494. @property
  495. def slow_network(self) -> bool:
  496. return "CURL_DBG_SOCK_WBLOCK" in os.environ or \
  497. "CURL_DBG_SOCK_WPARTIAL" in os.environ
  498. @property
  499. def ci_run(self) -> bool:
  500. return "CURL_CI" in os.environ
  501. def port_for(self, alpn_proto: Optional[str] = None):
  502. if alpn_proto is None or \
  503. alpn_proto in ['h2', 'http/1.1', 'http/1.0', 'http/0.9']:
  504. return self.https_port
  505. if alpn_proto in ['h3']:
  506. return self.h3_port
  507. return self.http_port
  508. def authority_for(self, domain: str, alpn_proto: Optional[str] = None):
  509. return f'{domain}:{self.port_for(alpn_proto=alpn_proto)}'
  510. def make_data_file(self, indir: str, fname: str, fsize: int,
  511. line_length: int = 1024) -> str:
  512. if line_length < 11:
  513. raise RuntimeError('line_length less than 11 not supported')
  514. fpath = os.path.join(indir, fname)
  515. s10 = "0123456789"
  516. s = round((line_length / 10) + 1) * s10
  517. s = s[0:line_length-11]
  518. with open(fpath, 'w') as fd:
  519. for i in range(int(fsize / line_length)):
  520. fd.write(f"{i:09d}-{s}\n")
  521. remain = int(fsize % line_length)
  522. if remain != 0:
  523. i = int(fsize / line_length) + 1
  524. fd.write(f"{i:09d}-{s}"[0:remain-1] + "\n")
  525. return fpath