Michal 'vorner' Vaner 13 years ago
parent
commit
d66a6761de

+ 107 - 6
src/bin/ddns/ddns.py.in

@@ -23,19 +23,32 @@ from isc.dns import *
 from isc.config.ccsession import *
 from isc.cc import SessionError, SessionTimeout
 import isc.util.process
+import isc.util.io.socketsession
+import select
+import errno
 
 from isc.log_messages.ddns_messages import *
 
 from optparse import OptionParser, OptionValueError
 import os
+import os.path
 import signal
+import socket
 
 isc.log.init("b10-ddns")
 logger = isc.log.Logger("ddns")
+TRACE_BASIC = logger.DBGLVL_TRACE_BASIC
 
 DATA_PATH = bind10_config.DATA_PATH
+SOCKET_FILE = DATA_PATH + '/ddns_socket'
 if "B10_FROM_SOURCE" in os.environ:
     DATA_PATH = os.environ['B10_FROM_SOURCE'] + "/src/bin/ddns"
+if "B10_FROM_BUILD" in os.environ:
+    if "B10_FROM_SOURCE_LOCALSTATEDIR" in os.environ:
+        SOCKET_FILE = os.environ["B10_FROM_SOURCE_LOCALSTATEDIR"] + \
+            "/ddns_socket"
+    else:
+        SOCKET_FILE = os.environ["B10_FROM_BUILD"] + "/ddns_socket"
 SPECFILE_LOCATION = DATA_PATH + "/ddns.spec"
 
 
@@ -65,6 +78,13 @@ class DDNSSession:
         '''Initialize a DDNS Session'''
         pass
 
+def clear_socket():
+    '''
+    Removes the socket file, if it exists.
+    '''
+    if os.path.exists(SOCKET_FILE):
+        os.remove(SOCKET_FILE)
+
 class DDNSServer:
     def __init__(self, cc_session=None):
         '''
@@ -85,9 +105,17 @@ class DDNSServer:
         self._config_data = self._cc.get_full_config()
         self._cc.start()
         self._shutdown = False
+        # List of the session receivers where we get the requests
+        self._socksession_receivers = {}
+        clear_socket()
+        self._listen_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        self._listen_socket.bind(SOCKET_FILE)
+        self._listen_socket.listen(16)
 
     def config_handler(self, new_config):
         '''Update config data.'''
+        # TODO: Handle exceptions and turn them to an error response
+        # (once we have any configuration)
         answer = create_answer(0)
         return answer
 
@@ -96,6 +124,7 @@ class DDNSServer:
         Handle a CC session command, as sent from bindctl or other
         BIND 10 modules.
         '''
+        # TODO: Handle exceptions and turn them to an error response
         if cmd == "shutdown":
             logger.info(DDNS_RECEIVED_SHUTDOWN_COMMAND)
             self.trigger_shutdown()
@@ -125,19 +154,90 @@ class DDNSServer:
         '''
         pass
 
+    def accept(self):
+        """
+        Accept another connection and create the session receiver.
+        """
+        try:
+            sock = self._listen_socket.accept()
+            fileno = sock.fileno()
+            logger.debug(TRACE_BASIC, DDNS_NEW_CONN, fileno,
+                         sock.getpeername())
+            receiver = isc.util.io.socketsession.SocketSessionReceiver(sock)
+            self._socksession_receivers[fileno] = (sock, receiver)
+        except (socket.error, isc.util.io.socketsession.SocketSessionError) \
+            as e:
+            # These exceptions mean the connection didn't work, but we can
+            # continue with the rest
+            logger.error(DDNS_ACCEPT_FAILURE, e)
+
+    def handle_request(self, request):
+        """
+        This is the place where the actual DDNS processing is done. Other
+        methods are either subroutines of this method or methods doing the
+        uninteresting "accounting" stuff, like accepting socket,
+        initialization, etc.
+
+        It is called with the request being session as received from
+        SocketSessionReceiver, i.e. tuple
+        (socket, local_address, remote_address, data).
+        """
+        # TODO: Implement the magic
+
+        # TODO: Don't propagate most of the exceptions (like datasrc errors),
+        # just drop the packet.
+        pass
+
+    def handle_session(self, fileno):
+        """
+        Handle incoming session on the socket with given fileno.
+        """
+        logger.debug(TRACE_BASIC, DDNS_SESSION, fileno)
+        (socket, receiver) = self._socksession_receivers[fileno]
+        try:
+            self.handle_request(receiver.pop())
+        except isc.util.io.socketsession.SocketSessionError as se:
+            # No matter why this failed, the connection is in unknown, possibly
+            # broken state. So, we close the socket and remove the receiver.
+            del self._socksession_receivers[fileno]
+            socket.close()
+            logger.warn(DDNS_DROP_CONN, fileno, se)
+
     def run(self):
         '''
         Get and process all commands sent from cfgmgr or other modules.
         This loops waiting for events until self.shutdown() has been called.
         '''
         logger.info(DDNS_RUNNING)
+        cc_fileno = self._cc.get_socket().fileno()
+        listen_fileno = self._listen_socket.fileno()
         while not self._shutdown:
-            # We do not catch any exceptions here right now, but this would
-            # be a good place to catch any exceptions that b10-ddns can
-            # recover from. We currently have no exception hierarchy to
-            # make such a distinction easily, but once we do, this would
-            # be the place to catch.
-            self._cc.check_command(False)
+            # In this event loop, we propagate most of exceptions, which will
+            # subsequently kill the process. We expect the handling functions
+            # to catch their own exceptions which they can recover from
+            # (malformed packets, lost connections, etc). The rationale behind
+            # this is they know best which exceptions are recoverable there
+            # and an exception may be recoverable somewhere, but not elsewhere.
+
+            try:
+                (reads, writes, exceptions) = \
+                    select.select([cc_fileno, listen_fileno] +
+                                  list(self._socksession_receivers.keys()), [],
+                                  [])
+            except select.error as se:
+                # In case it is just interrupted, we continue like nothing
+                # happened
+                if se.args[0] == errno.EINTR:
+                    (reads, writes, exceptions) = ([], [], [])
+                else:
+                    raise
+            for fileno in reads:
+                if fileno == cc_fileno:
+                    self._cc.check_command(True)
+                elif fileno == listen_fileno:
+                    self.accept()
+                else:
+                    self.handle_session(fileno)
         self.shutdown_cleanup()
         logger.info(DDNS_STOPPED)
 
@@ -204,6 +304,7 @@ def main(ddns_server=None):
         logger.error(DDNS_CC_SESSION_TIMEOUT_ERROR)
     except Exception as e:
         logger.error(DDNS_UNCAUGHT_EXCEPTION, type(e).__name__, str(e))
+    clear_socket()
 
 if '__main__' == __name__:
     main()

+ 24 - 0
src/bin/ddns/ddns_messages.mes

@@ -19,6 +19,12 @@
 # <topsrcdir>/tools/reorder_message_file.py to make sure the
 # messages are in the correct order.
 
+% DDNS_ACCEPT_FAILURE error accepting a connection: %1
+There was a low-level error when we tried to accept an incoming connection
+(probably coming from b10-auth). We continue serving on whatever other
+connections we already have, but this connection is dropped. The reason
+is logged.
+
 % DDNS_CC_SESSION_ERROR error reading from cc channel: %1
 There was a problem reading from the command and control channel. The
 most likely cause is that the msgq process is not running.
@@ -32,6 +38,13 @@ configuration manager b10-cfgmgr is not running.
 The ddns process encountered an error when installing the configuration at
 startup time.  Details of the error are included in the log message.
 
+% DDNS_DROP_CONN dropping connection on file descriptor %1 because of error %2
+There was an error on a connection with the b10-auth server (or whatever
+connects to the ddns daemon). This might be OK, for example when the
+authoritative server shuts down, the connection would get closed. It also
+can mean the system is busy and can't keep up or that the other side got
+confused and sent bad data.
+
 % DDNS_MODULECC_SESSION_ERROR error encountered by configuration/command module: %1
 There was a problem in the lower level module handling configuration and
 control commands.  This could happen for various reasons, but the most likely
@@ -39,6 +52,12 @@ cause is that the configuration database contains a syntax error and ddns
 failed to start at initialization.  A detailed error message from the module
 will also be displayed.
 
+% DDNS_NEW_CONN new connection on file descriptor %1 from %2
+Debug message. We received a connection and we are going to start handling
+requests from it. The file descriptor number and the address where the request
+comes from is logged. The connection is over a unix domain socket and is likely
+coming from a b10-auth process.
+
 % DDNS_RECEIVED_SHUTDOWN_COMMAND shutdown command received
 The ddns process received a shutdown command from the command channel
 and will now shut down.
@@ -47,6 +66,11 @@ and will now shut down.
 The ddns process has successfully started and is now ready to receive commands
 and updates.
 
+% DDNS_SESSION session arrived on file descriptor %1
+A debug message, informing there's some activity on the given file descriptor.
+It will be either a request or the file descriptor will be closed. See
+following log messages to see what of it.
+
 % DDNS_SHUTDOWN ddns server shutting down
 The ddns process is shutting down. It will no longer listen for new commands
 or updates. Any command or update that is being addressed at this moment will

+ 1 - 0
src/bin/ddns/tests/Makefile.am

@@ -21,6 +21,7 @@ endif
 	for pytest in $(PYTESTS) ; do \
 	echo Running test: $$pytest ; \
 	B10_FROM_SOURCE=$(abs_top_srcdir) \
+	B10_FROM_BUILD=$(abs_top_builddir) \
 	$(LIBRARY_PATH_PLACEHOLDER) \
 	PYTHONPATH=$(COMMON_PYTHON_PATH):$(abs_top_builddir)/src/bin/ddns:$(abs_top_builddir)/src/lib/dns/python/.libs:$(abs_top_builddir)/src/lib/util/io/.libs \
 	TESTDATASRCDIR=$(abs_srcdir)/testdata/ \

+ 272 - 1
src/bin/ddns/tests/ddns_test.py

@@ -19,6 +19,37 @@ import unittest
 import isc
 import ddns
 import isc.config
+import select
+import errno
+import isc.util.io.socketsession
+import socket
+import os.path
+
+class FakeSocket:
+    """
+    A fake socket. It only provides a file number, peer name and accept method.
+    """
+    def __init__(self, fileno):
+        self.__fileno = fileno
+    def fileno(self):
+        return self.__fileno
+    def getpeername(self):
+        return "fake_unix_socket"
+    def accept(self):
+        return FakeSocket(self.__fileno + 1)
+
+class FakeSessionReceiver:
+    """
+    A fake socket session receiver, for our tests.
+    """
+    def __init__(self, socket):
+        self._socket = socket
+    def socket(self):
+        """
+        This method is not present in the real receiver, but we use it to
+        inspect the socket passed to the constructor.
+        """
+        return self._socket
 
 class MyCCSession(isc.config.ConfigData):
     '''Fake session with minimal interface compliance'''
@@ -32,6 +63,12 @@ class MyCCSession(isc.config.ConfigData):
         '''Called by DDNSServer initialization, but not used in tests'''
         self._started = True
 
+    def get_socket(self):
+        """
+        Used to get the file number for select.
+        """
+        return FakeSocket(1)
+
 class MyDDNSServer():
     '''Fake DDNS server used to test the main() function'''
     def __init__(self):
@@ -63,7 +100,41 @@ class TestDDNSServer(unittest.TestCase):
         cc_session = MyCCSession()
         self.assertFalse(cc_session._started)
         self.ddns_server = ddns.DDNSServer(cc_session)
+        self.__cc_session = cc_session
         self.assertTrue(cc_session._started)
+        self.__select_expected = None
+        self.__select_answer = None
+        self.__select_exception = None
+        self.__hook_called = False
+        self.ddns_server._listen_socket = FakeSocket(2)
+        ddns.select.select = self.__select
+
+    def tearDown(self):
+        ddns.select.select = select.select
+        ddns.isc.util.io.socketsession.SocketSessionReceiver = \
+            isc.util.io.socketsession.SocketSessionReceiver
+
+    def test_listen(self):
+        '''
+        Test the old socket file is removed (if any) and a new socket
+        is created when the ddns server is created.
+        '''
+        # Make sure the socket does not exist now
+        ddns.clear_socket()
+        # Hook the call for clearing the socket
+        orig_clear = ddns.clear_socket
+        ddns.clear_socket = self.__hook
+        # Create the server
+        ddnss = ddns.DDNSServer(MyCCSession())
+        ddns.clear_socket = orig_clear
+        # The socket is created
+        self.assertTrue(os.path.exists(ddns.SOCKET_FILE))
+        self.assertTrue(isinstance(ddnss._listen_socket, socket.socket))
+        # And deletion of the socket was requested
+        self.assertIsNone(self.__hook_called)
+        # Now make sure the clear_socket really works
+        ddns.clear_socket()
+        self.assertFalse(os.path.exists(ddns.SOCKET_FILE))
 
     def test_config_handler(self):
         # Config handler does not do anything yet, but should at least
@@ -93,14 +164,215 @@ class TestDDNSServer(unittest.TestCase):
         signal_handler(None, None)
         self.assertTrue(self.ddns_server._shutdown)
 
+    def __select(self, reads, writes, exceptions, timeout=None):
+        """
+        A fake select. It checks it was called with the correct parameters and
+        returns a preset answer.
+
+        If there's an exception stored in __select_exception, it is raised
+        instead and the exception is cleared.
+        """
+        self.assertEqual(self.__select_expected, (reads, writes, exceptions,
+                                                  timeout))
+        if self.__select_exception is not None:
+            (self.__select_exception, exception) = (None,
+                                                    self.__select_exception)
+            raise exception
+        answer = self.__select_answer
+        self.__select_answer = None
+        self.ddns_server._shutdown = True
+        return answer
+
+    def __hook(self, param=None):
+        """
+        A hook that can be installed to any nullary or unary function and see
+        if it was really called.
+        """
+        self.__hook_called = param
+
+    def test_accept_called(self):
+        """
+        Test we call the accept function when a new connection comes.
+        """
+        self.ddns_server.accept = self.__hook
+        self.__select_expected = ([1, 2], [], [], None)
+        self.__select_answer = ([2], [], [])
+        self.__hook_called = "Not called"
+        self.ddns_server.run()
+        self.assertTrue(self.ddns_server._shutdown)
+        # The answer got used
+        self.assertIsNone(self.__select_answer)
+        # Reset, when called without parameter
+        self.assertIsNone(self.__hook_called)
+
+    def test_check_command_called(self):
+        """
+        Test the check_command is called when there's something on the
+        socket.
+        """
+        self.__cc_session.check_command = self.__hook
+        self.__select_expected = ([1, 2], [], [], None)
+        self.__select_answer = ([1], [], [])
+        self.ddns_server.run()
+        self.assertTrue(self.ddns_server._shutdown)
+        # The answer got used
+        self.assertIsNone(self.__select_answer)
+        # And the check_command was called with true parameter (eg.
+        # non-blocking)
+        self.assertTrue(self.__hook_called)
+
+    def test_accept(self):
+        """
+        Test that we can accept a new connection.
+        """
+        # There's nothing before the accept
+        ddns.isc.util.io.socketsession.SocketSessionReceiver = \
+            FakeSessionReceiver
+        self.assertEqual({}, self.ddns_server._socksession_receivers)
+        self.ddns_server.accept()
+        # Now the new socket session receiver is stored in the dict
+        # The 3 comes from _listen_socket.accept() - _listen_socket has
+        # fileno 2 and accept returns socket with fileno increased by one.
+        self.assertEqual([3],
+                         list(self.ddns_server._socksession_receivers.keys()))
+        (socket, receiver) = self.ddns_server._socksession_receivers[3]
+        self.assertTrue(isinstance(socket, FakeSocket))
+        self.assertEqual(3, socket.fileno())
+        self.assertTrue(isinstance(receiver, FakeSessionReceiver))
+        self.assertEqual(socket, receiver.socket())
+
+    def test_accept_fail(self):
+        """
+        Test we don't crash if an accept fails and that we don't modify the
+        internals.
+        """
+        # Make the accept fail
+        def accept_failure():
+            raise socket.error(errno.ECONNABORTED)
+        orig = self.ddns_server._listen_socket.accept
+        self.ddns_server._listen_socket.accept = accept_failure
+        self.assertEqual({}, self.ddns_server._socksession_receivers)
+        # Doesn't raise the exception
+        self.ddns_server.accept()
+        # And nothing is stored
+        self.assertEqual({}, self.ddns_server._socksession_receivers)
+        # Now make the socket receiver fail
+        self.ddns_server._listen_socket.accept = orig
+        def receiver_failure(sock):
+            raise isc.util.io.socketsession.SocketSessionError('Test error')
+        ddns.isc.util.io.socketsession.SocketSessionReceiver = \
+            receiver_failure
+        # Doesn't raise the exception
+        self.ddns_server.accept()
+        # And nothing is stored
+        self.assertEqual({}, self.ddns_server._socksession_receivers)
+        # Check we don't catch everything, so raise just an exception
+        def unexpected_failure(sock):
+            raise Exception('Test error')
+        ddns.isc.util.io.socketsession.SocketSessionReceiver = \
+            unexpected_failure
+        # This one gets through
+        self.assertRaises(Exception, self.ddns_server.accept)
+        # Nothing is stored as well
+        self.assertEqual({}, self.ddns_server._socksession_receivers)
+
+    def test_session_called(self):
+        """
+        Test the run calls handle_session when there's something on the
+        socket.
+        """
+        socket = FakeSocket(3)
+        self.ddns_server._socksession_receivers = \
+            {3: (socket, FakeSessionReceiver(socket))}
+        self.ddns_server.handle_session = self.__hook
+        self.__select_expected = ([1, 2, 3], [], [], None)
+        self.__select_answer = ([3], [], [])
+        self.ddns_server.run()
+        self.assertTrue(self.ddns_server._shutdown)
+        self.assertIsNone(self.__select_answer)
+        self.assertEqual(3, self.__hook_called)
+
+    def test_handle_session_ok(self):
+        """
+        Test the handle_session pops the receiver and calls handle_request
+        when everything is OK.
+        """
+        socket = FakeSocket(3)
+        receiver = FakeSessionReceiver(socket)
+        # It doesn't really matter what data we use here, it is only passed
+        # through the code
+        param = (FakeSocket(4), ('127.0.0.1', 1234), ('127.0.0.1', 1235),
+                 'Some data')
+        def pop():
+            return param
+        # Prepare data into the receiver
+        receiver.pop = pop
+        self.ddns_server._socksession_receivers = {3: (socket, receiver)}
+        self.ddns_server.handle_request = self.__hook
+        # Call it
+        self.ddns_server.handle_session(3)
+        # The popped data are passed into the handle_request
+        self.assertEqual(param, self.__hook_called)
+        # The receivers are kept the same
+        self.assertEqual({3: (socket, receiver)},
+                         self.ddns_server._socksession_receivers)
+
+    def test_handle_session_fail(self):
+        """
+        Test the handle_session removes (and closes) the socket and receiver
+        when the receiver complains.
+        """
+        socket = FakeSocket(3)
+        receiver = FakeSessionReceiver(socket)
+        def pop():
+            raise isc.util.io.socketsession.SocketSessionError('Test error')
+        receiver.pop = pop
+        socket.close = self.__hook
+        self.__hook_called = False
+        self.ddns_server._socksession_receivers = {3: (socket, receiver)}
+        self.ddns_server.handle_session(3)
+        # The "dead" receiver is removed
+        self.assertEqual({}, self.ddns_server._socksession_receivers)
+        # Close is called with no parameter, so the default None
+        self.assertIsNone(self.__hook_called)
+
+    def test_select_exception_ignored(self):
+        """
+        Test that the EINTR is ignored in select.
+        """
+        # Prepare the EINTR exception
+        self.__select_exception = select.error(errno.EINTR)
+        # We reuse the test here, as it should act the same. The exception
+        # should just get ignored.
+        self.test_check_command_called()
+
+    def test_select_exception_fatal(self):
+        """
+        Test that other exceptions are fatal to the run.
+        """
+        # Prepare a different exception
+        self.__select_exception = select.error(errno.EBADF)
+        self.__select_expected = ([1, 2], [], [], None)
+        self.assertRaises(select.error, self.ddns_server.run)
+
 class TestMain(unittest.TestCase):
     def setUp(self):
         self._server = MyDDNSServer()
+        self.__orig_clear = ddns.clear_socket
+        ddns.clear_socket = self.__clear_socket
+        self.__clear_called = False
+
+    def tearDown(self):
+        ddns.clear_socket = self.__orig_clear
 
     def test_main(self):
         self.assertFalse(self._server.run_called)
         ddns.main(self._server)
         self.assertTrue(self._server.run_called)
+        self.assertTrue(self.__clear_called)
+
+    def __clear_socket(self):
+        self.__clear_called = True
 
     def check_exception(self, ex):
         '''Common test sequence to see if the given exception is caused.
@@ -135,7 +407,6 @@ class TestMain(unittest.TestCase):
         self._server.set_exception(BaseException("error"))
         self.assertRaises(BaseException, ddns.main, self._server)
         self.assertTrue(self._server.exception_raised)
-        
 
 if __name__== "__main__":
     isc.log.resetUnitTestRootLogger()