Home | History | Annotate | Line # | Download | only in doth
      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