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