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('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 = os.environ.copy() 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 self._loaded_extra_configs = copy.deepcopy(self._extra_configs) 155 return self.wait_live(timeout=timedelta(seconds=5)) 156 157 def stop(self): 158 r = self._apachectl('stop') 159 self._loaded_extra_configs = None 160 if r.exit_code == 0: 161 return self.wait_dead(timeout=timedelta(seconds=5)) 162 log.fatal(f'stopping httpd failed: {r}') 163 return r.exit_code == 0 164 165 def restart(self): 166 self.stop() 167 return self.start() 168 169 def reload(self): 170 self._write_config() 171 r = self._apachectl("graceful") 172 self._loaded_extra_configs = None 173 if r.exit_code != 0: 174 log.error(f'failed to reload httpd: {r}') 175 self._loaded_extra_configs = copy.deepcopy(self._extra_configs) 176 return self.wait_live(timeout=timedelta(seconds=5)) 177 178 def reload_if_config_changed(self): 179 if self._loaded_extra_configs == self._extra_configs: 180 return True 181 return self.reload() 182 183 def wait_dead(self, timeout: timedelta): 184 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 185 try_until = datetime.now() + timeout 186 while datetime.now() < try_until: 187 r = curl.http_get(url=f'http://{self.env.domain1}:{self.env.http_port}/') 188 if r.exit_code != 0: 189 return True 190 time.sleep(.1) 191 log.debug(f"Server still responding after {timeout}") 192 return False 193 194 def wait_live(self, timeout: timedelta): 195 curl = CurlClient(env=self.env, run_dir=self._tmp_dir, 196 timeout=timeout.total_seconds()) 197 try_until = datetime.now() + timeout 198 while datetime.now() < try_until: 199 r = curl.http_get(url=f'http://{self.env.domain1}:{self.env.http_port}/') 200 if r.exit_code == 0: 201 return True 202 time.sleep(.1) 203 log.debug(f"Server still not responding after {timeout}") 204 return False 205 206 def _rmf(self, path): 207 if os.path.exists(path): 208 return os.remove(path) 209 210 def _mkpath(self, path): 211 if not os.path.exists(path): 212 return os.makedirs(path) 213 214 def _write_config(self): 215 domain1 = self.env.domain1 216 domain1brotli = self.env.domain1brotli 217 creds1 = self.env.get_credentials(domain1) 218 assert creds1 # convince pytype this isn't None 219 domain2 = self.env.domain2 220 creds2 = self.env.get_credentials(domain2) 221 assert creds2 # convince pytype this isn't None 222 proxy_domain = self.env.proxy_domain 223 proxy_creds = self.env.get_credentials(proxy_domain) 224 assert proxy_creds # convince pytype this isn't None 225 self._mkpath(self._conf_dir) 226 self._mkpath(self._logs_dir) 227 self._mkpath(self._tmp_dir) 228 self._mkpath(os.path.join(self._docs_dir, 'two')) 229 with open(os.path.join(self._docs_dir, 'data.json'), 'w') as fd: 230 data = { 231 'server': f'{domain1}', 232 } 233 fd.write(JSONEncoder().encode(data)) 234 with open(os.path.join(self._docs_dir, 'two/data.json'), 'w') as fd: 235 data = { 236 'server': f'{domain2}', 237 } 238 fd.write(JSONEncoder().encode(data)) 239 if self._proxy_auth_basic: 240 with open(self._basic_passwords, 'w') as fd: 241 fd.write('proxy:$apr1$FQfeInbs$WQZbODJlVg60j0ogEIlTW/\n') 242 if self._auth_digest: 243 with open(self._digest_passwords, 'w') as fd: 244 fd.write('test:restricted area:57123e269fd73d71ae0656594e938e2f\n') 245 self._mkpath(os.path.join(self.docs_dir, 'restricted/digest')) 246 with open(os.path.join(self.docs_dir, 'restricted/digest/data.json'), 'w') as fd: 247 fd.write('{"area":"digest"}\n') 248 with open(self._conf_file, 'w') as fd: 249 for m in self.MODULES: 250 if os.path.exists(os.path.join(self._mods_dir, f'mod_{m}.so')): 251 fd.write(f'LoadModule {m}_module "{self._mods_dir}/mod_{m}.so"\n') 252 if Httpd.MOD_CURLTEST is not None: 253 fd.write(f'LoadModule curltest_module "{Httpd.MOD_CURLTEST}"\n') 254 conf = [ # base server config 255 f'ServerRoot "{self._apache_dir}"', 256 'DefaultRuntimeDir logs', 257 'PidFile httpd.pid', 258 f'ErrorLog {self._error_log}', 259 f'LogLevel {self._get_log_level()}', 260 'StartServers 4', 261 'ReadBufferSize 16000', 262 'H2MinWorkers 16', 263 'H2MaxWorkers 256', 264 f'Listen {self.env.http_port}', 265 f'Listen {self.env.https_port}', 266 f'Listen {self.env.proxy_port}', 267 f'Listen {self.env.proxys_port}', 268 f'TypesConfig "{self._conf_dir}/mime.types', 269 'SSLSessionCache "shmcb:ssl_gcache_data(32000)"', 270 ] 271 if 'base' in self._extra_configs: 272 conf.extend(self._extra_configs['base']) 273 conf.extend([ # plain http host for domain1 274 f'<VirtualHost *:{self.env.http_port}>', 275 f' ServerName {domain1}', 276 ' ServerAlias localhost', 277 f' DocumentRoot "{self._docs_dir}"', 278 ' Protocols h2c http/1.1', 279 ' H2Direct on', 280 ]) 281 conf.extend(self._curltest_conf(domain1)) 282 conf.extend([ 283 '</VirtualHost>', 284 '', 285 ]) 286 conf.extend([ # https host for domain1, h1 + h2 287 f'<VirtualHost *:{self.env.https_port}>', 288 f' ServerName {domain1}', 289 ' ServerAlias localhost', 290 ' Protocols h2 http/1.1', 291 ' SSLEngine on', 292 f' SSLCertificateFile {creds1.cert_file}', 293 f' SSLCertificateKeyFile {creds1.pkey_file}', 294 f' DocumentRoot "{self._docs_dir}"', 295 ]) 296 conf.extend(self._curltest_conf(domain1)) 297 if domain1 in self._extra_configs: 298 conf.extend(self._extra_configs[domain1]) 299 conf.extend([ 300 '</VirtualHost>', 301 '', 302 ]) 303 # Alternate to domain1 with BROTLI compression 304 conf.extend([ # https host for domain1, h1 + h2 305 f'<VirtualHost *:{self.env.https_port}>', 306 f' ServerName {domain1brotli}', 307 ' Protocols h2 http/1.1', 308 ' SSLEngine on', 309 f' SSLCertificateFile {creds1.cert_file}', 310 f' SSLCertificateKeyFile {creds1.pkey_file}', 311 f' DocumentRoot "{self._docs_dir}"', 312 ' SetOutputFilter BROTLI_COMPRESS', 313 ]) 314 conf.extend(self._curltest_conf(domain1)) 315 if domain1 in self._extra_configs: 316 conf.extend(self._extra_configs[domain1]) 317 conf.extend([ 318 '</VirtualHost>', 319 '', 320 ]) 321 conf.extend([ # plain http host for domain2 322 f'<VirtualHost *:{self.env.http_port}>', 323 f' ServerName {domain2}', 324 ' ServerAlias localhost', 325 f' DocumentRoot "{self._docs_dir}"', 326 ' Protocols h2c http/1.1', 327 ]) 328 conf.extend(self._curltest_conf(domain2)) 329 conf.extend([ 330 '</VirtualHost>', 331 '', 332 ]) 333 conf.extend([ # https host for domain2, no h2 334 f'<VirtualHost *:{self.env.https_port}>', 335 f' ServerName {domain2}', 336 ' Protocols http/1.1', 337 ' SSLEngine on', 338 f' SSLCertificateFile {creds2.cert_file}', 339 f' SSLCertificateKeyFile {creds2.pkey_file}', 340 f' DocumentRoot "{self._docs_dir}/two"', 341 ]) 342 conf.extend(self._curltest_conf(domain2)) 343 if domain2 in self._extra_configs: 344 conf.extend(self._extra_configs[domain2]) 345 conf.extend([ 346 '</VirtualHost>', 347 '', 348 ]) 349 conf.extend([ # http forward proxy 350 f'<VirtualHost *:{self.env.proxy_port}>', 351 f' ServerName {proxy_domain}', 352 ' Protocols h2c http/1.1', 353 ' ProxyRequests On', 354 ' H2ProxyRequests On', 355 ' ProxyVia On', 356 f' AllowCONNECT {self.env.http_port} {self.env.https_port}', 357 ]) 358 conf.extend(self._get_proxy_conf()) 359 conf.extend([ 360 '</VirtualHost>', 361 '', 362 ]) 363 conf.extend([ # https forward proxy 364 f'<VirtualHost *:{self.env.proxys_port}>', 365 f' ServerName {proxy_domain}', 366 ' Protocols h2 http/1.1', 367 ' SSLEngine on', 368 f' SSLCertificateFile {proxy_creds.cert_file}', 369 f' SSLCertificateKeyFile {proxy_creds.pkey_file}', 370 ' ProxyRequests On', 371 ' H2ProxyRequests On', 372 ' ProxyVia On', 373 f' AllowCONNECT {self.env.http_port} {self.env.https_port}', 374 ]) 375 conf.extend(self._get_proxy_conf()) 376 conf.extend([ 377 '</VirtualHost>', 378 '', 379 ]) 380 381 fd.write("\n".join(conf)) 382 with open(os.path.join(self._conf_dir, 'mime.types'), 'w') as fd: 383 fd.write("\n".join([ 384 'text/html html', 385 'application/json json', 386 '' 387 ])) 388 389 def _get_proxy_conf(self): 390 if self._proxy_auth_basic: 391 return [ 392 ' <Proxy "*">', 393 ' AuthType Basic', 394 ' AuthName "Restricted Proxy"', 395 ' AuthBasicProvider file', 396 f' AuthUserFile "{self._basic_passwords}"', 397 ' Require user proxy', 398 ' </Proxy>', 399 ] 400 else: 401 return [ 402 ' <Proxy "*">', 403 ' Require ip 127.0.0.1', 404 ' </Proxy>', 405 ] 406 407 def _get_log_level(self): 408 if self.env.verbose > 3: 409 return 'trace2' 410 if self.env.verbose > 2: 411 return 'trace1' 412 if self.env.verbose > 1: 413 return 'debug' 414 return 'info' 415 416 def _curltest_conf(self, servername) -> List[str]: 417 lines = [] 418 if Httpd.MOD_CURLTEST is not None: 419 lines.extend([ 420 ' Redirect 302 /data.json.302 /data.json', 421 ' Redirect 301 /curltest/echo301 /curltest/echo', 422 ' Redirect 302 /curltest/echo302 /curltest/echo', 423 ' Redirect 303 /curltest/echo303 /curltest/echo', 424 ' Redirect 307 /curltest/echo307 /curltest/echo', 425 ' <Location /curltest/sslinfo>', 426 ' SSLOptions StdEnvVars', 427 ' SetHandler curltest-sslinfo', 428 ' </Location>', 429 ' <Location /curltest/echo>', 430 ' SetHandler curltest-echo', 431 ' </Location>', 432 ' <Location /curltest/put>', 433 ' SetHandler curltest-put', 434 ' </Location>', 435 ' <Location /curltest/tweak>', 436 ' SetHandler curltest-tweak', 437 ' </Location>', 438 ' Redirect 302 /tweak /curltest/tweak', 439 ' <Location /curltest/1_1>', 440 ' SetHandler curltest-1_1-required', 441 ' </Location>', 442 ' <Location /curltest/shutdown_unclean>', 443 ' SetHandler curltest-tweak', 444 ' SetEnv force-response-1.0 1', 445 ' </Location>', 446 ' SetEnvIf Request_URI "/shutdown_unclean" ssl-unclean=1', 447 ]) 448 if self._auth_digest: 449 lines.extend([ 450 f' <Directory {self.docs_dir}/restricted/digest>', 451 ' AuthType Digest', 452 ' AuthName "restricted area"', 453 f' AuthDigestDomain "https://{servername}"', 454 ' AuthBasicProvider file', 455 f' AuthUserFile "{self._digest_passwords}"', 456 ' Require valid-user', 457 ' </Directory>', 458 459 ]) 460 return lines 461 462 def _init_curltest(self): 463 if Httpd.MOD_CURLTEST is not None: 464 return 465 local_dir = os.path.dirname(inspect.getfile(Httpd)) 466 p = subprocess.run([self.env.apxs, '-c', 'mod_curltest.c'], 467 capture_output=True, 468 cwd=os.path.join(local_dir, 'mod_curltest')) 469 rv = p.returncode 470 if rv != 0: 471 log.error(f"compiling mod_curltest failed: {p.stderr}") 472 raise Exception(f"compiling mod_curltest failed: {p.stderr}") 473 Httpd.MOD_CURLTEST = os.path.join( 474 local_dir, 'mod_curltest/.libs/mod_curltest.so') 475