mirror of
https://github.com/curl/curl.git
synced 2026-07-29 19:49:10 +03:00
tests-httpd: basic infra to run curl against an apache httpd plus nghttpx for h3
- adding '--with-test-httpd=<path>' to configure non-standard apache2
install
- python env and base classes for running httpd
- basic tests for connectivity with h1/h2/h3
- adding test cases for truncated responses in http versions.
- adding goaway test for HTTP/3.
- adding "stuttering" tests with parallel downloads in chunks with
varying delays between chunks.
- adding a curltest module to the httpd server, adding GOAWAY test.
- mod_curltest now installs 2 handlers
- 'echo': writing as response body what came as request body
- 'tweak': with query parameters to tweak response behaviour
- marked known fails as skip for now
Closes #10175
This commit is contained in:
parent
1c5d8acf79
commit
33ac97e1cb
22 changed files with 3016 additions and 46 deletions
31
tests/tests-httpd/testenv/__init__.py
Normal file
31
tests/tests-httpd/testenv/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#***************************************************************************
|
||||
# _ _ ____ _
|
||||
# Project ___| | | | _ \| |
|
||||
# / __| | | | |_) | |
|
||||
# | (__| |_| | _ <| |___
|
||||
# \___|\___/|_| \_\_____|
|
||||
#
|
||||
# Copyright (C) 2008 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# This software is licensed as described in the file COPYING, which
|
||||
# you should have received as part of this distribution. The terms
|
||||
# are also available at https://curl.se/docs/copyright.html.
|
||||
#
|
||||
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
# copies of the Software, and permit persons to whom the Software is
|
||||
# furnished to do so, under the terms of the COPYING file.
|
||||
#
|
||||
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
# KIND, either express or implied.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
#
|
||||
###########################################################################
|
||||
#
|
||||
from .env import Env
|
||||
from .certs import TestCA, Credentials
|
||||
from .httpd import Httpd
|
||||
from .curl import CurlClient, ExecResult
|
||||
from .nghttpx import Nghttpx
|
||||
528
tests/tests-httpd/testenv/certs.py
Normal file
528
tests/tests-httpd/testenv/certs.py
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#***************************************************************************
|
||||
# _ _ ____ _
|
||||
# Project ___| | | | _ \| |
|
||||
# / __| | | | |_) | |
|
||||
# | (__| |_| | _ <| |___
|
||||
# \___|\___/|_| \_\_____|
|
||||
#
|
||||
# Copyright (C) 2008 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# This software is licensed as described in the file COPYING, which
|
||||
# you should have received as part of this distribution. The terms
|
||||
# are also available at https://curl.se/docs/copyright.html.
|
||||
#
|
||||
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
# copies of the Software, and permit persons to whom the Software is
|
||||
# furnished to do so, under the terms of the COPYING file.
|
||||
#
|
||||
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
# KIND, either express or implied.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
#
|
||||
###########################################################################
|
||||
#
|
||||
import os
|
||||
import re
|
||||
from datetime import timedelta, datetime
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, load_pem_private_key
|
||||
from cryptography.x509 import ExtendedKeyUsageOID, NameOID
|
||||
|
||||
|
||||
EC_SUPPORTED = {}
|
||||
EC_SUPPORTED.update([(curve.name.upper(), curve) for curve in [
|
||||
ec.SECP192R1,
|
||||
ec.SECP224R1,
|
||||
ec.SECP256R1,
|
||||
ec.SECP384R1,
|
||||
]])
|
||||
|
||||
|
||||
def _private_key(key_type):
|
||||
if isinstance(key_type, str):
|
||||
key_type = key_type.upper()
|
||||
m = re.match(r'^(RSA)?(\d+)$', key_type)
|
||||
if m:
|
||||
key_type = int(m.group(2))
|
||||
|
||||
if isinstance(key_type, int):
|
||||
return rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_type,
|
||||
backend=default_backend()
|
||||
)
|
||||
if not isinstance(key_type, ec.EllipticCurve) and key_type in EC_SUPPORTED:
|
||||
key_type = EC_SUPPORTED[key_type]
|
||||
return ec.generate_private_key(
|
||||
curve=key_type,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
|
||||
class CertificateSpec:
|
||||
|
||||
def __init__(self, name: Optional[str] = None,
|
||||
domains: Optional[List[str]] = None,
|
||||
email: Optional[str] = None,
|
||||
key_type: Optional[str] = None,
|
||||
single_file: bool = False,
|
||||
valid_from: timedelta = timedelta(days=-1),
|
||||
valid_to: timedelta = timedelta(days=89),
|
||||
client: bool = False,
|
||||
sub_specs: Optional[List['CertificateSpec']] = None):
|
||||
self._name = name
|
||||
self.domains = domains
|
||||
self.client = client
|
||||
self.email = email
|
||||
self.key_type = key_type
|
||||
self.single_file = single_file
|
||||
self.valid_from = valid_from
|
||||
self.valid_to = valid_to
|
||||
self.sub_specs = sub_specs
|
||||
|
||||
@property
|
||||
def name(self) -> Optional[str]:
|
||||
if self._name:
|
||||
return self._name
|
||||
elif self.domains:
|
||||
return self.domains[0]
|
||||
return None
|
||||
|
||||
@property
|
||||
def type(self) -> Optional[str]:
|
||||
if self.domains and len(self.domains):
|
||||
return "server"
|
||||
elif self.client:
|
||||
return "client"
|
||||
elif self.name:
|
||||
return "ca"
|
||||
return None
|
||||
|
||||
|
||||
class Credentials:
|
||||
|
||||
def __init__(self,
|
||||
name: str,
|
||||
cert: Any,
|
||||
pkey: Any,
|
||||
issuer: Optional['Credentials'] = None):
|
||||
self._name = name
|
||||
self._cert = cert
|
||||
self._pkey = pkey
|
||||
self._issuer = issuer
|
||||
self._cert_file = None
|
||||
self._pkey_file = None
|
||||
self._store = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def subject(self) -> x509.Name:
|
||||
return self._cert.subject
|
||||
|
||||
@property
|
||||
def key_type(self):
|
||||
if isinstance(self._pkey, RSAPrivateKey):
|
||||
return f"rsa{self._pkey.key_size}"
|
||||
elif isinstance(self._pkey, EllipticCurvePrivateKey):
|
||||
return f"{self._pkey.curve.name}"
|
||||
else:
|
||||
raise Exception(f"unknown key type: {self._pkey}")
|
||||
|
||||
@property
|
||||
def private_key(self) -> Any:
|
||||
return self._pkey
|
||||
|
||||
@property
|
||||
def certificate(self) -> Any:
|
||||
return self._cert
|
||||
|
||||
@property
|
||||
def cert_pem(self) -> bytes:
|
||||
return self._cert.public_bytes(Encoding.PEM)
|
||||
|
||||
@property
|
||||
def pkey_pem(self) -> bytes:
|
||||
return self._pkey.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL if self.key_type.startswith('rsa') else PrivateFormat.PKCS8,
|
||||
NoEncryption())
|
||||
|
||||
@property
|
||||
def issuer(self) -> Optional['Credentials']:
|
||||
return self._issuer
|
||||
|
||||
def set_store(self, store: 'CertStore'):
|
||||
self._store = store
|
||||
|
||||
def set_files(self, cert_file: str, pkey_file: Optional[str] = None,
|
||||
combined_file: Optional[str] = None):
|
||||
self._cert_file = cert_file
|
||||
self._pkey_file = pkey_file
|
||||
self._combined_file = combined_file
|
||||
|
||||
@property
|
||||
def cert_file(self) -> str:
|
||||
return self._cert_file
|
||||
|
||||
@property
|
||||
def pkey_file(self) -> Optional[str]:
|
||||
return self._pkey_file
|
||||
|
||||
@property
|
||||
def combined_file(self) -> Optional[str]:
|
||||
return self._combined_file
|
||||
|
||||
def get_first(self, name) -> Optional['Credentials']:
|
||||
creds = self._store.get_credentials_for_name(name) if self._store else []
|
||||
return creds[0] if len(creds) else None
|
||||
|
||||
def get_credentials_for_name(self, name) -> List['Credentials']:
|
||||
return self._store.get_credentials_for_name(name) if self._store else []
|
||||
|
||||
def issue_certs(self, specs: List[CertificateSpec],
|
||||
chain: Optional[List['Credentials']] = None) -> List['Credentials']:
|
||||
return [self.issue_cert(spec=spec, chain=chain) for spec in specs]
|
||||
|
||||
def issue_cert(self, spec: CertificateSpec,
|
||||
chain: Optional[List['Credentials']] = None) -> 'Credentials':
|
||||
key_type = spec.key_type if spec.key_type else self.key_type
|
||||
creds = None
|
||||
if self._store:
|
||||
creds = self._store.load_credentials(
|
||||
name=spec.name, key_type=key_type, single_file=spec.single_file, issuer=self)
|
||||
if creds is None:
|
||||
creds = TestCA.create_credentials(spec=spec, issuer=self, key_type=key_type,
|
||||
valid_from=spec.valid_from, valid_to=spec.valid_to)
|
||||
if self._store:
|
||||
self._store.save(creds, single_file=spec.single_file)
|
||||
if spec.type == "ca":
|
||||
self._store.save_chain(creds, "ca", with_root=True)
|
||||
|
||||
if spec.sub_specs:
|
||||
if self._store:
|
||||
sub_store = CertStore(fpath=os.path.join(self._store.path, creds.name))
|
||||
creds.set_store(sub_store)
|
||||
subchain = chain.copy() if chain else []
|
||||
subchain.append(self)
|
||||
creds.issue_certs(spec.sub_specs, chain=subchain)
|
||||
return creds
|
||||
|
||||
|
||||
class CertStore:
|
||||
|
||||
def __init__(self, fpath: str):
|
||||
self._store_dir = fpath
|
||||
if not os.path.exists(self._store_dir):
|
||||
os.makedirs(self._store_dir)
|
||||
self._creds_by_name = {}
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._store_dir
|
||||
|
||||
def save(self, creds: Credentials, name: Optional[str] = None,
|
||||
chain: Optional[List[Credentials]] = None,
|
||||
single_file: bool = False) -> None:
|
||||
name = name if name is not None else creds.name
|
||||
cert_file = self.get_cert_file(name=name, key_type=creds.key_type)
|
||||
pkey_file = self.get_pkey_file(name=name, key_type=creds.key_type)
|
||||
comb_file = self.get_combined_file(name=name, key_type=creds.key_type)
|
||||
if single_file:
|
||||
pkey_file = None
|
||||
with open(cert_file, "wb") as fd:
|
||||
fd.write(creds.cert_pem)
|
||||
if chain:
|
||||
for c in chain:
|
||||
fd.write(c.cert_pem)
|
||||
if pkey_file is None:
|
||||
fd.write(creds.pkey_pem)
|
||||
if pkey_file is not None:
|
||||
with open(pkey_file, "wb") as fd:
|
||||
fd.write(creds.pkey_pem)
|
||||
with open(comb_file, "wb") as fd:
|
||||
fd.write(creds.cert_pem)
|
||||
if chain:
|
||||
for c in chain:
|
||||
fd.write(c.cert_pem)
|
||||
fd.write(creds.pkey_pem)
|
||||
creds.set_files(cert_file, pkey_file, comb_file)
|
||||
self._add_credentials(name, creds)
|
||||
|
||||
def save_chain(self, creds: Credentials, infix: str, with_root=False):
|
||||
name = creds.name
|
||||
chain = [creds]
|
||||
while creds.issuer is not None:
|
||||
creds = creds.issuer
|
||||
chain.append(creds)
|
||||
if not with_root and len(chain) > 1:
|
||||
chain = chain[:-1]
|
||||
chain_file = os.path.join(self._store_dir, f'{name}-{infix}.pem')
|
||||
with open(chain_file, "wb") as fd:
|
||||
for c in chain:
|
||||
fd.write(c.cert_pem)
|
||||
|
||||
def _add_credentials(self, name: str, creds: Credentials):
|
||||
if name not in self._creds_by_name:
|
||||
self._creds_by_name[name] = []
|
||||
self._creds_by_name[name].append(creds)
|
||||
|
||||
def get_credentials_for_name(self, name) -> List[Credentials]:
|
||||
return self._creds_by_name[name] if name in self._creds_by_name else []
|
||||
|
||||
def get_cert_file(self, name: str, key_type=None) -> str:
|
||||
key_infix = ".{0}".format(key_type) if key_type is not None else ""
|
||||
return os.path.join(self._store_dir, f'{name}{key_infix}.cert.pem')
|
||||
|
||||
def get_pkey_file(self, name: str, key_type=None) -> str:
|
||||
key_infix = ".{0}".format(key_type) if key_type is not None else ""
|
||||
return os.path.join(self._store_dir, f'{name}{key_infix}.pkey.pem')
|
||||
|
||||
def get_combined_file(self, name: str, key_type=None) -> str:
|
||||
return os.path.join(self._store_dir, f'{name}.pem')
|
||||
|
||||
def load_pem_cert(self, fpath: str) -> x509.Certificate:
|
||||
with open(fpath) as fd:
|
||||
return x509.load_pem_x509_certificate("".join(fd.readlines()).encode())
|
||||
|
||||
def load_pem_pkey(self, fpath: str):
|
||||
with open(fpath) as fd:
|
||||
return load_pem_private_key("".join(fd.readlines()).encode(), password=None)
|
||||
|
||||
def load_credentials(self, name: str, key_type=None,
|
||||
single_file: bool = False,
|
||||
issuer: Optional[Credentials] = None):
|
||||
cert_file = self.get_cert_file(name=name, key_type=key_type)
|
||||
pkey_file = cert_file if single_file else self.get_pkey_file(name=name, key_type=key_type)
|
||||
comb_file = self.get_combined_file(name=name, key_type=key_type)
|
||||
if os.path.isfile(cert_file) and os.path.isfile(pkey_file):
|
||||
cert = self.load_pem_cert(cert_file)
|
||||
pkey = self.load_pem_pkey(pkey_file)
|
||||
creds = Credentials(name=name, cert=cert, pkey=pkey, issuer=issuer)
|
||||
creds.set_store(self)
|
||||
creds.set_files(cert_file, pkey_file, comb_file)
|
||||
self._add_credentials(name, creds)
|
||||
return creds
|
||||
return None
|
||||
|
||||
|
||||
class TestCA:
|
||||
|
||||
@classmethod
|
||||
def create_root(cls, name: str, store_dir: str, key_type: str = "rsa2048") -> Credentials:
|
||||
store = CertStore(fpath=store_dir)
|
||||
creds = store.load_credentials(name="ca", key_type=key_type, issuer=None)
|
||||
if creds is None:
|
||||
creds = TestCA._make_ca_credentials(name=name, key_type=key_type)
|
||||
store.save(creds, name="ca")
|
||||
creds.set_store(store)
|
||||
return creds
|
||||
|
||||
@staticmethod
|
||||
def create_credentials(spec: CertificateSpec, issuer: Credentials, key_type: Any,
|
||||
valid_from: timedelta = timedelta(days=-1),
|
||||
valid_to: timedelta = timedelta(days=89),
|
||||
) -> Credentials:
|
||||
"""Create a certificate signed by this CA for the given domains.
|
||||
:returns: the certificate and private key PEM file paths
|
||||
"""
|
||||
if spec.domains and len(spec.domains):
|
||||
creds = TestCA._make_server_credentials(name=spec.name, domains=spec.domains,
|
||||
issuer=issuer, valid_from=valid_from,
|
||||
valid_to=valid_to, key_type=key_type)
|
||||
elif spec.client:
|
||||
creds = TestCA._make_client_credentials(name=spec.name, issuer=issuer,
|
||||
email=spec.email, valid_from=valid_from,
|
||||
valid_to=valid_to, key_type=key_type)
|
||||
elif spec.name:
|
||||
creds = TestCA._make_ca_credentials(name=spec.name, issuer=issuer,
|
||||
valid_from=valid_from, valid_to=valid_to,
|
||||
key_type=key_type)
|
||||
else:
|
||||
raise Exception(f"unrecognized certificate specification: {spec}")
|
||||
return creds
|
||||
|
||||
@staticmethod
|
||||
def _make_x509_name(org_name: str = None, common_name: str = None, parent: x509.Name = None) -> x509.Name:
|
||||
name_pieces = []
|
||||
if org_name:
|
||||
oid = NameOID.ORGANIZATIONAL_UNIT_NAME if parent else NameOID.ORGANIZATION_NAME
|
||||
name_pieces.append(x509.NameAttribute(oid, org_name))
|
||||
elif common_name:
|
||||
name_pieces.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name))
|
||||
if parent:
|
||||
name_pieces.extend([rdn for rdn in parent])
|
||||
return x509.Name(name_pieces)
|
||||
|
||||
@staticmethod
|
||||
def _make_csr(
|
||||
subject: x509.Name,
|
||||
pkey: Any,
|
||||
issuer_subject: Optional[Credentials],
|
||||
valid_from_delta: timedelta = None,
|
||||
valid_until_delta: timedelta = None
|
||||
):
|
||||
pubkey = pkey.public_key()
|
||||
issuer_subject = issuer_subject if issuer_subject is not None else subject
|
||||
|
||||
valid_from = datetime.now()
|
||||
if valid_until_delta is not None:
|
||||
valid_from += valid_from_delta
|
||||
valid_until = datetime.now()
|
||||
if valid_until_delta is not None:
|
||||
valid_until += valid_until_delta
|
||||
|
||||
return (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer_subject)
|
||||
.public_key(pubkey)
|
||||
.not_valid_before(valid_from)
|
||||
.not_valid_after(valid_until)
|
||||
.serial_number(x509.random_serial_number())
|
||||
.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(pubkey),
|
||||
critical=False,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_ca_usages(csr: Any) -> Any:
|
||||
return csr.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=9),
|
||||
critical=True,
|
||||
).add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=True,
|
||||
encipher_only=False,
|
||||
decipher_only=False),
|
||||
critical=True
|
||||
).add_extension(
|
||||
x509.ExtendedKeyUsage([
|
||||
ExtendedKeyUsageOID.CLIENT_AUTH,
|
||||
ExtendedKeyUsageOID.SERVER_AUTH,
|
||||
ExtendedKeyUsageOID.CODE_SIGNING,
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_leaf_usages(csr: Any, domains: List[str], issuer: Credentials) -> Any:
|
||||
return csr.add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None),
|
||||
critical=True,
|
||||
).add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
|
||||
issuer.certificate.extensions.get_extension_for_class(
|
||||
x509.SubjectKeyIdentifier).value),
|
||||
critical=False
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains]),
|
||||
critical=True,
|
||||
).add_extension(
|
||||
x509.ExtendedKeyUsage([
|
||||
ExtendedKeyUsageOID.SERVER_AUTH,
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_client_usages(csr: Any, issuer: Credentials, rfc82name: str = None) -> Any:
|
||||
cert = csr.add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None),
|
||||
critical=True,
|
||||
).add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
|
||||
issuer.certificate.extensions.get_extension_for_class(
|
||||
x509.SubjectKeyIdentifier).value),
|
||||
critical=False
|
||||
)
|
||||
if rfc82name:
|
||||
cert.add_extension(
|
||||
x509.SubjectAlternativeName([x509.RFC822Name(rfc82name)]),
|
||||
critical=True,
|
||||
)
|
||||
cert.add_extension(
|
||||
x509.ExtendedKeyUsage([
|
||||
ExtendedKeyUsageOID.CLIENT_AUTH,
|
||||
]),
|
||||
critical=True
|
||||
)
|
||||
return cert
|
||||
|
||||
@staticmethod
|
||||
def _make_ca_credentials(name, key_type: Any,
|
||||
issuer: Credentials = None,
|
||||
valid_from: timedelta = timedelta(days=-1),
|
||||
valid_to: timedelta = timedelta(days=89),
|
||||
) -> Credentials:
|
||||
pkey = _private_key(key_type=key_type)
|
||||
if issuer is not None:
|
||||
issuer_subject = issuer.certificate.subject
|
||||
issuer_key = issuer.private_key
|
||||
else:
|
||||
issuer_subject = None
|
||||
issuer_key = pkey
|
||||
subject = TestCA._make_x509_name(org_name=name, parent=issuer.subject if issuer else None)
|
||||
csr = TestCA._make_csr(subject=subject,
|
||||
issuer_subject=issuer_subject, pkey=pkey,
|
||||
valid_from_delta=valid_from, valid_until_delta=valid_to)
|
||||
csr = TestCA._add_ca_usages(csr)
|
||||
cert = csr.sign(private_key=issuer_key,
|
||||
algorithm=hashes.SHA256(),
|
||||
backend=default_backend())
|
||||
return Credentials(name=name, cert=cert, pkey=pkey, issuer=issuer)
|
||||
|
||||
@staticmethod
|
||||
def _make_server_credentials(name: str, domains: List[str], issuer: Credentials,
|
||||
key_type: Any,
|
||||
valid_from: timedelta = timedelta(days=-1),
|
||||
valid_to: timedelta = timedelta(days=89),
|
||||
) -> Credentials:
|
||||
name = name
|
||||
pkey = _private_key(key_type=key_type)
|
||||
subject = TestCA._make_x509_name(common_name=name, parent=issuer.subject)
|
||||
csr = TestCA._make_csr(subject=subject,
|
||||
issuer_subject=issuer.certificate.subject, pkey=pkey,
|
||||
valid_from_delta=valid_from, valid_until_delta=valid_to)
|
||||
csr = TestCA._add_leaf_usages(csr, domains=domains, issuer=issuer)
|
||||
cert = csr.sign(private_key=issuer.private_key,
|
||||
algorithm=hashes.SHA256(),
|
||||
backend=default_backend())
|
||||
return Credentials(name=name, cert=cert, pkey=pkey, issuer=issuer)
|
||||
|
||||
@staticmethod
|
||||
def _make_client_credentials(name: str,
|
||||
issuer: Credentials, email: Optional[str],
|
||||
key_type: Any,
|
||||
valid_from: timedelta = timedelta(days=-1),
|
||||
valid_to: timedelta = timedelta(days=89),
|
||||
) -> Credentials:
|
||||
pkey = _private_key(key_type=key_type)
|
||||
subject = TestCA._make_x509_name(common_name=name, parent=issuer.subject)
|
||||
csr = TestCA._make_csr(subject=subject,
|
||||
issuer_subject=issuer.certificate.subject, pkey=pkey,
|
||||
valid_from_delta=valid_from, valid_until_delta=valid_to)
|
||||
csr = TestCA._add_client_usages(csr, issuer=issuer, rfc82name=email)
|
||||
cert = csr.sign(private_key=issuer.private_key,
|
||||
algorithm=hashes.SHA256(),
|
||||
backend=default_backend())
|
||||
return Credentials(name=name, cert=cert, pkey=pkey, issuer=issuer)
|
||||
360
tests/tests-httpd/testenv/curl.py
Normal file
360
tests/tests-httpd/testenv/curl.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#***************************************************************************
|
||||
# _ _ ____ _
|
||||
# Project ___| | | | _ \| |
|
||||
# / __| | | | |_) | |
|
||||
# | (__| |_| | _ <| |___
|
||||
# \___|\___/|_| \_\_____|
|
||||
#
|
||||
# Copyright (C) 2008 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# This software is licensed as described in the file COPYING, which
|
||||
# you should have received as part of this distribution. The terms
|
||||
# are also available at https://curl.se/docs/copyright.html.
|
||||
#
|
||||
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
# copies of the Software, and permit persons to whom the Software is
|
||||
# furnished to do so, under the terms of the COPYING file.
|
||||
#
|
||||
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
# KIND, either express or implied.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
#
|
||||
###########################################################################
|
||||
#
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import timedelta, datetime
|
||||
from typing import List, Optional, Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .env import Env
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExecResult:
|
||||
|
||||
def __init__(self, args: List[str], exit_code: int,
|
||||
stdout: List[str], stderr: List[str],
|
||||
duration: Optional[timedelta] = None,
|
||||
with_stats: bool = False):
|
||||
self._args = args
|
||||
self._exit_code = exit_code
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self._duration = duration if duration is not None else timedelta()
|
||||
self._response = None
|
||||
self._responses = []
|
||||
self._results = {}
|
||||
self._assets = []
|
||||
self._stats = []
|
||||
self._json_out = None
|
||||
self._with_stats = with_stats
|
||||
if with_stats:
|
||||
self._parse_stats()
|
||||
else:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
out = ''.join(self._stdout)
|
||||
self._json_out = json.loads(out)
|
||||
except:
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return f"ExecResult[code={self.exit_code}, args={self._args}, stdout={self._stdout}, stderr={self._stderr}]"
|
||||
|
||||
def _parse_stats(self):
|
||||
self._stats = []
|
||||
for l in self._stdout:
|
||||
try:
|
||||
self._stats.append(json.loads(l))
|
||||
except:
|
||||
log.error(f'not a JSON stat: {l}')
|
||||
log.error(f'stdout is: {"".join(self._stdout)}')
|
||||
break
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
return self._exit_code
|
||||
|
||||
@property
|
||||
def args(self) -> List[str]:
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def outraw(self) -> bytes:
|
||||
return ''.join(self._stdout).encode()
|
||||
|
||||
@property
|
||||
def stdout(self) -> str:
|
||||
return ''.join(self._stdout)
|
||||
|
||||
@property
|
||||
def json(self) -> Optional[Dict]:
|
||||
"""Output as JSON dictionary or None if not parseable."""
|
||||
return self._json_out
|
||||
|
||||
@property
|
||||
def stderr(self) -> str:
|
||||
return ''.join(self._stderr)
|
||||
|
||||
@property
|
||||
def duration(self) -> timedelta:
|
||||
return self._duration
|
||||
|
||||
@property
|
||||
def response(self) -> Optional[Dict]:
|
||||
return self._response
|
||||
|
||||
@property
|
||||
def responses(self) -> List[Dict]:
|
||||
return self._responses
|
||||
|
||||
@property
|
||||
def results(self) -> Dict:
|
||||
return self._results
|
||||
|
||||
@property
|
||||
def assets(self) -> List:
|
||||
return self._assets
|
||||
|
||||
@property
|
||||
def with_stats(self) -> bool:
|
||||
return self._with_stats
|
||||
|
||||
@property
|
||||
def stats(self) -> List:
|
||||
return self._stats
|
||||
|
||||
@property
|
||||
def total_connects(self) -> Optional[int]:
|
||||
if len(self.stats):
|
||||
n = 0
|
||||
for stat in self.stats:
|
||||
n += stat['num_connects']
|
||||
return n
|
||||
return None
|
||||
|
||||
def add_response(self, resp: Dict):
|
||||
self._response = resp
|
||||
self._responses.append(resp)
|
||||
|
||||
def add_results(self, results: Dict):
|
||||
self._results.update(results)
|
||||
if 'response' in results:
|
||||
self.add_response(results['response'])
|
||||
|
||||
def add_assets(self, assets: List):
|
||||
self._assets.extend(assets)
|
||||
|
||||
def check_responses(self, count: int, exp_status: Optional[int] = None):
|
||||
if len(self.responses) != count:
|
||||
seen_queries = []
|
||||
for idx, resp in enumerate(self.responses):
|
||||
assert resp['status'] == 200, f'response #{idx} status: {resp["status"]}'
|
||||
if 'rquery' not in resp['header']:
|
||||
log.error(f'response #{idx} missing "rquery": {resp["header"]}')
|
||||
seen_queries.append(int(resp['header']['rquery']))
|
||||
for i in range(0, count-1):
|
||||
if i not in seen_queries:
|
||||
log.error(f'response for query {i} missing')
|
||||
if self.with_stats and len(self.stats) == count:
|
||||
log.error(f'got all {count} stats, though')
|
||||
assert len(self.responses) == count, \
|
||||
f'response count: expected {count}, got {len(self.responses)}'
|
||||
if exp_status is not None:
|
||||
for idx, x in enumerate(self.responses):
|
||||
assert x['status'] == exp_status, \
|
||||
f'response #{idx} unexpectedstatus: {x["status"]}'
|
||||
if self.with_stats:
|
||||
assert len(self.stats) == count, f'{self}'
|
||||
|
||||
|
||||
class CurlClient:
|
||||
|
||||
ALPN_ARG = {
|
||||
'http/0.9': '--http0.9',
|
||||
'http/1.0': '--http1.0',
|
||||
'http/1.1': '--http1.1',
|
||||
'h2': '--http2',
|
||||
'h2c': '--http2',
|
||||
'h3': '--http3',
|
||||
}
|
||||
|
||||
def __init__(self, env: Env, run_dir: Optional[str] = None):
|
||||
self.env = env
|
||||
self._curl = os.environ['CURL'] if 'CURL' in os.environ else env.curl
|
||||
self._run_dir = run_dir if run_dir else os.path.join(env.gen_dir, 'curl')
|
||||
self._stdoutfile = f'{self._run_dir}/curl.stdout'
|
||||
self._stderrfile = f'{self._run_dir}/curl.stderr'
|
||||
self._headerfile = f'{self._run_dir}/curl.headers'
|
||||
self._tracefile = f'{self._run_dir}/curl.trace'
|
||||
self._log_path = f'{self._run_dir}/curl.log'
|
||||
self._rmrf(self._run_dir)
|
||||
self._mkpath(self._run_dir)
|
||||
|
||||
def _rmf(self, path):
|
||||
if os.path.exists(path):
|
||||
return os.remove(path)
|
||||
|
||||
def _rmrf(self, path):
|
||||
if os.path.exists(path):
|
||||
return shutil.rmtree(path)
|
||||
|
||||
def _mkpath(self, path):
|
||||
if not os.path.exists(path):
|
||||
return os.makedirs(path)
|
||||
|
||||
def http_get(self, url: str, extra_args: Optional[List[str]] = None):
|
||||
return self._raw(url, options=extra_args, with_stats=False)
|
||||
|
||||
def http_download(self, urls: List[str],
|
||||
alpn_proto: Optional[str] = None,
|
||||
with_stats: bool = True,
|
||||
extra_args: List[str] = None):
|
||||
if extra_args is None:
|
||||
extra_args = []
|
||||
extra_args.extend([
|
||||
'-o', 'download.data',
|
||||
])
|
||||
if with_stats:
|
||||
extra_args.extend([
|
||||
'-w', '%{json}\\n'
|
||||
])
|
||||
return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
|
||||
with_stats=with_stats)
|
||||
|
||||
def _run(self, args, intext='', with_stats: bool = False):
|
||||
self._rmf(self._stdoutfile)
|
||||
self._rmf(self._stderrfile)
|
||||
self._rmf(self._headerfile)
|
||||
self._rmf(self._tracefile)
|
||||
start = datetime.now()
|
||||
with open(self._stdoutfile, 'w') as cout:
|
||||
with open(self._stderrfile, 'w') as cerr:
|
||||
p = subprocess.run(args, stderr=cerr, stdout=cout,
|
||||
cwd=self._run_dir, shell=False,
|
||||
input=intext.encode() if intext else None)
|
||||
coutput = open(self._stdoutfile).readlines()
|
||||
cerrput = open(self._stderrfile).readlines()
|
||||
return ExecResult(args=args, exit_code=p.returncode,
|
||||
stdout=coutput, stderr=cerrput,
|
||||
duration=datetime.now() - start,
|
||||
with_stats=with_stats)
|
||||
|
||||
def _raw(self, urls, timeout=10, options=None, insecure=False,
|
||||
alpn_proto: Optional[str] = None,
|
||||
force_resolve=True, with_stats=False):
|
||||
args = self._complete_args(
|
||||
urls=urls, timeout=timeout, options=options, insecure=insecure,
|
||||
alpn_proto=alpn_proto, force_resolve=force_resolve)
|
||||
r = self._run(args, with_stats=with_stats)
|
||||
if r.exit_code == 0:
|
||||
self._parse_headerfile(self._headerfile, r=r)
|
||||
if r.json:
|
||||
r.response["json"] = r.json
|
||||
return r
|
||||
|
||||
def _complete_args(self, urls, timeout=None, options=None,
|
||||
insecure=False, force_resolve=True,
|
||||
alpn_proto: Optional[str] = None):
|
||||
if not isinstance(urls, list):
|
||||
urls = [urls]
|
||||
|
||||
args = [
|
||||
self._curl, "-s", "--path-as-is", "-D", self._headerfile,
|
||||
]
|
||||
if self.env.verbose > 2:
|
||||
args.extend(['--trace', self._tracefile])
|
||||
|
||||
for url in urls:
|
||||
u = urlparse(urls[0])
|
||||
if alpn_proto is not None:
|
||||
if alpn_proto not in self.ALPN_ARG:
|
||||
raise Exception(f'unknown ALPN protocol: "{alpn_proto}"')
|
||||
args.append(self.ALPN_ARG[alpn_proto])
|
||||
|
||||
if u.scheme == 'http':
|
||||
pass
|
||||
elif insecure:
|
||||
args.append('--insecure')
|
||||
elif options and "--cacert" in options:
|
||||
pass
|
||||
elif u.hostname:
|
||||
args.extend(["--cacert", self.env.ca.cert_file])
|
||||
|
||||
if force_resolve and u.hostname and u.hostname != 'localhost' \
|
||||
and not re.match(r'^(\d+|\[|:).*', u.hostname):
|
||||
port = u.port if u.port else 443
|
||||
args.extend(["--resolve", f"{u.hostname}:{port}:127.0.0.1"])
|
||||
if timeout is not None and int(timeout) > 0:
|
||||
args.extend(["--connect-timeout", str(int(timeout))])
|
||||
if options:
|
||||
args.extend(options)
|
||||
args.append(url)
|
||||
return args
|
||||
|
||||
def _parse_headerfile(self, headerfile: str, r: ExecResult = None) -> ExecResult:
|
||||
lines = open(headerfile).readlines()
|
||||
if r is None:
|
||||
r = ExecResult(args=[], exit_code=0, stdout=[], stderr=[])
|
||||
|
||||
response = None
|
||||
|
||||
def fin_response(resp):
|
||||
if resp:
|
||||
r.add_response(resp)
|
||||
|
||||
expected = ['status']
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if re.match(r'^$', line):
|
||||
if 'trailer' in expected:
|
||||
# end of trailers
|
||||
fin_response(response)
|
||||
response = None
|
||||
expected = ['status']
|
||||
elif 'header' in expected:
|
||||
# end of header, another status or trailers might follow
|
||||
expected = ['status', 'trailer']
|
||||
else:
|
||||
assert False, f"unexpected line: '{line}'"
|
||||
continue
|
||||
if 'status' in expected:
|
||||
# log.debug("reading 1st response line: %s", line)
|
||||
m = re.match(r'^(\S+) (\d+)( .*)?$', line)
|
||||
if m:
|
||||
fin_response(response)
|
||||
response = {
|
||||
"protocol": m.group(1),
|
||||
"status": int(m.group(2)),
|
||||
"description": m.group(3),
|
||||
"header": {},
|
||||
"trailer": {},
|
||||
"body": r.outraw
|
||||
}
|
||||
expected = ['header']
|
||||
continue
|
||||
if 'trailer' in expected:
|
||||
m = re.match(r'^([^:]+):\s*(.*)$', line)
|
||||
if m:
|
||||
response['trailer'][m.group(1).lower()] = m.group(2)
|
||||
continue
|
||||
if 'header' in expected:
|
||||
m = re.match(r'^([^:]+):\s*(.*)$', line)
|
||||
if m:
|
||||
response['header'][m.group(1).lower()] = m.group(2)
|
||||
continue
|
||||
assert False, f"unexpected line: '{line}, expected: {expected}'"
|
||||
|
||||
fin_response(response)
|
||||
return r
|
||||
|
||||
243
tests/tests-httpd/testenv/env.py
Normal file
243
tests/tests-httpd/testenv/env.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#***************************************************************************
|
||||
# _ _ ____ _
|
||||
# Project ___| | | | _ \| |
|
||||
# / __| | | | |_) | |
|
||||
# | (__| |_| | _ <| |___
|
||||
# \___|\___/|_| \_\_____|
|
||||
#
|
||||
# Copyright (C) 2008 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# This software is licensed as described in the file COPYING, which
|
||||
# you should have received as part of this distribution. The terms
|
||||
# are also available at https://curl.se/docs/copyright.html.
|
||||
#
|
||||
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
# copies of the Software, and permit persons to whom the Software is
|
||||
# furnished to do so, under the terms of the COPYING file.
|
||||
#
|
||||
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
# KIND, either express or implied.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
#
|
||||
###########################################################################
|
||||
#
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from configparser import ConfigParser, ExtendedInterpolation
|
||||
from typing import Optional
|
||||
|
||||
from .certs import CertificateSpec, TestCA, Credentials
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_config_from(conf_path):
|
||||
if os.path.isfile(conf_path):
|
||||
config = ConfigParser(interpolation=ExtendedInterpolation())
|
||||
config.read(conf_path)
|
||||
return config
|
||||
return None
|
||||
|
||||
|
||||
TESTS_HTTPD_PATH = os.path.dirname(os.path.dirname(__file__))
|
||||
DEF_CONFIG = init_config_from(os.path.join(TESTS_HTTPD_PATH, 'config.ini'))
|
||||
|
||||
TOP_PATH = os.path.dirname(os.path.dirname(TESTS_HTTPD_PATH))
|
||||
CURL = os.path.join(TOP_PATH, 'src/curl')
|
||||
|
||||
|
||||
class EnvConfig:
|
||||
|
||||
def __init__(self):
|
||||
self.tests_dir = TESTS_HTTPD_PATH
|
||||
self.gen_dir = os.path.join(self.tests_dir, 'gen')
|
||||
self.config = DEF_CONFIG
|
||||
# check cur and its features
|
||||
self.curl = CURL
|
||||
self.curl_features = []
|
||||
self.curl_protos = []
|
||||
p = subprocess.run(args=[self.curl, '-V'],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
assert False, f'{self.curl} -V failed with exit code: {p.returncode}'
|
||||
for l in p.stdout.splitlines(keepends=False):
|
||||
if l.startswith('Features: '):
|
||||
self.curl_features = [feat.lower() for feat in l[10:].split(' ')]
|
||||
if l.startswith('Protocols: '):
|
||||
self.curl_protos = [prot.lower() for prot in l[11:].split(' ')]
|
||||
self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip())
|
||||
log.error(f'nghttpx -v: {p.stdout}')
|
||||
|
||||
self.http_port = self.config['test']['http_port']
|
||||
self.https_port = self.config['test']['https_port']
|
||||
self.h3_port = self.config['test']['h3_port']
|
||||
self.httpd = self.config['httpd']['httpd']
|
||||
self.apachectl = self.config['httpd']['apachectl']
|
||||
self.apxs = self.config['httpd']['apxs']
|
||||
if len(self.apxs) == 0:
|
||||
self.apxs = None
|
||||
|
||||
self.examples_pem = {
|
||||
'key': 'xxx',
|
||||
'cert': 'xxx',
|
||||
}
|
||||
self.htdocs_dir = os.path.join(self.gen_dir, 'htdocs')
|
||||
self.tld = 'tests-httpd.curl.se'
|
||||
self.domain1 = f"one.{self.tld}"
|
||||
self.domain2 = f"two.{self.tld}"
|
||||
self.cert_specs = [
|
||||
CertificateSpec(domains=[self.domain1], key_type='rsa2048'),
|
||||
CertificateSpec(domains=[self.domain2], key_type='rsa2048'),
|
||||
CertificateSpec(name="clientsX", sub_specs=[
|
||||
CertificateSpec(name="user1", client=True),
|
||||
]),
|
||||
]
|
||||
|
||||
self.nghttpx = self.config['nghttpx']['nghttpx']
|
||||
self.nghttpx_with_h3 = False
|
||||
if len(self.nghttpx) == 0:
|
||||
self.nghttpx = 'nghttpx'
|
||||
if self.nghttpx is not None:
|
||||
p = subprocess.run(args=[self.nghttpx, '-v'],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
# not a working nghttpx
|
||||
self.nghttpx = None
|
||||
else:
|
||||
self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip()) is not None
|
||||
log.error(f'nghttpx -v: {p.stdout}')
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
return os.path.isfile(self.httpd) and \
|
||||
os.path.isfile(self.apachectl) and \
|
||||
self.apxs is not None and \
|
||||
os.path.isfile(self.apxs)
|
||||
|
||||
def get_incomplete_reason(self) -> Optional[str]:
|
||||
if not os.path.isfile(self.httpd):
|
||||
return f'httpd ({self.httpd}) not found'
|
||||
if not os.path.isfile(self.apachectl):
|
||||
return f'apachectl ({self.apachectl}) not found'
|
||||
if self.apxs is None:
|
||||
return f"apxs (provided by apache2-dev) not found"
|
||||
if not os.path.isfile(self.apxs):
|
||||
return f"apxs ({self.apxs}) not found"
|
||||
return None
|
||||
|
||||
|
||||
class Env:
|
||||
|
||||
CONFIG = EnvConfig()
|
||||
|
||||
@staticmethod
|
||||
def setup_incomplete() -> bool:
|
||||
return not Env.CONFIG.is_complete()
|
||||
|
||||
@staticmethod
|
||||
def incomplete_reason() -> Optional[str]:
|
||||
return Env.CONFIG.get_incomplete_reason()
|
||||
|
||||
@staticmethod
|
||||
def have_h3_server() -> bool:
|
||||
return Env.CONFIG.nghttpx_with_h3
|
||||
|
||||
@staticmethod
|
||||
def have_h3_curl() -> bool:
|
||||
return 'http3' in Env.CONFIG.curl_features
|
||||
|
||||
@staticmethod
|
||||
def have_h3() -> bool:
|
||||
return Env.have_h3_curl() and Env.have_h3_server()
|
||||
|
||||
def __init__(self, pytestconfig=None):
|
||||
self._verbose = pytestconfig.option.verbose \
|
||||
if pytestconfig is not None else 0
|
||||
self._ca = None
|
||||
|
||||
def issue_certs(self):
|
||||
if self._ca is None:
|
||||
ca_dir = os.path.join(self.CONFIG.gen_dir, 'ca')
|
||||
self._ca = TestCA.create_root(name=self.CONFIG.tld,
|
||||
store_dir=ca_dir,
|
||||
key_type="rsa2048")
|
||||
self._ca.issue_certs(self.CONFIG.cert_specs)
|
||||
|
||||
def setup(self):
|
||||
os.makedirs(self.gen_dir, exist_ok=True)
|
||||
os.makedirs(self.htdocs_dir, exist_ok=True)
|
||||
self.issue_certs()
|
||||
|
||||
def get_credentials(self, domain) -> Optional[Credentials]:
|
||||
creds = self.ca.get_credentials_for_name(domain)
|
||||
if len(creds) > 0:
|
||||
return creds[0]
|
||||
return None
|
||||
|
||||
@property
|
||||
def verbose(self) -> int:
|
||||
return self._verbose
|
||||
|
||||
@property
|
||||
def gen_dir(self) -> str:
|
||||
return self.CONFIG.gen_dir
|
||||
|
||||
@property
|
||||
def ca(self):
|
||||
return self._ca
|
||||
|
||||
@property
|
||||
def htdocs_dir(self) -> str:
|
||||
return self.CONFIG.htdocs_dir
|
||||
|
||||
@property
|
||||
def domain1(self) -> str:
|
||||
return self.CONFIG.domain1
|
||||
|
||||
@property
|
||||
def domain2(self) -> str:
|
||||
return self.CONFIG.domain2
|
||||
|
||||
@property
|
||||
def http_port(self) -> str:
|
||||
return self.CONFIG.http_port
|
||||
|
||||
@property
|
||||
def https_port(self) -> str:
|
||||
return self.CONFIG.https_port
|
||||
|
||||
@property
|
||||
def h3_port(self) -> str:
|
||||
return self.CONFIG.h3_port
|
||||
|
||||
@property
|
||||
def curl(self) -> str:
|
||||
return self.CONFIG.curl
|
||||
|
||||
@property
|
||||
def httpd(self) -> str:
|
||||
return self.CONFIG.httpd
|
||||
|
||||
@property
|
||||
def apachectl(self) -> str:
|
||||
return self.CONFIG.apachectl
|
||||
|
||||
@property
|
||||
def apxs(self) -> str:
|
||||
return self.CONFIG.apxs
|
||||
|
||||
@property
|
||||
def nghttpx(self) -> Optional[str]:
|
||||
return self.CONFIG.nghttpx
|
||||
|
||||
def authority_for(self, domain: str, alpn_proto: Optional[str] = None):
|
||||
if alpn_proto is None or \
|
||||
alpn_proto in ['h2', 'http/1.1', 'http/1.0', 'http/0.9']:
|
||||
return f'{domain}:{self.https_port}'
|
||||
if alpn_proto in ['h3']:
|
||||
return f'{domain}:{self.h3_port}'
|
||||
return f'{domain}:{self.http_port}'
|
||||
288
tests/tests-httpd/testenv/httpd.py
Normal file
288
tests/tests-httpd/testenv/httpd.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#***************************************************************************
|
||||
# _ _ ____ _
|
||||
# Project ___| | | | _ \| |
|
||||
# / __| | | | |_) | |
|
||||
# | (__| |_| | _ <| |___
|
||||
# \___|\___/|_| \_\_____|
|
||||
#
|
||||
# Copyright (C) 2008 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# This software is licensed as described in the file COPYING, which
|
||||
# you should have received as part of this distribution. The terms
|
||||
# are also available at https://curl.se/docs/copyright.html.
|
||||
#
|
||||
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
# copies of the Software, and permit persons to whom the Software is
|
||||
# furnished to do so, under the terms of the COPYING file.
|
||||
#
|
||||
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
# KIND, either express or implied.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
#
|
||||
###########################################################################
|
||||
#
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import timedelta, datetime
|
||||
from json import JSONEncoder
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from .curl import CurlClient, ExecResult
|
||||
from .env import Env
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Httpd:
|
||||
|
||||
MODULES = [
|
||||
'log_config', 'logio', 'unixd', 'version', 'watchdog',
|
||||
'authn_core', 'authz_user', 'authz_core',
|
||||
'env', 'filter', 'headers', 'mime',
|
||||
'rewrite', 'http2', 'ssl',
|
||||
'mpm_event',
|
||||
]
|
||||
COMMON_MODULES_DIRS = [
|
||||
'/usr/lib/apache2/modules', # debian
|
||||
'/usr/libexec/apache2/', # macos
|
||||
]
|
||||
|
||||
MOD_CURLTEST = None
|
||||
|
||||
def __init__(self, env: Env):
|
||||
self.env = env
|
||||
self._cmd = env.apachectl
|
||||
self._apache_dir = os.path.join(env.gen_dir, 'apache')
|
||||
self._docs_dir = os.path.join(self._apache_dir, 'docs')
|
||||
self._conf_dir = os.path.join(self._apache_dir, 'conf')
|
||||
self._conf_file = os.path.join(self._conf_dir, 'test.conf')
|
||||
self._logs_dir = os.path.join(self._apache_dir, 'logs')
|
||||
self._error_log = os.path.join(self._logs_dir, 'error_log')
|
||||
self._tmp_dir = os.path.join(self._apache_dir, 'tmp')
|
||||
self._mods_dir = None
|
||||
if env.apxs is not None:
|
||||
p = subprocess.run(args=[env.apxs, '-q', 'libexecdir'],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise Exception(f'{env.apxs} failed to query libexecdir: {p}')
|
||||
self._mods_dir = p.stdout.strip()
|
||||
else:
|
||||
for md in self.COMMON_MODULES_DIRS:
|
||||
if os.path.isdir(md):
|
||||
self._mods_dir = md
|
||||
if self._mods_dir is None:
|
||||
raise Exception(f'apache modules dir cannot be found')
|
||||
self._process = None
|
||||
self._rmf(self._error_log)
|
||||
self._init_curltest()
|
||||
|
||||
def clear_logs(self):
|
||||
self._rmf(self._error_log)
|
||||
|
||||
def exists(self):
|
||||
return os.path.exists(self._cmd)
|
||||
|
||||
def _run(self, args, intext=''):
|
||||
p = subprocess.run(args, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
cwd=self.env.gen_dir,
|
||||
input=intext.encode() if intext else None)
|
||||
start = datetime.now()
|
||||
return ExecResult(args=args, exit_code=p.returncode,
|
||||
stdout=p.stdout.decode().splitlines(),
|
||||
stderr=p.stderr.decode().splitlines(),
|
||||
duration=datetime.now() - start)
|
||||
|
||||
def _apachectl(self, cmd: str):
|
||||
args = [self.env.apachectl,
|
||||
"-d", self._apache_dir,
|
||||
"-f", self._conf_file,
|
||||
"-k", cmd]
|
||||
return self._run(args=args)
|
||||
|
||||
def start(self):
|
||||
if self._process:
|
||||
self.stop()
|
||||
self._write_config()
|
||||
with open(self._error_log, 'a') as fd:
|
||||
fd.write('start of server\n')
|
||||
with open(os.path.join(self._apache_dir, 'xxx'), 'a') as fd:
|
||||
fd.write('start of server\n')
|
||||
r = self._apachectl('start')
|
||||
if r.exit_code != 0:
|
||||
log.error(f'failed to start httpd: {r}')
|
||||
return self.wait_live(timeout=timedelta(seconds=5))
|
||||
|
||||
def stop(self):
|
||||
r = self._apachectl('stop')
|
||||
if r.exit_code == 0:
|
||||
return self.wait_dead(timeout=timedelta(seconds=5))
|
||||
return r.exit_code == 0
|
||||
|
||||
def restart(self):
|
||||
self.stop()
|
||||
return self.start()
|
||||
|
||||
def reload(self):
|
||||
r = self._apachectl("graceful")
|
||||
if r.exit_code != 0:
|
||||
log.error(f'failed to reload httpd: {r}')
|
||||
return self.wait_live(timeout=timedelta(seconds=5))
|
||||
|
||||
def wait_dead(self, timeout: timedelta):
|
||||
curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
|
||||
try_until = datetime.now() + timeout
|
||||
while datetime.now() < try_until:
|
||||
r = curl.http_get(url=f'http://{self.env.domain1}:{self.env.http_port}/')
|
||||
if r.exit_code != 0:
|
||||
return True
|
||||
time.sleep(.1)
|
||||
log.debug(f"Server still responding after {timeout}")
|
||||
return False
|
||||
|
||||
def wait_live(self, timeout: timedelta):
|
||||
curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
|
||||
try_until = datetime.now() + timeout
|
||||
while datetime.now() < try_until:
|
||||
r = curl.http_get(url=f'http://{self.env.domain1}:{self.env.http_port}/')
|
||||
if r.exit_code == 0:
|
||||
return True
|
||||
time.sleep(.1)
|
||||
log.debug(f"Server still not responding after {timeout}")
|
||||
return False
|
||||
|
||||
def _rmf(self, path):
|
||||
if os.path.exists(path):
|
||||
return os.remove(path)
|
||||
|
||||
def _mkpath(self, path):
|
||||
if not os.path.exists(path):
|
||||
return os.makedirs(path)
|
||||
|
||||
def _write_config(self):
|
||||
domain1 = self.env.domain1
|
||||
creds1 = self.env.get_credentials(domain1)
|
||||
domain2 = self.env.domain2
|
||||
creds2 = self.env.get_credentials(domain2)
|
||||
self._mkpath(self._conf_dir)
|
||||
self._mkpath(self._logs_dir)
|
||||
self._mkpath(self._tmp_dir)
|
||||
self._mkpath(os.path.join(self._docs_dir, 'two'))
|
||||
with open(os.path.join(self._docs_dir, 'data.json'), 'w') as fd:
|
||||
data = {
|
||||
'server': f'{domain1}',
|
||||
}
|
||||
fd.write(JSONEncoder().encode(data))
|
||||
with open(os.path.join(self._docs_dir, 'two/data.json'), 'w') as fd:
|
||||
data = {
|
||||
'server': f'{domain2}',
|
||||
}
|
||||
fd.write(JSONEncoder().encode(data))
|
||||
with open(self._conf_file, 'w') as fd:
|
||||
for m in self.MODULES:
|
||||
if os.path.exists(os.path.join(self._mods_dir, f'mod_{m}.so')):
|
||||
fd.write(f'LoadModule {m}_module "{self._mods_dir}/mod_{m}.so"\n')
|
||||
if Httpd.MOD_CURLTEST is not None:
|
||||
fd.write(f'LoadModule curltest_module \"{Httpd.MOD_CURLTEST}\"\n')
|
||||
conf = [ # base server config
|
||||
f'ServerRoot "{self._apache_dir}"',
|
||||
f'DefaultRuntimeDir logs',
|
||||
f'PidFile httpd.pid',
|
||||
f'ErrorLog {self._error_log}',
|
||||
f'LogLevel {self._get_log_level()}',
|
||||
f'LogLevel http:trace4',
|
||||
f'H2MinWorkers 16',
|
||||
f'H2MaxWorkers 128',
|
||||
f'Listen {self.env.http_port}',
|
||||
f'Listen {self.env.https_port}',
|
||||
f'TypesConfig "{self._conf_dir}/mime.types',
|
||||
# we want the quest string in a response header, so we
|
||||
# can check responses more easily
|
||||
f'Header set rquery "%{{QUERY_STRING}}s"',
|
||||
]
|
||||
conf.extend([ # plain http host for domain1
|
||||
f'<VirtualHost *:{self.env.http_port}>',
|
||||
f' ServerName {domain1}',
|
||||
f' DocumentRoot "{self._docs_dir}"',
|
||||
])
|
||||
conf.extend(self._curltest_conf())
|
||||
conf.extend([
|
||||
f'</VirtualHost>',
|
||||
f'',
|
||||
])
|
||||
conf.extend([ # https host for domain1, h1 + h2
|
||||
f'<VirtualHost *:{self.env.https_port}>',
|
||||
f' ServerName {domain1}',
|
||||
f' Protocols h2 http/1.1',
|
||||
f' SSLEngine on',
|
||||
f' SSLCertificateFile {creds1.cert_file}',
|
||||
f' SSLCertificateKeyFile {creds1.pkey_file}',
|
||||
f' DocumentRoot "{self._docs_dir}"',
|
||||
])
|
||||
conf.extend(self._curltest_conf())
|
||||
conf.extend([
|
||||
f'</VirtualHost>',
|
||||
f'',
|
||||
])
|
||||
conf.extend([ # https host for domain2, no h2
|
||||
f'<VirtualHost *:{self.env.https_port}>',
|
||||
f' ServerName {domain2}',
|
||||
f' Protocols http/1.1',
|
||||
f' SSLEngine on',
|
||||
f' SSLCertificateFile {creds2.cert_file}',
|
||||
f' SSLCertificateKeyFile {creds2.pkey_file}',
|
||||
f' DocumentRoot "{self._docs_dir}/two"',
|
||||
])
|
||||
conf.extend(self._curltest_conf())
|
||||
conf.extend([
|
||||
f'</VirtualHost>',
|
||||
f'',
|
||||
])
|
||||
fd.write("\n".join(conf))
|
||||
with open(os.path.join(self._conf_dir, 'mime.types'), 'w') as fd:
|
||||
fd.write("\n".join([
|
||||
'text/html html',
|
||||
'application/json json',
|
||||
''
|
||||
]))
|
||||
|
||||
def _get_log_level(self):
|
||||
if self.env.verbose > 3:
|
||||
return 'trace2'
|
||||
if self.env.verbose > 2:
|
||||
return 'trace1'
|
||||
if self.env.verbose > 1:
|
||||
return 'debug'
|
||||
return 'info'
|
||||
|
||||
def _curltest_conf(self) -> List[str]:
|
||||
if Httpd.MOD_CURLTEST is not None:
|
||||
return [
|
||||
f' <Location /curltest/echo>',
|
||||
f' SetHandler curltest-echo',
|
||||
f' </Location>',
|
||||
f' <Location /curltest/tweak>',
|
||||
f' SetHandler curltest-tweak',
|
||||
f' </Location>',
|
||||
]
|
||||
return []
|
||||
|
||||
def _init_curltest(self):
|
||||
if Httpd.MOD_CURLTEST is not None:
|
||||
return
|
||||
local_dir = os.path.dirname(inspect.getfile(Httpd))
|
||||
p = subprocess.run([self.env.apxs, '-c', 'mod_curltest.c'],
|
||||
capture_output=True,
|
||||
cwd=os.path.join(local_dir, 'mod_curltest'))
|
||||
rv = p.returncode
|
||||
if rv != 0:
|
||||
log.error(f"compiling mod_curltest failed: {p.stderr}")
|
||||
raise Exception(f"compiling mod_curltest failed: {p.stderr}")
|
||||
Httpd.MOD_CURLTEST = os.path.join(
|
||||
local_dir, 'mod_curltest/.libs/mod_curltest.so')
|
||||
5
tests/tests-httpd/testenv/mod_curltest/.gitignore
vendored
Normal file
5
tests/tests-httpd/testenv/mod_curltest/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
|
||||
*.slo
|
||||
411
tests/tests-httpd/testenv/mod_curltest/mod_curltest.c
Normal file
411
tests/tests-httpd/testenv/mod_curltest/mod_curltest.c
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/***************************************************************************
|
||||
* _ _ ____ _
|
||||
* Project ___| | | | _ \| |
|
||||
* / __| | | | |_) | |
|
||||
* | (__| |_| | _ <| |___
|
||||
* \___|\___/|_| \_\_____|
|
||||
*
|
||||
* Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
*
|
||||
* This software is licensed as described in the file COPYING, which
|
||||
* you should have received as part of this distribution. The terms
|
||||
* are also available at https://curl.se/docs/copyright.html.
|
||||
*
|
||||
* You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
* copies of the Software, and permit persons to whom the Software is
|
||||
* furnished to do so, under the terms of the COPYING file.
|
||||
*
|
||||
* This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
* KIND, either express or implied.
|
||||
*
|
||||
* SPDX-License-Identifier: curl
|
||||
*
|
||||
***************************************************************************/
|
||||
#include <apr_optional.h>
|
||||
#include <apr_optional_hooks.h>
|
||||
#include <apr_strings.h>
|
||||
#include <apr_cstr.h>
|
||||
#include <apr_time.h>
|
||||
#include <apr_want.h>
|
||||
|
||||
#include <httpd.h>
|
||||
#include <http_protocol.h>
|
||||
#include <http_request.h>
|
||||
#include <http_log.h>
|
||||
|
||||
static void curltest_hooks(apr_pool_t *pool);
|
||||
static int curltest_echo_handler(request_rec *r);
|
||||
static int curltest_tweak_handler(request_rec *r);
|
||||
|
||||
AP_DECLARE_MODULE(curltest) = {
|
||||
STANDARD20_MODULE_STUFF,
|
||||
NULL, /* func to create per dir config */
|
||||
NULL, /* func to merge per dir config */
|
||||
NULL, /* func to create per server config */
|
||||
NULL, /* func to merge per server config */
|
||||
NULL, /* command handlers */
|
||||
curltest_hooks,
|
||||
#if defined(AP_MODULE_FLAG_NONE)
|
||||
AP_MODULE_FLAG_ALWAYS_MERGE
|
||||
#endif
|
||||
};
|
||||
|
||||
static int curltest_post_config(apr_pool_t *p, apr_pool_t *plog,
|
||||
apr_pool_t *ptemp, server_rec *s)
|
||||
{
|
||||
void *data = NULL;
|
||||
const char *key = "mod_curltest_init_counter";
|
||||
|
||||
(void)plog;(void)ptemp;
|
||||
|
||||
apr_pool_userdata_get(&data, key, s->process->pool);
|
||||
if(!data) {
|
||||
/* dry run */
|
||||
apr_pool_userdata_set((const void *)1, key,
|
||||
apr_pool_cleanup_null, s->process->pool);
|
||||
return APR_SUCCESS;
|
||||
}
|
||||
|
||||
/* mess with the overall server here */
|
||||
|
||||
return APR_SUCCESS;
|
||||
}
|
||||
|
||||
static void curltest_hooks(apr_pool_t *pool)
|
||||
{
|
||||
ap_log_perror(APLOG_MARK, APLOG_TRACE1, 0, pool, "installing hooks");
|
||||
|
||||
/* Run once after configuration is set, but before mpm children initialize.
|
||||
*/
|
||||
ap_hook_post_config(curltest_post_config, NULL, NULL, APR_HOOK_MIDDLE);
|
||||
|
||||
/* curl test handlers */
|
||||
ap_hook_handler(curltest_echo_handler, NULL, NULL, APR_HOOK_MIDDLE);
|
||||
ap_hook_handler(curltest_tweak_handler, NULL, NULL, APR_HOOK_MIDDLE);
|
||||
}
|
||||
|
||||
#define SECS_PER_HOUR (60*60)
|
||||
#define SECS_PER_DAY (24*SECS_PER_HOUR)
|
||||
|
||||
static apr_status_t duration_parse(apr_interval_time_t *ptimeout, const char *value,
|
||||
const char *def_unit)
|
||||
{
|
||||
char *endp;
|
||||
apr_int64_t n;
|
||||
|
||||
n = apr_strtoi64(value, &endp, 10);
|
||||
if(errno) {
|
||||
return errno;
|
||||
}
|
||||
if(!endp || !*endp) {
|
||||
if (!def_unit) def_unit = "s";
|
||||
}
|
||||
else if(endp == value) {
|
||||
return APR_EINVAL;
|
||||
}
|
||||
else {
|
||||
def_unit = endp;
|
||||
}
|
||||
|
||||
switch(*def_unit) {
|
||||
case 'D':
|
||||
case 'd':
|
||||
*ptimeout = apr_time_from_sec(n * SECS_PER_DAY);
|
||||
break;
|
||||
case 's':
|
||||
case 'S':
|
||||
*ptimeout = (apr_interval_time_t) apr_time_from_sec(n);
|
||||
break;
|
||||
case 'h':
|
||||
case 'H':
|
||||
/* Time is in hours */
|
||||
*ptimeout = (apr_interval_time_t) apr_time_from_sec(n * SECS_PER_HOUR);
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
switch(*(++def_unit)) {
|
||||
/* Time is in milliseconds */
|
||||
case 's':
|
||||
case 'S':
|
||||
*ptimeout = (apr_interval_time_t) n * 1000;
|
||||
break;
|
||||
/* Time is in minutes */
|
||||
case 'i':
|
||||
case 'I':
|
||||
*ptimeout = (apr_interval_time_t) apr_time_from_sec(n * 60);
|
||||
break;
|
||||
default:
|
||||
return APR_EGENERAL;
|
||||
}
|
||||
break;
|
||||
case 'u':
|
||||
case 'U':
|
||||
switch(*(++def_unit)) {
|
||||
/* Time is in microseconds */
|
||||
case 's':
|
||||
case 'S':
|
||||
*ptimeout = (apr_interval_time_t) n;
|
||||
break;
|
||||
default:
|
||||
return APR_EGENERAL;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return APR_EGENERAL;
|
||||
}
|
||||
return APR_SUCCESS;
|
||||
}
|
||||
|
||||
static int status_from_str(const char *s, apr_status_t *pstatus)
|
||||
{
|
||||
if(!strcmp("timeout", s)) {
|
||||
*pstatus = APR_TIMEUP;
|
||||
return 1;
|
||||
}
|
||||
else if(!strcmp("reset", s)) {
|
||||
*pstatus = APR_ECONNRESET;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int curltest_echo_handler(request_rec *r)
|
||||
{
|
||||
conn_rec *c = r->connection;
|
||||
apr_bucket_brigade *bb;
|
||||
apr_bucket *b;
|
||||
apr_status_t rv;
|
||||
char buffer[8192];
|
||||
const char *ct;
|
||||
long l;
|
||||
|
||||
if(strcmp(r->handler, "curltest-echo")) {
|
||||
return DECLINED;
|
||||
}
|
||||
if(r->method_number != M_GET && r->method_number != M_POST) {
|
||||
return DECLINED;
|
||||
}
|
||||
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "echo_handler: processing");
|
||||
r->status = 200;
|
||||
r->clength = -1;
|
||||
r->chunked = 1;
|
||||
apr_table_unset(r->headers_out, "Content-Length");
|
||||
/* Discourage content-encodings */
|
||||
apr_table_unset(r->headers_out, "Content-Encoding");
|
||||
apr_table_setn(r->subprocess_env, "no-brotli", "1");
|
||||
apr_table_setn(r->subprocess_env, "no-gzip", "1");
|
||||
|
||||
ct = apr_table_get(r->headers_in, "content-type");
|
||||
ap_set_content_type(r, ct? ct : "application/octet-stream");
|
||||
|
||||
bb = apr_brigade_create(r->pool, c->bucket_alloc);
|
||||
/* copy any request body into the response */
|
||||
if((rv = ap_setup_client_block(r, REQUEST_CHUNKED_DECHUNK))) goto cleanup;
|
||||
if(ap_should_client_block(r)) {
|
||||
while(0 < (l = ap_get_client_block(r, &buffer[0], sizeof(buffer)))) {
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
|
||||
"echo_handler: copying %ld bytes from request body", l);
|
||||
rv = apr_brigade_write(bb, NULL, NULL, buffer, l);
|
||||
if (APR_SUCCESS != rv) goto cleanup;
|
||||
rv = ap_pass_brigade(r->output_filters, bb);
|
||||
if (APR_SUCCESS != rv) goto cleanup;
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
|
||||
"echo_handler: passed %ld bytes from request body", l);
|
||||
}
|
||||
}
|
||||
/* we are done */
|
||||
b = apr_bucket_eos_create(c->bucket_alloc);
|
||||
APR_BRIGADE_INSERT_TAIL(bb, b);
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "echo_handler: request read");
|
||||
|
||||
if(r->trailers_in && !apr_is_empty_table(r->trailers_in)) {
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r,
|
||||
"echo_handler: seeing incoming trailers");
|
||||
apr_table_setn(r->trailers_out, "h2test-trailers-in",
|
||||
apr_itoa(r->pool, 1));
|
||||
}
|
||||
|
||||
rv = ap_pass_brigade(r->output_filters, bb);
|
||||
|
||||
cleanup:
|
||||
if(rv == APR_SUCCESS
|
||||
|| r->status != HTTP_OK
|
||||
|| c->aborted) {
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "echo_handler: done");
|
||||
return OK;
|
||||
}
|
||||
else {
|
||||
/* no way to know what type of error occurred */
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r, "echo_handler failed");
|
||||
return AP_FILTER_ERROR;
|
||||
}
|
||||
return DECLINED;
|
||||
}
|
||||
|
||||
static int curltest_tweak_handler(request_rec *r)
|
||||
{
|
||||
conn_rec *c = r->connection;
|
||||
apr_bucket_brigade *bb;
|
||||
apr_bucket *b;
|
||||
apr_status_t rv;
|
||||
char buffer[16*1024];
|
||||
int i, chunks = 3, error_bucket = 1;
|
||||
size_t chunk_size = sizeof(buffer);
|
||||
const char *request_id = "none";
|
||||
apr_time_t delay = 0, chunk_delay = 0;
|
||||
apr_array_header_t *args = NULL;
|
||||
int http_status = 200;
|
||||
apr_status_t error = APR_SUCCESS, body_error = APR_SUCCESS;
|
||||
|
||||
if(strcmp(r->handler, "curltest-tweak")) {
|
||||
return DECLINED;
|
||||
}
|
||||
if(r->method_number != M_GET && r->method_number != M_POST) {
|
||||
return DECLINED;
|
||||
}
|
||||
|
||||
if(r->args) {
|
||||
args = apr_cstr_split(r->args, "&", 1, r->pool);
|
||||
for(i = 0; i < args->nelts; ++i) {
|
||||
char *s, *val, *arg = APR_ARRAY_IDX(args, i, char*);
|
||||
s = strchr(arg, '=');
|
||||
if(s) {
|
||||
*s = '\0';
|
||||
val = s + 1;
|
||||
if(!strcmp("status", arg)) {
|
||||
http_status = (int)apr_atoi64(val);
|
||||
if(http_status > 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("chunks", arg)) {
|
||||
chunks = (int)apr_atoi64(val);
|
||||
if(chunks >= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("chunk_size", arg)) {
|
||||
chunk_size = (int)apr_atoi64(val);
|
||||
if(chunk_size >= 0) {
|
||||
if(chunk_size > sizeof(buffer)) {
|
||||
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r,
|
||||
"chunk_size %zu too large", chunk_size);
|
||||
ap_die(HTTP_BAD_REQUEST, r);
|
||||
return OK;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("id", arg)) {
|
||||
/* just an id for repeated requests with curl's url globbing */
|
||||
request_id = val;
|
||||
continue;
|
||||
}
|
||||
else if(!strcmp("error", arg)) {
|
||||
if(status_from_str(val, &error)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("error_bucket", arg)) {
|
||||
error_bucket = (int)apr_atoi64(val);
|
||||
if(error_bucket >= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("body_error", arg)) {
|
||||
if(status_from_str(val, &body_error)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("delay", arg)) {
|
||||
rv = duration_parse(&delay, val, "s");
|
||||
if(APR_SUCCESS == rv) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(!strcmp("chunk_delay", arg)) {
|
||||
rv = duration_parse(&chunk_delay, val, "s");
|
||||
if(APR_SUCCESS == rv) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, "query parameter not "
|
||||
"understood: '%s' in %s",
|
||||
arg, r->args);
|
||||
ap_die(HTTP_BAD_REQUEST, r);
|
||||
return OK;
|
||||
}
|
||||
}
|
||||
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, "error_handler: processing "
|
||||
"request, %s", r->args? r->args : "(no args)");
|
||||
r->status = http_status;
|
||||
r->clength = -1;
|
||||
r->chunked = 1;
|
||||
apr_table_setn(r->headers_out, "request-id", request_id);
|
||||
apr_table_unset(r->headers_out, "Content-Length");
|
||||
/* Discourage content-encodings */
|
||||
apr_table_unset(r->headers_out, "Content-Encoding");
|
||||
apr_table_setn(r->subprocess_env, "no-brotli", "1");
|
||||
apr_table_setn(r->subprocess_env, "no-gzip", "1");
|
||||
|
||||
ap_set_content_type(r, "application/octet-stream");
|
||||
bb = apr_brigade_create(r->pool, c->bucket_alloc);
|
||||
|
||||
if(delay) {
|
||||
apr_sleep(delay);
|
||||
}
|
||||
if(error != APR_SUCCESS) {
|
||||
return ap_map_http_request_error(error, HTTP_BAD_REQUEST);
|
||||
}
|
||||
/* flush response */
|
||||
b = apr_bucket_flush_create(c->bucket_alloc);
|
||||
APR_BRIGADE_INSERT_TAIL(bb, b);
|
||||
rv = ap_pass_brigade(r->output_filters, bb);
|
||||
if (APR_SUCCESS != rv) goto cleanup;
|
||||
|
||||
memset(buffer, 'X', sizeof(buffer));
|
||||
for(i = 0; i < chunks; ++i) {
|
||||
if(chunk_delay) {
|
||||
apr_sleep(chunk_delay);
|
||||
}
|
||||
rv = apr_brigade_write(bb, NULL, NULL, buffer, chunk_size);
|
||||
if(APR_SUCCESS != rv) goto cleanup;
|
||||
rv = ap_pass_brigade(r->output_filters, bb);
|
||||
if(APR_SUCCESS != rv) goto cleanup;
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
|
||||
"error_handler: passed %lu bytes as response body",
|
||||
(unsigned long)chunk_size);
|
||||
if(body_error != APR_SUCCESS) {
|
||||
rv = body_error;
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
/* we are done */
|
||||
b = apr_bucket_eos_create(c->bucket_alloc);
|
||||
APR_BRIGADE_INSERT_TAIL(bb, b);
|
||||
rv = ap_pass_brigade(r->output_filters, bb);
|
||||
apr_brigade_cleanup(bb);
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r,
|
||||
"error_handler: response passed");
|
||||
|
||||
cleanup:
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r,
|
||||
"error_handler: request cleanup, r->status=%d, aborted=%d",
|
||||
r->status, c->aborted);
|
||||
if(rv == APR_SUCCESS) {
|
||||
return OK;
|
||||
}
|
||||
if(error_bucket && 0) {
|
||||
http_status = ap_map_http_request_error(rv, HTTP_BAD_REQUEST);
|
||||
b = ap_bucket_error_create(http_status, NULL, r->pool, c->bucket_alloc);
|
||||
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, rv, r,
|
||||
"error_handler: passing error bucket, status=%d",
|
||||
http_status);
|
||||
APR_BRIGADE_INSERT_TAIL(bb, b);
|
||||
ap_pass_brigade(r->output_filters, bb);
|
||||
}
|
||||
return AP_FILTER_ERROR;
|
||||
}
|
||||
125
tests/tests-httpd/testenv/nghttpx.py
Normal file
125
tests/tests-httpd/testenv/nghttpx.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#***************************************************************************
|
||||
# _ _ ____ _
|
||||
# Project ___| | | | _ \| |
|
||||
# / __| | | | |_) | |
|
||||
# | (__| |_| | _ <| |___
|
||||
# \___|\___/|_| \_\_____|
|
||||
#
|
||||
# Copyright (C) 2008 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
|
||||
#
|
||||
# This software is licensed as described in the file COPYING, which
|
||||
# you should have received as part of this distribution. The terms
|
||||
# are also available at https://curl.se/docs/copyright.html.
|
||||
#
|
||||
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
|
||||
# copies of the Software, and permit persons to whom the Software is
|
||||
# furnished to do so, under the terms of the COPYING file.
|
||||
#
|
||||
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||
# KIND, either express or implied.
|
||||
#
|
||||
# SPDX-License-Identifier: curl
|
||||
#
|
||||
###########################################################################
|
||||
#
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .env import Env
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Nghttpx:
|
||||
|
||||
def __init__(self, env: Env):
|
||||
self.env = env
|
||||
self._cmd = env.nghttpx
|
||||
self._pid_file = os.path.join(env.gen_dir, 'nghttpx.pid')
|
||||
self._conf_file = os.path.join(env.gen_dir, 'nghttpx.conf')
|
||||
self._error_log = os.path.join(env.gen_dir, 'nghttpx.log')
|
||||
self._stderr = os.path.join(env.gen_dir, 'nghttpx.stderr')
|
||||
self._process = None
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
|
||||
def exists(self):
|
||||
return os.path.exists(self._cmd)
|
||||
|
||||
def clear_logs(self):
|
||||
self._rmf(self._error_log)
|
||||
self._rmf(self._stderr)
|
||||
|
||||
def is_running(self):
|
||||
if self._process:
|
||||
self._process.poll()
|
||||
return self._process.returncode is None
|
||||
return False
|
||||
|
||||
def start(self):
|
||||
if self._process:
|
||||
self.stop()
|
||||
self._write_config()
|
||||
args = [
|
||||
self._cmd,
|
||||
f'--frontend=*,{self.env.h3_port};quic',
|
||||
f'--backend=127.0.0.1,{self.env.https_port};{self.env.domain1};sni={self.env.domain1};proto=h2;tls',
|
||||
f'--backend=127.0.0.1,{self.env.http_port}',
|
||||
f'--log-level=INFO',
|
||||
f'--pid-file={self._pid_file}',
|
||||
f'--errorlog-file={self._error_log}',
|
||||
f'--conf={self._conf_file}',
|
||||
f'--cacert={self.env.ca.cert_file}',
|
||||
self.env.get_credentials(self.env.domain1).pkey_file,
|
||||
self.env.get_credentials(self.env.domain1).cert_file,
|
||||
]
|
||||
ngerr = open(self._stderr, 'a')
|
||||
self._process = subprocess.Popen(args=args, stderr=ngerr)
|
||||
return self._process.returncode is None
|
||||
|
||||
def stop(self):
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=2)
|
||||
self._process = None
|
||||
return True
|
||||
|
||||
def restart(self):
|
||||
self.stop()
|
||||
return self.start()
|
||||
|
||||
def reload(self, timeout: datetime.timedelta):
|
||||
if self._process:
|
||||
running = self._process
|
||||
os.kill(running.pid, signal.SIGQUIT)
|
||||
self.start()
|
||||
try:
|
||||
log.debug(f'waiting for nghttpx({running.pid}) to exit.')
|
||||
running.wait(timeout=timeout.seconds)
|
||||
log.debug(f'nghttpx({running.pid}) terminated -> {running.returncode}')
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error(f'SIGQUIT nghttpx({running.pid}), but did not shut down.')
|
||||
return False
|
||||
|
||||
def _rmf(self, path):
|
||||
if os.path.exists(path):
|
||||
return os.remove(path)
|
||||
|
||||
def _mkpath(self, path):
|
||||
if not os.path.exists(path):
|
||||
return os.makedirs(path)
|
||||
|
||||
def _write_config(self):
|
||||
with open(self._conf_file, 'w') as fd:
|
||||
fd.write(f'# nghttpx test config'),
|
||||
fd.write("\n".join([
|
||||
'# do we need something here?'
|
||||
]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue