Add support for SMPP testing

As defined in [1], the different related actors are implemented in this
commit: ESME and SMSC.

SMSC: In Osmocom, the SMSC is currently implemented inside the NITB or
the MSC. A new Smsc abstract class is created to shared code between the
NITB and the MSC, and also makes it easier for later when the SMSC is
splitted. ESMEs can be dynamically added to its configuration in a
similar way to how the BTSs are added.

ESME: A new class Esme is created which can be used by tests to control
an ESME to interact with the SMSC. The ESME functionalities are
implemented using python-smpplib. Required version of this library is at
least 43cc6f819ec76b2c0a9d36d1d439308634716227, which contains support
for python 3 and some required features to poll the socket.

This commit already contains a few tests which checks different
features and tests the API. Extending tested features or scenarios can be
later done quite easily.

The tests are not enabled by default right now, because there are several
of them in a suite and the ip_address resources are not freed after every
tests which ends up in the suite failing due to missing reserved
resources. All the tests run alone work though. When the issue is fixed
they can then be added to the default list of tests to be run.

[1] http://opensmpp.org/specs/SMPP_v3_4_Issue1_2.pdf

Change-Id: I14ca3cb009d6d646a449ca99b0200da12085c0da
diff --git a/src/osmo_gsm_tester/esme.py b/src/osmo_gsm_tester/esme.py
new file mode 100644
index 0000000..f92863d
--- /dev/null
+++ b/src/osmo_gsm_tester/esme.py
@@ -0,0 +1,138 @@
+# osmo_gsm_tester: SMPP ESME to talk to SMSC
+#
+# Copyright (C) 2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Pau Espin Pedrol <pespin@sysmocom.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import smpplib.gsm
+import smpplib.client
+import smpplib.consts
+import smpplib.exceptions
+
+from . import log, util, event_loop, sms
+
+# if you want to know what's happening inside python-smpplib
+#import logging
+#logging.basicConfig(level='DEBUG')
+
+MAX_SYS_ID_LEN = 16
+MAX_PASSWD_LEN = 16
+
+class Esme(log.Origin):
+    client = None
+    smsc = None
+
+    def __init__(self, msisdn):
+        self.msisdn = msisdn
+        # Get last characters of msisdn to stay inside MAX_SYS_ID_LEN. Similar to modulus operator.
+        self.set_system_id('esme-' + self.msisdn[-11:])
+        super().__init__(log.C_TST, self.system_id)
+        self.set_password('esme-pwd')
+        self.connected = False
+        self.bound = False
+        self.listening = False
+
+    def __del__(self):
+        try:
+            self.disconnect()
+        except smpplib.exceptions.ConnectionError:
+            pass
+
+    def set_smsc(self, smsc):
+        self.smsc = smsc
+
+    def set_system_id(self, name):
+        if len(name) > MAX_SYS_ID_LEN:
+            raise log.Error('Esme system_id too long! %d vs %d', len(name), MAX_SYS_ID_LEN)
+        self.system_id = name
+
+    def set_password(self, password):
+        if len(password) > MAX_PASSWD_LEN:
+            raise log.Error('Esme password too long! %d vs %d', len(password), MAX_PASSWD_LEN)
+        self.password = password
+
+    def conf_for_smsc(self):
+        config = { 'system_id': self.system_id, 'password': self.password }
+        return config
+
+    def poll(self):
+        self.client.poll()
+
+    def start_listening(self):
+        self.listening = True
+        event_loop.register_poll_func(self.poll)
+
+    def stop_listening(self):
+        if not self.listening:
+            return
+        self.listening = False
+        # Empty the queue before processing the unbind + disconnect PDUs
+        event_loop.unregister_poll_func(self.poll)
+        self.poll()
+
+    def connect(self):
+        host, port = self.smsc.addr_port
+        if self.client:
+            self.disconnect()
+        self.client = smpplib.client.Client(host, port, timeout=None)
+        self.client.set_message_sent_handler(
+            lambda pdu: self.dbg('message sent:', repr(pdu)) )
+        self.client.set_message_received_handler(
+            lambda pdu: self.dbg('message received:', repr(pdu)) )
+        self.client.connect()
+        self.connected = True
+        self.client.bind_transceiver(system_id=self.system_id, password=self.password)
+        self.bound = True
+        self.log('Connected and bound successfully. Starting to listen')
+        self.start_listening()
+
+    def disconnect(self):
+        self.stop_listening()
+        if self.bound:
+            self.client.unbind()
+            self.bound = False
+        if self.connected:
+            self.client.disconnect()
+            self.connected = False
+
+    def run_method_expect_failure(self, errcode, method, *args):
+        try:
+            method(*args)
+            #it should not succeed, raise an exception:
+            raise log.Error('SMPP Failure: %s should have failed with SMPP error %d (%s) but succeeded.' % (method, errcode, smpplib.consts.DESCRIPTIONS[errcode]))
+        except smpplib.exceptions.PDUError as e:
+            if e.args[1] != errcode:
+                raise e
+
+    def sms_send(self, sms_obj):
+        parts, encoding_flag, msg_type_flag = smpplib.gsm.make_parts(str(sms_obj))
+
+        self.log('Sending SMS "%s" to %s' % (str(sms_obj), sms_obj.dst_msisdn()))
+        for part in parts:
+            pdu = self.client.send_message(
+                source_addr_ton=smpplib.consts.SMPP_TON_INTL,
+                source_addr_npi=smpplib.consts.SMPP_NPI_ISDN,
+                source_addr=sms_obj.src_msisdn(),
+                dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
+                dest_addr_npi=smpplib.consts.SMPP_NPI_ISDN,
+                destination_addr=sms_obj.dst_msisdn(),
+                short_message=part,
+                data_coding=encoding_flag,
+                esm_class=smpplib.consts.SMPP_MSGMODE_FORWARD,
+                registered_delivery=False,
+                )
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/osmo_msc.py b/src/osmo_gsm_tester/osmo_msc.py
index 063b477..2c9b1e3 100644
--- a/src/osmo_gsm_tester/osmo_msc.py
+++ b/src/osmo_gsm_tester/osmo_msc.py
@@ -20,7 +20,7 @@
 import os
 import pprint
 
-from . import log, util, config, template, process, osmo_ctrl, pcap_recorder
+from . import log, util, config, template, process, osmo_ctrl, pcap_recorder, smsc
 
 class OsmoMsc(log.Origin):
     suite_run = None
@@ -30,6 +30,7 @@
     process = None
     hlr = None
     config = None
+    smsc = None
 
     def __init__(self, suite_run, hlr, mgcpgw, ip_address):
         super().__init__(log.C_RUN, 'osmo-msc_%s' % ip_address.get('addr'))
@@ -37,6 +38,7 @@
         self.ip_address = ip_address
         self.hlr = hlr
         self.mgcpgw = mgcpgw
+        self.smsc = smsc.Smsc((ip_address.get('addr'), 2775))
 
     def start(self):
         self.log('Starting osmo-msc')
@@ -73,6 +75,7 @@
         config.overlay(values, dict(msc=dict(ip_address=self.ip_address)))
         config.overlay(values, self.mgcpgw.conf_for_msc())
         config.overlay(values, self.hlr.conf_for_msc())
+        config.overlay(values, self.smsc.get_config())
         self.config = values
 
         self.dbg('MSC CONFIG:\n' + pprint.pformat(values))
diff --git a/src/osmo_gsm_tester/osmo_nitb.py b/src/osmo_gsm_tester/osmo_nitb.py
index 484358e..3ef5276 100644
--- a/src/osmo_gsm_tester/osmo_nitb.py
+++ b/src/osmo_gsm_tester/osmo_nitb.py
@@ -21,7 +21,7 @@
 import re
 import pprint
 
-from . import log, util, config, template, process, osmo_ctrl, pcap_recorder
+from . import log, util, config, template, process, osmo_ctrl, pcap_recorder, smsc
 
 class OsmoNitb(log.Origin):
     suite_run = None
@@ -30,12 +30,14 @@
     config_file = None
     process = None
     bts = None
+    smsc = None
 
     def __init__(self, suite_run, ip_address):
         super().__init__(log.C_RUN, 'osmo-nitb_%s' % ip_address.get('addr'))
         self.suite_run = suite_run
         self.ip_address = ip_address
         self.bts = []
+        self.smsc = smsc.Smsc((ip_address.get('addr'), 2775))
 
     def start(self):
         self.log('Starting osmo-nitb')
@@ -75,6 +77,7 @@
         for bts in self.bts:
             bts_list.append(bts.conf_for_bsc())
         config.overlay(values, dict(nitb=dict(net=dict(bts_list=bts_list))))
+        config.overlay(values, self.smsc.get_config())
         self.config = values
 
         self.dbg('NITB CONFIG:\n' + pprint.pformat(values))
diff --git a/src/osmo_gsm_tester/sms.py b/src/osmo_gsm_tester/sms.py
index 570ef96..e264b66 100644
--- a/src/osmo_gsm_tester/sms.py
+++ b/src/osmo_gsm_tester/sms.py
@@ -21,14 +21,16 @@
     _last_sms_idx = 0
     msg = None
 
-    def __init__(self, from_msisdn=None, to_msisdn=None, *tokens):
+    def __init__(self, src_msisdn=None, dst_msisdn=None, *tokens):
         Sms._last_sms_idx += 1
+        self._src_msisdn = src_msisdn
+        self._dst_msisdn = dst_msisdn
         msgs = ['message nr. %d' % Sms._last_sms_idx]
         msgs.extend(tokens)
-        if from_msisdn:
-            msgs.append('from %s' % from_msisdn)
-        if to_msisdn:
-            msgs.append('to %s' % to_msisdn)
+        if src_msisdn:
+            msgs.append('from %s' % src_msisdn)
+        if dst_msisdn:
+            msgs.append('to %s' % dst_msisdn)
         self.msg = ', '.join(msgs)
 
     def __str__(self):
@@ -42,6 +44,12 @@
             return self.msg == other.msg
         return self.msg == other
 
+    def src_msisdn(self):
+        return self._src_msisdn
+
+    def dst_msisdn(self):
+        return self._dst_msisdn
+
     def matches(self, msg):
         return self.msg == msg
 
diff --git a/src/osmo_gsm_tester/smsc.py b/src/osmo_gsm_tester/smsc.py
new file mode 100644
index 0000000..4837f37
--- /dev/null
+++ b/src/osmo_gsm_tester/smsc.py
@@ -0,0 +1,50 @@
+# osmo_gsm_tester: smsc interface
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Pau Espin Pedrol <pespin@sysmocom.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from . import log, config, util, template, process
+
+class Smsc:
+    esmes = None
+
+    SMSC_POLICY_CLOSED = 'closed'
+    SMSC_POLICY_ACCEPT_ALL = 'accept-all'
+
+    def __init__(self, smpp_addr_port):
+        self.addr_port = smpp_addr_port
+        self.policy = self.SMSC_POLICY_CLOSED
+        self.esmes = []
+
+    def get_config(self):
+        values = { 'smsc': { 'policy': self.policy } }
+        esme_list = []
+        for esme in self.esmes:
+            esme_list.append(esme.conf_for_smsc())
+        config.overlay(values, dict(smsc=dict(esme_list=esme_list)))
+        return values
+
+    def esme_add(self, esme):
+        if esme.system_id == '':
+            raise log.Error('esme system_id cannot be empty')
+        self.esmes.append(esme)
+        esme.set_smsc(self)
+
+    def set_smsc_policy(self, smsc_policy):
+        self.policy = smsc_policy
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/suite.py b/src/osmo_gsm_tester/suite.py
index f4b9260..71b8dc7 100644
--- a/src/osmo_gsm_tester/suite.py
+++ b/src/osmo_gsm_tester/suite.py
@@ -23,7 +23,7 @@
 import copy
 import traceback
 import pprint
-from . import config, log, template, util, resource, schema, ofono_client, event_loop
+from . import config, log, template, util, resource, schema, ofono_client, event_loop, esme, sms
 from . import osmo_nitb
 from . import osmo_hlr, osmo_mgcpgw, osmo_msc, osmo_bsc, osmo_stp
 from . import test
@@ -99,7 +99,7 @@
             log.large_separator(self.suite_run.trial.name(), self.suite_run.name(), self.name(), sublevel=3)
             self.status = Test.UNKNOWN
             self.start_timestamp = time.time()
-            test.setup(self.suite_run, self, ofono_client, sys.modules[__name__], event_loop)
+            test.setup(self.suite_run, self, ofono_client, sys.modules[__name__], event_loop, sms)
             with self.redirect_stdout():
                 util.run_python_file('%s.%s' % (self.suite_run.definition.name(), self.basename),
                                      self.path)
@@ -363,8 +363,12 @@
             l.append(self.modem())
         return l
 
+    def esme(self):
+        esme_obj = esme.Esme(self.msisdn())
+        return esme_obj
+
     def msisdn(self):
-        msisdn = self.resources_pool.next_msisdn(self.origin)
+        msisdn = self.resources_pool.next_msisdn(self)
         self.log('using MSISDN', msisdn)
         return msisdn
 
diff --git a/src/osmo_gsm_tester/templates/osmo-msc.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-msc.cfg.tmpl
index 247365e..89982e0 100644
--- a/src/osmo_gsm_tester/templates/osmo-msc.cfg.tmpl
+++ b/src/osmo_gsm_tester/templates/osmo-msc.cfg.tmpl
@@ -23,10 +23,16 @@
  bind ${msc.ip_address.addr}
 smpp
  local-tcp-ip ${msc.ip_address.addr} 2775
- system-id test
- policy closed
- esme test
-  password test
+ system-id test-msc
+ policy ${smsc.policy}
+%for esme in smsc.esme_list:
+ esme ${esme.system_id}
+% if esme.password == '':
+  no password
+% else:
+  password ${esme.password}
+% endif
   default-route
+%endfor
 hlr
  remote-ip ${hlr.ip_address.addr}
diff --git a/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
index a47ac02..23cc225 100644
--- a/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
+++ b/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
@@ -76,10 +76,16 @@
 %endfor
 smpp
  local-tcp-ip ${nitb.ip_address.addr} 2775
- system-id test
- policy closed
- esme test
-  password test
+ system-id test-nitb
+ policy ${smsc.policy}
+%for esme in smsc.esme_list:
+ esme ${esme.system_id}
+% if esme.password == '':
+  no password
+% else:
+  password ${esme.password}
+% endif
   default-route
+%endfor
 ctrl
  bind ${nitb.ip_address.addr}
diff --git a/src/osmo_gsm_tester/test.py b/src/osmo_gsm_tester/test.py
index 2958501..49911b3 100644
--- a/src/osmo_gsm_tester/test.py
+++ b/src/osmo_gsm_tester/test.py
@@ -33,9 +33,10 @@
 poll = None
 prompt = None
 Timeout = None
+Sms = None
 
-def setup(suite_run, _test, ofono_client, suite_module, event_module):
-    global trial, suite, test, resources, log, dbg, err, wait, wait_no_raise, sleep, poll, prompt, Timeout
+def setup(suite_run, _test, ofono_client, suite_module, event_module, sms_module):
+    global trial, suite, test, resources, log, dbg, err, wait, wait_no_raise, sleep, poll, prompt, Timeout, Sms
     trial = suite_run.trial
     suite = suite_run
     test = _test
@@ -49,5 +50,6 @@
     poll = event_module.poll
     prompt = suite_run.prompt
     Timeout = suite_module.Timeout
+    Sms = sms_module.Sms
 
 # vim: expandtab tabstop=4 shiftwidth=4