xref: /curl/tests/http/testenv/httpd.py (revision fb22459d)
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 inspect
28import logging
29import os
30import subprocess
31from datetime import timedelta, datetime
32from json import JSONEncoder
33import time
34from typing import List, Union, Optional
35
36from .curl import CurlClient, ExecResult
37from .env import Env
38
39
40log = logging.getLogger(__name__)
41
42
43class Httpd:
44
45    MODULES = [
46        'log_config', 'logio', 'unixd', 'version', 'watchdog',
47        'authn_core', 'authn_file',
48        'authz_user', 'authz_core', 'authz_host',
49        'auth_basic', 'auth_digest',
50        'alias', 'env', 'filter', 'headers', 'mime', 'setenvif',
51        'socache_shmcb',
52        'rewrite', 'http2', 'ssl', 'proxy', 'proxy_http', 'proxy_connect',
53        'brotli',
54        'mpm_event',
55    ]
56    COMMON_MODULES_DIRS = [
57        '/usr/lib/apache2/modules',  # debian
58        '/usr/libexec/apache2/',     # macos
59    ]
60
61    MOD_CURLTEST = None
62
63    def __init__(self, env: Env, proxy_auth: bool = False):
64        self.env = env
65        self._cmd = env.apachectl
66        self._apache_dir = os.path.join(env.gen_dir, 'apache')
67        self._run_dir = os.path.join(self._apache_dir, 'run')
68        self._lock_dir = os.path.join(self._apache_dir, 'locks')
69        self._docs_dir = os.path.join(self._apache_dir, 'docs')
70        self._conf_dir = os.path.join(self._apache_dir, 'conf')
71        self._conf_file = os.path.join(self._conf_dir, 'test.conf')
72        self._logs_dir = os.path.join(self._apache_dir, 'logs')
73        self._error_log = os.path.join(self._logs_dir, 'error_log')
74        self._tmp_dir = os.path.join(self._apache_dir, 'tmp')
75        self._basic_passwords = os.path.join(self._conf_dir, 'basic.passwords')
76        self._digest_passwords = os.path.join(self._conf_dir, 'digest.passwords')
77        self._mods_dir = None
78        self._auth_digest = True
79        self._proxy_auth_basic = proxy_auth
80        self._extra_configs = {}
81        assert env.apxs
82        p = subprocess.run(args=[env.apxs, '-q', 'libexecdir'],
83                           capture_output=True, text=True)
84        if p.returncode != 0:
85            raise Exception(f'{env.apxs} failed to query libexecdir: {p}')
86        self._mods_dir = p.stdout.strip()
87        if self._mods_dir is None:
88            raise Exception(f'apache modules dir cannot be found')
89        if not os.path.exists(self._mods_dir):
90            raise Exception(f'apache modules dir does not exist: {self._mods_dir}')
91        self._process = None
92        self._rmf(self._error_log)
93        self._init_curltest()
94
95    @property
96    def docs_dir(self):
97        return self._docs_dir
98
99    def clear_logs(self):
100        self._rmf(self._error_log)
101
102    def exists(self):
103        return os.path.exists(self._cmd)
104
105    def set_extra_config(self, domain: str, lines: Optional[Union[str, List[str]]]):
106        if lines is None:
107            self._extra_configs.pop(domain, None)
108        else:
109            self._extra_configs[domain] = lines
110
111    def clear_extra_configs(self):
112        self._extra_configs = {}
113
114    def set_proxy_auth(self, active: bool):
115        self._proxy_auth_basic = active
116
117    def _run(self, args, intext=''):
118        env = {}
119        for key, val in os.environ.items():
120            env[key] = val
121        env['APACHE_RUN_DIR'] = self._run_dir
122        env['APACHE_RUN_USER'] = os.environ['USER']
123        env['APACHE_LOCK_DIR'] = self._lock_dir
124        env['APACHE_CONFDIR'] = self._apache_dir
125        p = subprocess.run(args, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
126                           cwd=self.env.gen_dir,
127                           input=intext.encode() if intext else None,
128                           env=env)
129        start = datetime.now()
130        return ExecResult(args=args, exit_code=p.returncode,
131                          stdout=p.stdout.decode().splitlines(),
132                          stderr=p.stderr.decode().splitlines(),
133                          duration=datetime.now() - start)
134
135    def _apachectl(self, cmd: str):
136        args = [self.env.apachectl,
137                "-d", self._apache_dir,
138                "-f", self._conf_file,
139                "-k", cmd]
140        return self._run(args=args)
141
142    def start(self):
143        if self._process:
144            self.stop()
145        self._write_config()
146        with open(self._error_log, 'a') as fd:
147            fd.write('start of server\n')
148        with open(os.path.join(self._apache_dir, 'xxx'), 'a') as fd:
149            fd.write('start of server\n')
150        r = self._apachectl('start')
151        if r.exit_code != 0:
152            log.error(f'failed to start httpd: {r}')
153            return False
154        return self.wait_live(timeout=timedelta(seconds=5))
155
156    def stop(self):
157        r = self._apachectl('stop')
158        if r.exit_code == 0:
159            return self.wait_dead(timeout=timedelta(seconds=5))
160        log.fatal(f'stopping httpd failed: {r}')
161        return r.exit_code == 0
162
163    def restart(self):
164        self.stop()
165        return self.start()
166
167    def reload(self):
168        self._write_config()
169        r = self._apachectl("graceful")
170        if r.exit_code != 0:
171            log.error(f'failed to reload httpd: {r}')
172        return self.wait_live(timeout=timedelta(seconds=5))
173
174    def wait_dead(self, timeout: timedelta):
175        curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
176        try_until = datetime.now() + timeout
177        while datetime.now() < try_until:
178            r = curl.http_get(url=f'http://{self.env.domain1}:{self.env.http_port}/')
179            if r.exit_code != 0:
180                return True
181            time.sleep(.1)
182        log.debug(f"Server still responding after {timeout}")
183        return False
184
185    def wait_live(self, timeout: timedelta):
186        curl = CurlClient(env=self.env, run_dir=self._tmp_dir,
187                          timeout=timeout.total_seconds())
188        try_until = datetime.now() + timeout
189        while datetime.now() < try_until:
190            r = curl.http_get(url=f'http://{self.env.domain1}:{self.env.http_port}/')
191            if r.exit_code == 0:
192                return True
193            time.sleep(.1)
194        log.debug(f"Server still not responding after {timeout}")
195        return False
196
197    def _rmf(self, path):
198        if os.path.exists(path):
199            return os.remove(path)
200
201    def _mkpath(self, path):
202        if not os.path.exists(path):
203            return os.makedirs(path)
204
205    def _write_config(self):
206        domain1 = self.env.domain1
207        domain1brotli = self.env.domain1brotli
208        creds1 = self.env.get_credentials(domain1)
209        domain2 = self.env.domain2
210        creds2 = self.env.get_credentials(domain2)
211        proxy_domain = self.env.proxy_domain
212        proxy_creds = self.env.get_credentials(proxy_domain)
213        self._mkpath(self._conf_dir)
214        self._mkpath(self._logs_dir)
215        self._mkpath(self._tmp_dir)
216        self._mkpath(os.path.join(self._docs_dir, 'two'))
217        with open(os.path.join(self._docs_dir, 'data.json'), 'w') as fd:
218            data = {
219                'server': f'{domain1}',
220            }
221            fd.write(JSONEncoder().encode(data))
222        with open(os.path.join(self._docs_dir, 'two/data.json'), 'w') as fd:
223            data = {
224                'server': f'{domain2}',
225            }
226            fd.write(JSONEncoder().encode(data))
227        if self._proxy_auth_basic:
228            with open(self._basic_passwords, 'w') as fd:
229                fd.write('proxy:$apr1$FQfeInbs$WQZbODJlVg60j0ogEIlTW/\n')
230        if self._auth_digest:
231            with open(self._digest_passwords, 'w') as fd:
232                fd.write('test:restricted area:57123e269fd73d71ae0656594e938e2f\n')
233            self._mkpath(os.path.join(self.docs_dir, 'restricted/digest'))
234            with open(os.path.join(self.docs_dir, 'restricted/digest/data.json'), 'w') as fd:
235                fd.write('{"area":"digest"}\n')
236        with open(self._conf_file, 'w') as fd:
237            for m in self.MODULES:
238                if os.path.exists(os.path.join(self._mods_dir, f'mod_{m}.so')):
239                    fd.write(f'LoadModule {m}_module   "{self._mods_dir}/mod_{m}.so"\n')
240            if Httpd.MOD_CURLTEST is not None:
241                fd.write(f'LoadModule curltest_module   \"{Httpd.MOD_CURLTEST}\"\n')
242            conf = [   # base server config
243                f'ServerRoot "{self._apache_dir}"',
244                f'DefaultRuntimeDir logs',
245                f'PidFile httpd.pid',
246                f'ErrorLog {self._error_log}',
247                f'LogLevel {self._get_log_level()}',
248                f'StartServers 4',
249                f'H2MinWorkers 16',
250                f'H2MaxWorkers 256',
251                f'H2Direct on',
252                f'Listen {self.env.http_port}',
253                f'Listen {self.env.https_port}',
254                f'Listen {self.env.proxy_port}',
255                f'Listen {self.env.proxys_port}',
256                f'TypesConfig "{self._conf_dir}/mime.types',
257                f'SSLSessionCache "shmcb:ssl_gcache_data(32000)"',
258            ]
259            if 'base' in self._extra_configs:
260                conf.extend(self._extra_configs['base'])
261            conf.extend([  # plain http host for domain1
262                f'<VirtualHost *:{self.env.http_port}>',
263                f'    ServerName {domain1}',
264                f'    ServerAlias localhost',
265                f'    DocumentRoot "{self._docs_dir}"',
266                f'    Protocols h2c http/1.1',
267            ])
268            conf.extend(self._curltest_conf(domain1))
269            conf.extend([
270                f'</VirtualHost>',
271                f'',
272            ])
273            conf.extend([  # https host for domain1, h1 + h2
274                f'<VirtualHost *:{self.env.https_port}>',
275                f'    ServerName {domain1}',
276                f'    ServerAlias localhost',
277                f'    Protocols h2 http/1.1',
278                f'    SSLEngine on',
279                f'    SSLCertificateFile {creds1.cert_file}',
280                f'    SSLCertificateKeyFile {creds1.pkey_file}',
281                f'    DocumentRoot "{self._docs_dir}"',
282            ])
283            conf.extend(self._curltest_conf(domain1))
284            if domain1 in self._extra_configs:
285                conf.extend(self._extra_configs[domain1])
286            conf.extend([
287                f'</VirtualHost>',
288                f'',
289            ])
290            # Alternate to domain1 with BROTLI compression
291            conf.extend([  # https host for domain1, h1 + h2
292                f'<VirtualHost *:{self.env.https_port}>',
293                f'    ServerName {domain1brotli}',
294                f'    Protocols h2 http/1.1',
295                f'    SSLEngine on',
296                f'    SSLCertificateFile {creds1.cert_file}',
297                f'    SSLCertificateKeyFile {creds1.pkey_file}',
298                f'    DocumentRoot "{self._docs_dir}"',
299                f'    SetOutputFilter BROTLI_COMPRESS',
300            ])
301            conf.extend(self._curltest_conf(domain1))
302            if domain1 in self._extra_configs:
303                conf.extend(self._extra_configs[domain1])
304            conf.extend([
305                f'</VirtualHost>',
306                f'',
307            ])
308            conf.extend([  # https host for domain2, no h2
309                f'<VirtualHost *:{self.env.https_port}>',
310                f'    ServerName {domain2}',
311                f'    Protocols http/1.1',
312                f'    SSLEngine on',
313                f'    SSLCertificateFile {creds2.cert_file}',
314                f'    SSLCertificateKeyFile {creds2.pkey_file}',
315                f'    DocumentRoot "{self._docs_dir}/two"',
316            ])
317            conf.extend(self._curltest_conf(domain2))
318            if domain2 in self._extra_configs:
319                conf.extend(self._extra_configs[domain2])
320            conf.extend([
321                f'</VirtualHost>',
322                f'',
323            ])
324            conf.extend([  # http forward proxy
325                f'<VirtualHost *:{self.env.proxy_port}>',
326                f'    ServerName {proxy_domain}',
327                f'    Protocols h2c http/1.1',
328                f'    ProxyRequests On',
329                f'    H2ProxyRequests On',
330                f'    ProxyVia On',
331                f'    AllowCONNECT {self.env.http_port} {self.env.https_port}',
332            ])
333            conf.extend(self._get_proxy_conf())
334            conf.extend([
335                f'</VirtualHost>',
336                f'',
337            ])
338            conf.extend([  # https forward proxy
339                f'<VirtualHost *:{self.env.proxys_port}>',
340                f'    ServerName {proxy_domain}',
341                f'    Protocols h2 http/1.1',
342                f'    SSLEngine on',
343                f'    SSLCertificateFile {proxy_creds.cert_file}',
344                f'    SSLCertificateKeyFile {proxy_creds.pkey_file}',
345                f'    ProxyRequests On',
346                f'    H2ProxyRequests On',
347                f'    ProxyVia On',
348                f'    AllowCONNECT {self.env.http_port} {self.env.https_port}',
349            ])
350            conf.extend(self._get_proxy_conf())
351            conf.extend([
352                f'</VirtualHost>',
353                f'',
354            ])
355
356            fd.write("\n".join(conf))
357        with open(os.path.join(self._conf_dir, 'mime.types'), 'w') as fd:
358            fd.write("\n".join([
359                'text/html             html',
360                'application/json      json',
361                ''
362            ]))
363
364    def _get_proxy_conf(self):
365        if self._proxy_auth_basic:
366            return [
367                f'    <Proxy "*">',
368                f'      AuthType Basic',
369                f'      AuthName "Restricted Proxy"',
370                f'      AuthBasicProvider file',
371                f'      AuthUserFile "{self._basic_passwords}"',
372                f'      Require user proxy',
373                f'    </Proxy>',
374            ]
375        else:
376            return [
377                f'    <Proxy "*">',
378                f'      Require ip 127.0.0.1',
379                f'    </Proxy>',
380            ]
381
382    def _get_log_level(self):
383        if self.env.verbose > 3:
384            return 'trace2'
385        if self.env.verbose > 2:
386            return 'trace1'
387        if self.env.verbose > 1:
388            return 'debug'
389        return 'info'
390
391    def _curltest_conf(self, servername) -> List[str]:
392        lines = []
393        if Httpd.MOD_CURLTEST is not None:
394            lines.extend([
395                f'    Redirect 302 /data.json.302 /data.json',
396                f'    Redirect 301 /curltest/echo301 /curltest/echo',
397                f'    Redirect 302 /curltest/echo302 /curltest/echo',
398                f'    Redirect 303 /curltest/echo303 /curltest/echo',
399                f'    Redirect 307 /curltest/echo307 /curltest/echo',
400                f'    <Location /curltest/sslinfo>',
401                f'      SSLOptions StdEnvVars',
402                f'      SetHandler curltest-sslinfo',
403                f'    </Location>',
404                f'    <Location /curltest/echo>',
405                f'      SetHandler curltest-echo',
406                f'    </Location>',
407                f'    <Location /curltest/put>',
408                f'      SetHandler curltest-put',
409                f'    </Location>',
410                f'    <Location /curltest/tweak>',
411                f'      SetHandler curltest-tweak',
412                f'    </Location>',
413                f'    Redirect 302 /tweak /curltest/tweak',
414                f'    <Location /curltest/1_1>',
415                f'      SetHandler curltest-1_1-required',
416                f'    </Location>',
417                f'    <Location /curltest/shutdown_unclean>',
418                f'      SetHandler curltest-tweak',
419                f'      SetEnv force-response-1.0 1',
420                f'    </Location>',
421                f'    SetEnvIf Request_URI "/shutdown_unclean" ssl-unclean=1',
422            ])
423        if self._auth_digest:
424            lines.extend([
425                f'    <Directory {self.docs_dir}/restricted/digest>',
426                f'      AuthType Digest',
427                f'      AuthName "restricted area"',
428                f'      AuthDigestDomain "https://{servername}"',
429                f'      AuthBasicProvider file',
430                f'      AuthUserFile "{self._digest_passwords}"',
431                f'      Require valid-user',
432                f'    </Directory>',
433
434            ])
435        return lines
436
437    def _init_curltest(self):
438        if Httpd.MOD_CURLTEST is not None:
439            return
440        local_dir = os.path.dirname(inspect.getfile(Httpd))
441        p = subprocess.run([self.env.apxs, '-c', 'mod_curltest.c'],
442                           capture_output=True,
443                           cwd=os.path.join(local_dir, 'mod_curltest'))
444        rv = p.returncode
445        if rv != 0:
446            log.error(f"compiling mod_curltest failed: {p.stderr}")
447            raise Exception(f"compiling mod_curltest failed: {p.stderr}")
448        Httpd.MOD_CURLTEST = os.path.join(
449            local_dir, 'mod_curltest/.libs/mod_curltest.so')
450