1 #!/usr/bin/env python3 2 3 # Copyright (C) Internet Systems Consortium, Inc. ("ISC") 4 # 5 # SPDX-License-Identifier: MPL-2.0 6 # 7 # This Source Code Form is subject to the terms of the Mozilla Public 8 # License, v. 2.0. If a copy of the MPL was not distributed with this 9 # file, you can obtain one at https://mozilla.org/MPL/2.0/. 10 # 11 # See the COPYRIGHT file distributed with this work for additional 12 # information regarding copyright ownership. 13 14 from functools import reduce 15 from resource import RLIMIT_NOFILE, getrlimit, setrlimit 16 17 import os 18 import random 19 import socket 20 import subprocess 21 import sys 22 import time 23 24 MULTIDIG_INSTANCES = 10 25 CONNECT_TRIES = 5 26 27 random.seed() 28 29 # Ensure we have enough file desriptors to work 30 rlimit_nofile = getrlimit(RLIMIT_NOFILE) 31 if rlimit_nofile[0] < 1024: 32 setrlimit(RLIMIT_NOFILE, (1024, rlimit_nofile[1])) 33 34 35 # Introduce some random delay 36 def jitter(): 37 time.sleep((500 + random.randint(0, 250)) / 1000000.0) 38 39 40 # A set of simple procedures to get the test's configuration options 41 def get_http_port(http_secure=False): 42 http_port_env = None 43 if http_secure: 44 http_port_env = os.getenv("HTTPSPORT") 45 else: 46 http_port_env = os.getenv("HTTPPORT") 47 if http_port_env: 48 return int(http_port_env) 49 return 443 50 51 52 def get_http_host(): 53 bind_host = os.getenv("BINDHOST") 54 if bind_host: 55 return bind_host 56 return "localhost" 57 58 59 def get_dig_path(): 60 dig_path = os.getenv("DIG") 61 if dig_path: 62 return dig_path 63 return "dig" 64 65 66 # A simple class which creates the given number of TCP connections to 67 # the given host in order to stress the BIND's quota facility 68 class TCPConnector: 69 def __init__(self, host, port): 70 self.host = host 71 self.port = port 72 self.connections = [] 73 74 def connect_one(self): 75 tries = CONNECT_TRIES 76 while tries > 0: 77 try: 78 sock = socket.create_connection( 79 address=(self.host, self.port), timeout=None 80 ) 81 self.connections.append(sock) 82 break 83 except ConnectionResetError: 84 # some jitter for BSDs 85 jitter() 86 continue 87 except TimeoutError: 88 jitter() 89 continue 90 finally: 91 tries -= 1 92 93 # Close an established connection (randomly) 94 def disconnect_random(self): 95 pos = random.randint(0, len(self.connections) - 1) 96 conn = self.connections[pos] 97 try: 98 conn.shutdown(socket.SHUT_RDWR) 99 conn.close() 100 except OSError: 101 conn.close() 102 finally: 103 self.connections.remove(conn) 104 105 def disconnect_all(self): 106 while len(self.connections) != 0: 107 self.disconnect_random() 108 109 110 # A simple class which allows running a dig instance under control of 111 # the process 112 class SubDIG: 113 def __init__(self, http_secure=None, extra_args=None): 114 self.sub_process = None 115 self.dig_path = get_dig_path() 116 self.host = get_http_host() 117 self.port = get_http_port(http_secure=http_secure) 118 if http_secure: 119 self.http_secure = True 120 else: 121 self.http_secure = False 122 self.extra_args = extra_args 123 124 # This method constructs a command string 125 def get_command(self): 126 command = self.dig_path + " -p " + str(self.port) + " " 127 command = command + "+noadd +nosea +nostat +noquest +nocmd +time=30 " 128 if self.http_secure: 129 command = command + "+https " 130 else: 131 command = command + "+http-plain " 132 command = command + "@" + self.host + " " 133 if self.extra_args: 134 command = command + self.extra_args 135 return command 136 137 def run(self): 138 with open(os.devnull, "w", encoding="utf-8") as devnull: 139 self.sub_process = subprocess.Popen( # pylint: disable=consider-using-with 140 self.get_command(), shell=True, stdout=devnull 141 ) 142 143 def wait(self, timeout=None): 144 res = None 145 if timeout is None: 146 return self.sub_process.wait() 147 try: 148 res = self.sub_process.wait(timeout=timeout) 149 except subprocess.TimeoutExpired: 150 return None 151 return res 152 153 def alive(self): 154 return self.sub_process.poll() is None 155 156 157 # A simple wrapper class which allows running multiple dig instances 158 # and examining their statuses in one logical operation. 159 class MultiDIG: 160 def __init__(self, numdigs, http_secure=None, extra_args=None): 161 assert int(numdigs) > 0, f"numdigs={numdigs}" 162 digs = [] 163 for _ in range(1, int(numdigs) + 1): 164 digs.append(SubDIG(http_secure=http_secure, extra_args=extra_args)) 165 self.digs = digs 166 assert len(self.digs) == int(numdigs), f"len={len(self.digs)} numdigs={numdigs}" 167 168 def run(self): 169 for p in self.digs: 170 p.run() 171 172 def wait(self): 173 return map(lambda p: (p.wait()), self.digs) 174 175 # Wait for the all instances to terminate with expected given 176 # status. Returns true or false. 177 def wait_for_result(self, result): 178 return reduce( 179 lambda a, b: ((a == result or a is True) and b == result), self.wait() 180 ) 181 182 def alive(self): 183 return reduce(lambda a, b: (a and b), map(lambda p: (p.alive()), self.digs)) 184 185 def completed(self): 186 total = 0 187 for p in self.digs: 188 if not p.alive(): 189 total += 1 190 return total 191 192 193 # The test's main logic 194 def run_test(http_secure=True): 195 query_args = "SOA ." 196 # Let's try to make a successful query 197 subdig = SubDIG(http_secure=http_secure, extra_args=query_args) 198 subdig.run() 199 assert subdig.wait() == 0, "DIG was expected to succeed" 200 # Let's create a lot of TCP connections to the server stress the 201 # HTTP quota 202 connector = TCPConnector(get_http_host(), get_http_port(http_secure=http_secure)) 203 # Let's make queries until the quota kicks in 204 subdig = SubDIG(http_secure=http_secure, extra_args=query_args) 205 subdig.run() 206 while True: 207 connector.connect_one() 208 subdig = SubDIG(http_secure=http_secure, extra_args=query_args) 209 subdig.run() 210 if subdig.wait(timeout=5) is None: 211 break 212 213 # At this point quota has kicked in. Additionally, let's create a 214 # bunch of dig processes all trying to make a query against the 215 # server with exceeded quota 216 multidig = MultiDIG( 217 MULTIDIG_INSTANCES, http_secure=http_secure, extra_args=query_args 218 ) 219 multidig.run() 220 # Wait for the dig instance to complete. Not a single instance has 221 # a chance to complete successfully because of the exceeded quota 222 assert ( 223 subdig.wait(timeout=5) is None 224 ), "The single DIG instance has stopped prematurely" 225 assert subdig.alive(), "The single DIG instance is expected to be alive" 226 assert multidig.alive(), ( 227 "The DIG instances from the set are all expected to " 228 f"be alive, but {multidig.completed()} of them have completed" 229 ) 230 # Let's close opened connections (in random order) to let all dig 231 # processes to complete 232 connector.disconnect_all() 233 # Wait for all processes to complete successfully 234 assert subdig.wait() == 0, "Single DIG instance failed" 235 assert ( 236 multidig.wait_for_result(0) is True 237 ), "One or more of DIG instances returned unexpected results" 238 239 240 def main(): 241 run_test(http_secure=True) 242 run_test(http_secure=False) 243 # If we have reached this point we could safely return 0 244 # (success). If the test fails because of an assert, the whole 245 # program will return non-zero exit code and produce the backtrace 246 return 0 247 248 249 sys.exit(main()) 250