caddy.py (6901B)
1 #!/usr/bin/env python3 2 # -*- coding: utf-8 -*- 3 #*************************************************************************** 4 # _ _ ____ _ 5 # Project ___| | | | _ \| | 6 # / __| | | | |_) | | 7 # | (__| |_| | _ <| |___ 8 # \___|\___/|_| \_\_____| 9 # 10 # Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al. 11 # 12 # This software is licensed as described in the file COPYING, which 13 # you should have received as part of this distribution. The terms 14 # are also available at https://curl.se/docs/copyright.html. 15 # 16 # You may opt to use, copy, modify, merge, publish, distribute and/or sell 17 # copies of the Software, and permit persons to whom the Software is 18 # furnished to do so, under the terms of the COPYING file. 19 # 20 # This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY 21 # KIND, either express or implied. 22 # 23 # SPDX-License-Identifier: curl 24 # 25 ########################################################################### 26 # 27 import logging 28 import os 29 import socket 30 import subprocess 31 import time 32 from datetime import timedelta, datetime 33 from json import JSONEncoder 34 from typing import Dict 35 36 from .curl import CurlClient 37 from .env import Env 38 from .ports import alloc_ports_and_do 39 40 log = logging.getLogger(__name__) 41 42 43 class Caddy: 44 45 PORT_SPECS = { 46 'caddy': socket.SOCK_STREAM, 47 'caddys': socket.SOCK_STREAM, 48 } 49 50 def __init__(self, env: Env): 51 self.env = env 52 self._caddy = os.environ['CADDY'] if 'CADDY' in os.environ else env.caddy 53 self._caddy_dir = os.path.join(env.gen_dir, 'caddy') 54 self._docs_dir = os.path.join(self._caddy_dir, 'docs') 55 self._conf_file = os.path.join(self._caddy_dir, 'Caddyfile') 56 self._error_log = os.path.join(self._caddy_dir, 'caddy.log') 57 self._tmp_dir = os.path.join(self._caddy_dir, 'tmp') 58 self._process = None 59 self._http_port = 0 60 self._https_port = 0 61 self._rmf(self._error_log) 62 63 @property 64 def docs_dir(self): 65 return self._docs_dir 66 67 @property 68 def port(self) -> int: 69 return self._https_port 70 71 def clear_logs(self): 72 self._rmf(self._error_log) 73 74 def is_running(self): 75 if self._process: 76 self._process.poll() 77 return self._process.returncode is None 78 return False 79 80 def start_if_needed(self): 81 if not self.is_running(): 82 return self.start() 83 return True 84 85 def initial_start(self): 86 87 def startup(ports: Dict[str, int]) -> bool: 88 self._http_port = ports['caddy'] 89 self._https_port = ports['caddys'] 90 if self.start(): 91 self.env.update_ports(ports) 92 return True 93 self.stop() 94 self._http_port = 0 95 self._https_port = 0 96 return False 97 98 return alloc_ports_and_do(Caddy.PORT_SPECS, startup, 99 self.env.gen_root, max_tries=3) 100 101 def start(self, wait_live=True): 102 assert self._http_port > 0 and self._https_port > 0 103 self._mkpath(self._tmp_dir) 104 if self._process: 105 self.stop() 106 self._write_config() 107 args = [ 108 self._caddy, 'run' 109 ] 110 caddyerr = open(self._error_log, 'a') 111 self._process = subprocess.Popen(args=args, cwd=self._caddy_dir, stderr=caddyerr) 112 if self._process.returncode is not None: 113 return False 114 return not wait_live or self.wait_live(timeout=timedelta(seconds=Env.SERVER_TIMEOUT)) 115 116 def stop(self, wait_dead=True): 117 self._mkpath(self._tmp_dir) 118 if self._process: 119 self._process.terminate() 120 self._process.wait(timeout=2) 121 self._process = None 122 return not wait_dead or self.wait_dead(timeout=timedelta(seconds=5)) 123 return True 124 125 def restart(self): 126 self.stop() 127 return self.start() 128 129 def wait_dead(self, timeout: timedelta): 130 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 131 try_until = datetime.now() + timeout 132 while datetime.now() < try_until: 133 check_url = f'https://{self.env.domain1}:{self.port}/' 134 r = curl.http_get(url=check_url) 135 if r.exit_code != 0: 136 return True 137 log.debug(f'waiting for caddy to stop responding: {r}') 138 time.sleep(.1) 139 log.debug(f"Server still responding after {timeout}") 140 return False 141 142 def wait_live(self, timeout: timedelta): 143 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 144 try_until = datetime.now() + timeout 145 while datetime.now() < try_until: 146 check_url = f'https://{self.env.domain1}:{self.port}/' 147 r = curl.http_get(url=check_url) 148 if r.exit_code == 0: 149 return True 150 time.sleep(.1) 151 log.error(f"Caddy still not responding after {timeout}") 152 return False 153 154 def _rmf(self, path): 155 if os.path.exists(path): 156 return os.remove(path) 157 158 def _mkpath(self, path): 159 if not os.path.exists(path): 160 return os.makedirs(path) 161 162 def _write_config(self): 163 domain1 = self.env.domain1 164 creds1 = self.env.get_credentials(domain1) 165 assert creds1 # convince pytype this isn't None 166 domain2 = self.env.domain2 167 creds2 = self.env.get_credentials(domain2) 168 assert creds2 # convince pytype this isn't None 169 self._mkpath(self._docs_dir) 170 self._mkpath(self._tmp_dir) 171 with open(os.path.join(self._docs_dir, 'data.json'), 'w') as fd: 172 data = { 173 'server': f'{domain1}', 174 } 175 fd.write(JSONEncoder().encode(data)) 176 with open(self._conf_file, 'w') as fd: 177 conf = [ # base server config 178 '{', 179 f' http_port {self._http_port}', 180 f' https_port {self._https_port}', 181 ' log default {', 182 ' level ERROR', 183 '}', 184 f' servers :{self._https_port} {{', 185 ' protocols h3 h2 h1', 186 ' }', 187 '}', 188 f'{domain1}:{self._https_port} {{', 189 ' file_server * {', 190 f' root {self._docs_dir}', 191 ' }', 192 f' tls {creds1.cert_file} {creds1.pkey_file}', 193 '}', 194 ] 195 if self.env.http_port > 0: 196 conf.extend([ 197 f'{domain2} {{', 198 f' reverse_proxy /* http://localhost:{self.env.http_port} {{', 199 ' }', 200 f' tls {creds2.cert_file} {creds2.pkey_file}', 201 '}', 202 ]) 203 fd.write("\n".join(conf))