Init commit for standalone enodebd
Change-Id: I88eeef5135dd7ba8551ddd9fb6a0695f5325337b
diff --git a/tr069/__init__.py b/tr069/__init__.py
new file mode 100644
index 0000000..5c6cb64
--- /dev/null
+++ b/tr069/__init__.py
@@ -0,0 +1,12 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
diff --git a/tr069/models.py b/tr069/models.py
new file mode 100644
index 0000000..1f850c0
--- /dev/null
+++ b/tr069/models.py
@@ -0,0 +1,433 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+
+from spyne.model import ComplexModel
+from spyne.model.complex import XmlAttribute, XmlData
+from spyne.model.primitive import (
+ Boolean,
+ DateTime,
+ Integer,
+ String,
+ UnsignedInteger,
+)
+from spyne.util.odict import odict
+
+# Namespaces
+XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance'
+SOAP_ENV = 'http://schemas.xmlsoap.org/soap/envelope/'
+SOAP_ENC = 'http://schemas.xmlsoap.org/soap/encoding/'
+CWMP_NS = 'urn:dslforum-org:cwmp-1-0'
+
+
+class Tr069ComplexModel(ComplexModel):
+ """ Base class for TR-069 models, to set common attributes. Does not appear
+ in CWMP XSD file. """
+ __namespace__ = CWMP_NS
+
+
+class anySimpleType(Tr069ComplexModel):
+ """ Type used to transfer simple data of various types. Data type is
+ defined in 'type' XML attribute. Data is handled as a string. """
+ _type_info = odict()
+ _type_info["type"] = XmlAttribute(String, ns=XSI_NS)
+ _type_info["Data"] = XmlData(String)
+
+ def __repr__(self):
+ """For types we can't resolve only print the datum"""
+ return self.Data
+
+
+# SOAP Header Elements
+
+
+class ID(Tr069ComplexModel):
+ # Note: for some reason, XmlAttribute/XmlData pairs MUST be ordered, with
+ # XmlAttribute coming first. This appears to be a spyne bug (something to do
+ # with spyne.interface._base.add_class())
+ _type_info = odict()
+ _type_info["mustUnderstand"] = XmlAttribute(String, ns=SOAP_ENV)
+ _type_info["Data"] = XmlData(String)
+
+
+class HoldRequests(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["mustUnderstand"] = XmlAttribute(String, ns=SOAP_ENV)
+ _type_info["Data"] = XmlData(Boolean)
+
+
+# SOAP Fault Extensions
+
+
+class SetParameterValuesFault(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterName"] = String
+ _type_info["FaultCode"] = UnsignedInteger
+ _type_info["FaultString"] = String
+
+
+class Fault(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["FaultCode"] = UnsignedInteger
+ _type_info["FaultString"] = String
+ _type_info["SetParameterValuesFault"] = SetParameterValuesFault.customize(
+ max_occurs='unbounded',
+ )
+
+
+# Type definitions used in messages
+
+
+class MethodList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["string"] = String(max_length=64, max_occurs='unbounded')
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class FaultStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["FaultCode"] = Integer
+ _type_info["FaultString"] = String(max_length=256)
+
+
+class DeviceIdStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Manufacturer"] = String(max_length=64)
+ _type_info["OUI"] = String(length=6)
+ _type_info["ProductClass"] = String(max_length=64)
+ _type_info["SerialNumber"] = String(max_length=64)
+
+
+class EventStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["EventCode"] = String(max_length=64)
+ _type_info["CommandKey"] = String(max_length=32)
+
+
+class EventList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["EventStruct"] = EventStruct.customize(max_occurs='unbounded')
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class ParameterValueStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Name"] = String
+ _type_info["Value"] = anySimpleType
+
+
+class ParameterValueList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterValueStruct"] = ParameterValueStruct.customize(
+ max_occurs='unbounded',
+ )
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class ParameterInfoStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Name"] = String(max_length=256)
+ _type_info["Writable"] = Boolean
+
+
+class ParameterInfoList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterInfoStruct"] = ParameterInfoStruct.customize(max_occurs='unbounded')
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class ParameterNames(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["string"] = String.customize(max_occurs='unbounded', max_length=256)
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class ParameterKeyType(anySimpleType):
+ pass
+
+
+class AccessList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["string"] = String.customize(max_occurs='unbounded', max_length=64)
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class SetParameterAttributesStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Name"] = String(max_length=256)
+ _type_info["NotificationChange"] = Boolean
+ _type_info["Notification"] = Integer
+ _type_info["AccessListChange"] = Boolean
+ _type_info["AccessList"] = AccessList
+
+
+class SetParameterAttributesList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["SetParameterAttributesStruct"] = SetParameterAttributesStruct.customize(
+ max_occurs='unbounded',
+ )
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class ParameterAttributeStruct(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Name"] = String(max_length=256)
+ _type_info["Notification"] = Integer
+ _type_info["AccessList"] = AccessList
+
+
+class ParameterAttributeList(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterValueStruct"] = ParameterAttributeStruct.customize(
+ max_occurs='unbounded',
+ )
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+
+class CommandKeyType(String.customize(max_length=32)):
+ pass
+
+
+class ObjectNameType(String.customize(max_length=256)):
+ pass
+
+
+# CPE messages
+
+
+class SetParameterValues(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterList"] = ParameterValueList
+ _type_info["ParameterKey"] = ParameterKeyType
+
+
+class SetParameterValuesResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Status"] = Integer
+
+
+class GetParameterValues(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterNames"] = ParameterNames
+
+
+class GetParameterValuesResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterList"] = ParameterValueList
+
+
+class GetParameterNames(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterPath"] = String.customize(max_length=256)
+ _type_info["NextLevel"] = Boolean
+
+
+class GetParameterNamesResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterList"] = ParameterInfoList
+
+
+class SetParameterAttributes(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterList"] = SetParameterAttributesList
+
+
+class SetParameterAttributesResponse(Tr069ComplexModel):
+ # Dummy field required because spyne does not allow 'bare' RPC function with
+ # no input parameters. This field is never sent by CPE.
+ _type_info = odict()
+ _type_info["DummyField"] = UnsignedInteger
+
+
+class GetParameterAttributes(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterNames"] = ParameterNames
+
+
+class GetParameterAttributesResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ParameterList"] = ParameterAttributeList
+
+
+class AddObject(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ObjectName"] = ObjectNameType
+ _type_info["ParameterKey"] = ParameterKeyType
+
+
+class AddObjectResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["InstanceNumber"] = UnsignedInteger
+ _type_info["Status"] = Integer
+
+
+class DeleteObject(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["ObjectName"] = ObjectNameType
+ _type_info["ParameterKey"] = ParameterKeyType
+
+
+class DeleteObjectResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Status"] = Integer
+
+
+class Download(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["CommandKey"] = CommandKeyType
+ _type_info["FileType"] = String(max_length=64)
+ _type_info["URL"] = String(max_length=256)
+ _type_info["Username"] = String(max_length=256)
+ _type_info["Password"] = String(max_length=256)
+ _type_info["FileSize"] = UnsignedInteger
+ _type_info["TargetFileName"] = String(max_length=256)
+ _type_info["DelaySeconds"] = UnsignedInteger
+ _type_info["SuccessURL"] = String(max_length=256)
+ _type_info["FailureURL"] = String(max_length=256)
+
+
+class DownloadResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["Status"] = Integer
+ _type_info["StartTime"] = DateTime
+ _type_info["CompleteTime"] = DateTime
+
+
+class Reboot(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["CommandKey"] = CommandKeyType
+
+
+class RebootResponse(Tr069ComplexModel):
+ # Dummy field required because spyne does not allow 'bare' RPC function with
+ # no input parameters. This field is never sent by CPE.
+ _type_info = odict()
+ _type_info["DummyField"] = UnsignedInteger
+
+
+# ACS messages
+
+
+class Inform(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["DeviceId"] = DeviceIdStruct
+ _type_info["Event"] = EventList
+ _type_info["MaxEnvelopes"] = UnsignedInteger
+ _type_info["CurrentTime"] = DateTime
+ _type_info["RetryCount"] = UnsignedInteger
+ _type_info["ParameterList"] = ParameterValueList
+
+
+class InformResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["MaxEnvelopes"] = UnsignedInteger
+
+
+class TransferComplete(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["CommandKey"] = CommandKeyType
+ _type_info["FaultStruct"] = FaultStruct
+ _type_info["StartTime"] = DateTime
+ _type_info["CompleteTime"] = DateTime
+
+
+class TransferCompleteResponse(Tr069ComplexModel):
+ # Dummy field required because spyne does not allow 'bare' RPC function with
+ # no input parameters. This field is never sent by ACS.
+ _type_info = odict()
+ _type_info["DummyField"] = UnsignedInteger
+
+
+class GetRPCMethods(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["DummyField"] = UnsignedInteger
+
+
+class GetRPCMethodsResponse(Tr069ComplexModel):
+ _type_info = odict()
+ _type_info["MethodList"] = MethodList
+
+
+#
+# Miscellaneous
+#
+
+class ParameterListUnion(Tr069ComplexModel):
+ """ Union of structures that get instantiated as 'ParameterList' in ACS->CPE
+ messages. This is required because AcsToCpeRequests can only have one
+ parameter named 'ParameterList', so that must also be a union """
+ _type_info = odict()
+
+ # Fields from ParameterValueList
+ _type_info["ParameterValueStruct"] = ParameterValueStruct.customize(
+ max_occurs='unbounded',
+ )
+ _type_info["arrayType"] = XmlAttribute(String, ns=SOAP_ENC)
+
+ # Fields from SetParameterAttributesList
+ _type_info["SetParameterAttributesStruct"] = \
+ SetParameterAttributesStruct.customize(max_occurs='unbounded')
+ # arrayType = XmlAttribute(String, ns=SOAP_ENC) - Already covered above
+
+
+class AcsToCpeRequests(Tr069ComplexModel):
+ """ Union of all ACS->CPE requests. Only fields for one request is populated
+ per message instance """
+ _type_info = odict()
+
+ # Fields for SetParameterValues
+ _type_info["ParameterList"] = ParameterListUnion # See ParameterListUnion for explanation
+ _type_info["ParameterKey"] = ParameterKeyType
+
+ # Fields for GetParameterValues
+ # _type_info["ParameterList"] = ParameterValueList - Already covered above
+
+ # Fields for GetParameterNames
+ _type_info["ParameterPath"] = String.customize(max_length=256)
+ _type_info["NextLevel"] = Boolean
+
+ # Fields for SetParameterAttributes
+ # _type_info["ParameterList"] = SetParameterAttributesList - Already covered above
+
+ # Fields for GetParameterAttributes
+ _type_info["ParameterNames"] = ParameterNames
+
+ # Fields for AddObject
+ _type_info["ObjectName"] = ObjectNameType
+ _type_info["ParameterKey"] = ParameterKeyType
+
+ # Fields for DeleteObject
+ # _type_info["ObjectName"] = ObjectNameType - Already covered above
+ # _type_info["ParameterKey"] = ParameterKeyType - Already covered above
+
+ # Fields for Download
+ _type_info["CommandKey"] = CommandKeyType
+ _type_info["FileType"] = String(max_length=64)
+ _type_info["URL"] = String(max_length=256)
+ _type_info["Username"] = String(max_length=256)
+ _type_info["Password"] = String(max_length=256)
+ _type_info["FileSize"] = UnsignedInteger
+ _type_info["TargetFileName"] = String(max_length=256)
+ _type_info["DelaySeconds"] = UnsignedInteger
+ _type_info["SuccessURL"] = String(max_length=256)
+ _type_info["FailureURL"] = String(max_length=256)
+
+ # Fields for Reboot
+ # _type_info["CommandKey"] = CommandKeyType - Already covered above
+
+
+class DummyInput(Tr069ComplexModel):
+ """ Dummy complex model. Used for 'EmptyHttp' function, because spyne Does
+ not handle 'bare' function with no inputs """
+ _type_info = odict()
+ _type_info["DummyField"] = UnsignedInteger
diff --git a/tr069/rpc_methods.py b/tr069/rpc_methods.py
new file mode 100644
index 0000000..aff0f30
--- /dev/null
+++ b/tr069/rpc_methods.py
@@ -0,0 +1,371 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from logger import EnodebdLogger as logger
+from state_machines.enb_acs_manager import StateMachineManager
+from spyne.decorator import rpc
+from spyne.model.complex import ComplexModelBase
+from spyne.server.wsgi import WsgiMethodContext
+from spyne.service import ServiceBase
+
+from . import models
+
+# Allow methods without 'self' as first input. Required by spyne
+# pylint: disable=no-self-argument
+
+# RPC methods supported by ACS
+RPC_METHODS = ['Inform', 'GetRPCMethods', 'TransferComplete']
+RPC_RESPONSES = [method + 'Response' for method in RPC_METHODS]
+# RPC methods supported by CPE
+CPE_RPC_METHODS = [
+ 'SetParameterValues',
+ 'GetParameterValues',
+ 'GetParameterNames',
+ 'SetParameterAttributes',
+ 'GetParameterAttributes',
+ 'AddObject',
+ 'DeleteObject',
+ 'Download',
+ 'Reboot',
+]
+CPE_RPC_RESPONSES = [method + 'Response' for method in CPE_RPC_METHODS]
+# ACS RPC methods that are not explicitly described by the spec (hence shouldn't
+# be advertised by GetRPCMethods). Note: No responses for these
+PSEUDO_RPC_METHODS = ['Fault']
+# Top-level CWMP header elements. Namespaces should be preserved on these (since
+# they are not within other CWMP elements)
+TOP_LEVEL_HEADER_ELEMENTS = ['ID', 'HoldRequests']
+
+
+def fill_response_header(ctx):
+ """ Echo message ID from input header to output header, when responding to
+ CPE->ACS RPC calls """
+ ctx.out_header = models.ID(mustUnderstand='1')
+ ctx.out_header.Data = ctx.in_header.Data
+
+
+class AutoConfigServer(ServiceBase):
+ """ TR-069 ACS implementation. The TR-069/CWMP RPC messages are defined, as
+ per cwmp-1-0.xsd schema definition, in the RPC decorators below. These
+ RPC methods are intended to be called by TR-069-compliant customer
+ premesis equipment (CPE), over the SOAP/HTTP interface defined by
+ TR-069.
+
+ Per spyne documentation, this class is never instantiated, so all RPC
+ functions are implicitly staticmethods. Hence use static class variables
+ to hold state.
+ This also means that only a single thread can be used (since there are
+ no locks).
+ Note that staticmethod decorator can't be used in conjunction with rpc
+ decorator.
+ """
+ __out_header__ = models.ID
+ __in_header__ = models.ID
+ _acs_to_cpe_queue = None
+ _cpe_to_acs_queue = None
+
+ """ Set maxEnvelopes to 1, as per TR-069 spec """
+ _max_envelopes = 1
+
+ @classmethod
+ def set_state_machine_manager(
+ cls,
+ state_machine_manager: StateMachineManager,
+ ) -> None:
+ cls.state_machine_manager = state_machine_manager
+
+ @classmethod
+ def _handle_tr069_message(
+ cls,
+ ctx: WsgiMethodContext,
+ message: ComplexModelBase,
+ ) -> ComplexModelBase:
+ # Log incoming msg
+ if hasattr(message, 'as_dict'):
+ logger.debug('Handling TR069 message: %s', str(type(message)))
+ else:
+ logger.debug('Handling TR069 message.')
+
+ req = cls._get_tr069_response_from_sm(ctx, message)
+
+ # Log outgoing msg
+ if hasattr(req, 'as_dict'):
+ logger.debug('Sending TR069 message: %s', str(req.as_dict()))
+ else:
+ logger.debug('Sending TR069 message.')
+
+ # Set header
+ ctx.out_header = models.ID(mustUnderstand='1')
+ ctx.out_header.Data = 'null'
+
+ # Set return message name
+ if isinstance(req, models.DummyInput):
+ # Generate 'empty' request to CPE using empty message name
+ ctx.descriptor.out_message.Attributes.sub_name = 'EmptyHttp'
+ return models.AcsToCpeRequests()
+ ctx.descriptor.out_message.Attributes.sub_name = req.__class__.__name__
+ return cls._generate_acs_to_cpe_request_copy(req)
+
+ @classmethod
+ def _get_tr069_response_from_sm(
+ cls,
+ ctx: WsgiMethodContext,
+ message: ComplexModelBase,
+ ) -> ComplexModelBase:
+ # We want to blanket-catch all exceptions because a problem with one
+ # tr-069 session shouldn't tank the service for all other enodeB's
+ # being managed
+ try:
+ return cls.state_machine_manager.handle_tr069_message(ctx, message)
+ except Exception: # pylint: disable=broad-except
+ logger.exception(
+ 'Unexpected exception from state machine manager, returning '
+ 'empty request',
+ )
+ return models.DummyInput()
+
+ @staticmethod
+ def _generate_acs_to_cpe_request_copy(request):
+ """ Create an AcsToCpeRequests instance with all the appropriate
+ members set from the input request. AcsToCpeRequests is a union of
+ all request messages, so field names match.
+ """
+ request_out = models.AcsToCpeRequests()
+ for parameter in request.get_flat_type_info(request.__class__):
+ try:
+ setattr(request_out, parameter, getattr(request, parameter))
+ except AttributeError:
+ # Allow un-set parameters. If CPE can't handle this, it will
+ # respond with an error message
+ pass
+ return request_out
+
+ # CPE->ACS RPC calls
+
+ @rpc(
+ models.GetRPCMethods,
+ _returns=models.GetRPCMethodsResponse,
+ _body_style="bare",
+ _operation_name="GetRPCMethods",
+ _out_message_name="GetRPCMethodsResponse",
+ )
+ def get_rpc_methods(ctx, request):
+ """ GetRPCMethods RPC call is terminated here. No need to pass to higher
+ layer """
+ fill_response_header(ctx)
+ resp = AutoConfigServer._handle_tr069_message(ctx, request)
+ return resp
+
+ @rpc(
+ models.Inform,
+ _returns=models.InformResponse,
+ _body_style="bare",
+ _operation_name="Inform",
+ _out_message_name="InformResponse",
+ )
+ def inform(ctx, request):
+ """ Inform response generated locally """
+ fill_response_header(ctx)
+ resp = AutoConfigServer._handle_tr069_message(ctx, request)
+ resp.MaxEnvelopes = AutoConfigServer._max_envelopes
+ return resp
+
+ @rpc(
+ models.TransferComplete,
+ _returns=models.TransferCompleteResponse,
+ _body_style="bare",
+ _operation_name="TransferComplete",
+ _out_message_name="TransferCompleteResponse",
+ )
+ def transfer_complete(ctx, request):
+ fill_response_header(ctx)
+ resp = AutoConfigServer._handle_tr069_message(ctx, request)
+ resp.MaxEnvelopes = AutoConfigServer._max_envelopes
+ return resp
+
+ # Spyne does not handle no input or SimpleModel input for 'bare' function
+ # DummyInput is unused
+ # pylint: disable=unused-argument
+ @rpc(
+ models.DummyInput,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="EmptyHttp",
+ _body_style='bare',
+ _operation_name="EmptyHttp",
+ )
+ def empty_http(ctx, dummy):
+ # Function to handle empty HTTP request
+ return AutoConfigServer._handle_tr069_message(ctx, dummy)
+
+ # CPE->ACS responses to ACS->CPE RPC calls
+
+ @rpc(
+ models.SetParameterValuesResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="SetParameterValuesResponse",
+ )
+ def set_parameter_values_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.GetParameterValuesResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="GetParameterValuesResponse",
+ )
+ def get_parameter_values_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.GetParameterNamesResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="GetParameterNamesResponse",
+ )
+ def get_parameter_names_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.SetParameterAttributesResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="SetParameterAttributesResponse",
+ )
+ def set_parameter_attributes_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.GetParameterAttributesResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="GetParameterAttributesResponse",
+ )
+ def get_parameter_attributes_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.AddObjectResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="AddObjectResponse",
+ )
+ def add_object_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.DeleteObjectResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="DeleteObjectResponse",
+ )
+ def delete_object_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.DownloadResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="DownloadResponse",
+ )
+ def download_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.RebootResponse,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="RebootResponse",
+ )
+ def reboot_response(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+ @rpc(
+ models.Fault,
+ _returns=models.AcsToCpeRequests,
+ _out_message_name="MessageNameToBeReplaced",
+ _body_style='bare',
+ _operation_name="Fault",
+ )
+ def fault(ctx, response):
+ return AutoConfigServer._handle_tr069_message(ctx, response)
+
+
+def on_method_return_string(ctx):
+ """
+ By default, spyne adds a namespace to every single XML element.
+ There isn't a way to change this behavior, and the spyne-recommended way
+ to fix this is by doing string manipulation. The TR-069 spec mandates that
+ only the top-level CWMP elements contain namespaces. Hence this
+ function is to remove namespaces from all elements except top-level CWMP
+ elements (e.g. RPC request/response names, header elements).
+ """
+ # Format strings for XML tags, corresponding to:
+ # 1) Normal start or end tag (without attribute)
+ # 2) Open and close tag (when no attributes or sub-structures exist)
+ # 3) Tag containing attributes
+ # We don't just look for 'cwmp:%s' (with no character after %s) because this
+ # would pick up all tags that start with the tag of interest (e.g
+ # cwmp:SetParameterAttributes would also match
+ # cwmp:SetParameterAttributesStruct)
+ XML_FORMAT_STRS = [
+ ["cwmp:%s>", "!!!TEMP_MOD!!!:%s>"],
+ ["cwmp:%s/>", "!!!TEMP_MOD!!!:%s/>"],
+ ["cwmp:%s ", "!!!TEMP_MOD!!!:%s "],
+ ]
+ fields_to_preserve_ns = list(RPC_METHODS) + list(RPC_RESPONSES) + \
+ list(CPE_RPC_METHODS) + list(CPE_RPC_RESPONSES) + \
+ list(PSEUDO_RPC_METHODS) + list(TOP_LEVEL_HEADER_ELEMENTS)
+ for field in fields_to_preserve_ns:
+ for formats in XML_FORMAT_STRS:
+ orig_str = formats[0] % field
+ temp_str = formats[1] % field
+ ctx.out_string[0] = ctx.out_string[0].replace(
+ orig_str.encode('ascii'), temp_str.encode('ascii'),
+ )
+
+ # Also preserve namespace inside strings, e.g. for arrayType="cwmp:..."
+ orig_str = "=\"cwmp:"
+ temp_str = "=\"!!!TEMP_MOD!!!:"
+ ctx.out_string[0] = ctx.out_string[0].replace(
+ orig_str.encode('ascii'), temp_str.encode('ascii'),
+ )
+ orig_str = "=\'cwmp:"
+ temp_str = "=\'!!!TEMP_MOD!!!:"
+ ctx.out_string[0] = ctx.out_string[0].replace(
+ orig_str.encode('ascii'), temp_str.encode('ascii'),
+ )
+
+ ctx.out_string[0] = ctx.out_string[0].replace(b'cwmp:', b'')
+ ctx.out_string[0] = ctx.out_string[0].replace(b'!!!TEMP_MOD!!!:', b'cwmp:')
+
+ # Special-case handling so that 'EmptyHttp' RPC will be called using
+ # completely empty HTTP request (not even containing a SOAP envelope), as
+ # per TR-069 spec.
+ if(ctx.descriptor.out_message.Attributes.sub_name == 'EmptyHttp'):
+ ctx.out_string = [b'']
+
+
+AutoConfigServer.event_manager.add_listener(
+ 'method_return_string',
+ on_method_return_string,
+)
diff --git a/tr069/server.py b/tr069/server.py
new file mode 100644
index 0000000..ecda15b
--- /dev/null
+++ b/tr069/server.py
@@ -0,0 +1,155 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+import _thread
+import socket
+from wsgiref.simple_server import (
+ ServerHandler,
+ WSGIRequestHandler,
+ WSGIServer,
+ make_server,
+)
+
+from common.misc_utils import get_ip_from_if
+from configuration.service_configs import load_service_config
+from logger import EnodebdLogger as logger
+from state_machines.enb_acs_manager import StateMachineManager
+from spyne.server.wsgi import WsgiApplication
+
+from .models import CWMP_NS
+from .rpc_methods import AutoConfigServer
+from .spyne_mods import Tr069Application, Tr069Soap11
+
+# Socket timeout in seconds. Should be set larger than the longest TR-069
+# response time (typically for a GetParameterValues of the entire data model),
+# measured at 168secs. Should also be set smaller than ENB_CONNECTION_TIMEOUT,
+# to avoid incorrectly detecting eNodeB timeout.
+SOCKET_TIMEOUT = 240
+
+
+class tr069_WSGIRequestHandler(WSGIRequestHandler):
+ timeout = 10
+ # pylint: disable=attribute-defined-outside-init
+
+ def handle_single(self):
+ """Handle a single HTTP request"""
+ self.raw_requestline = self.rfile.readline(65537)
+ if len(self.raw_requestline) > 65536:
+ self.requestline = ''
+ self.request_version = ''
+ self.command = ''
+ self.close_connection = 1
+ self.send_error(414)
+ return
+
+ if not self.parse_request(): # An error code has been sent, just exit
+ return
+
+ handler = ServerHandler(
+ self.rfile, self.wfile, self.get_stderr(), self.get_environ(),
+ )
+ handler.http_version = "1.1"
+ handler.request_handler = self # backpointer for logging
+
+ # eNodeB will sometimes close connection to enodebd.
+ # The cause of this is unknown, but we can safely ignore the
+ # closed connection, and continue as normal otherwise.
+ #
+ # While this throws a BrokenPipe exception in wsgi server,
+ # it also causes an AttributeError to be raised because of a
+ # bug in the wsgi server.
+ # https://bugs.python.org/issue27682
+ try:
+ handler.run(self.server.get_app())
+ except BrokenPipeError:
+ self.log_error("eNodeB has unexpectedly closed the TCP connection.")
+
+ def handle(self):
+ self.protocol_version = "HTTP/1.1"
+ self.close_connection = 0
+
+ try:
+ while not self.close_connection:
+ self.handle_single()
+
+ except socket.timeout as e:
+ self.log_error("tr069 WSGI Server Socket Timeout: %r", e)
+ self.close_connection = 1
+ return
+
+ except socket.error as e:
+ self.log_error("tr069 WSGI Server Socket Error: %r", e)
+ self.close_connection = 1
+ return
+
+ # Disable pylint warning because we are using same parameter name as built-in
+ # pylint: disable=redefined-builtin
+ def log_message(self, format, *args):
+ """ Overwrite message logging to use python logging framework rather
+ than stderr """
+ logger.debug("%s - %s", self.client_address[0], format % args)
+
+ # Disable pylint warning because we are using same parameter name as built-in
+ # pylint: disable=redefined-builtin
+ def log_error(self, format, *args):
+ """ Overwrite message logging to use python logging framework rather
+ than stderr """
+ logger.warning("%s - %s", self.client_address[0], format % args)
+
+
+def tr069_server(state_machine_manager: StateMachineManager) -> None:
+ """
+ TR-069 server
+ Inputs:
+ - acs_to_cpe_queue = instance of Queue
+ containing messages from parent process/thread to be sent to CPE
+ - cpe_to_acs_queue = instance of Queue
+ containing messages from CPE to be sent to parent process/thread
+ """
+ config = load_service_config("enodebd")
+
+ AutoConfigServer.set_state_machine_manager(state_machine_manager)
+
+ app = Tr069Application(
+ [AutoConfigServer], CWMP_NS,
+ in_protocol=Tr069Soap11(validator='soft'),
+ out_protocol=Tr069Soap11(),
+ )
+ wsgi_app = WsgiApplication(app)
+
+ try:
+ ip_address = get_ip_from_if(config['tr069']['interface'])
+ except (ValueError, KeyError) as e:
+ # Interrupt main thread since process should not continue without TR-069
+ _thread.interrupt_main()
+ raise e
+
+ socket.setdefaulttimeout(SOCKET_TIMEOUT)
+ logger.info(
+ 'Starting TR-069 server on %s:%s',
+ ip_address, config['tr069']['port'],
+ )
+ server = make_server(
+ ip_address,
+ config['tr069']['port'], wsgi_app,
+ WSGIServer, tr069_WSGIRequestHandler,
+ )
+
+ # Note: use single-thread server, to avoid state contention
+ try:
+ server.serve_forever()
+ finally:
+ # Log error and interrupt main thread, to ensure that entire process
+ # is restarted if this thread exits
+ logger.error('Hit error in TR-069 thread. Interrupting main thread.')
+ _thread.interrupt_main()
diff --git a/tr069/spyne_mods.py b/tr069/spyne_mods.py
new file mode 100644
index 0000000..0f0e918
--- /dev/null
+++ b/tr069/spyne_mods.py
@@ -0,0 +1,155 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+This file contains modifications of the core spyne functionality. This is done
+using child classes and function override to avoid modifying spyne code itself.
+Each function below is a modified version of the parent function. These
+modifications are required because:
+1) Spyne is not fully python3-compliant
+2) Not all parts of the TR-069 spec are possible through spyne APIs (e.g RPC
+ calls from server to client in HTTP responses)
+3) Minor enhancements for debug-ability
+"""
+
+from lxml import etree
+from logger import EnodebdLogger as logger
+from spyne.application import Application
+from spyne.interface._base import Interface
+from spyne.protocol.soap import Soap11
+from spyne.protocol.xml import XmlDocument
+
+
+class Tr069Interface(Interface):
+ """ Modified base interface class. """
+
+ def reset_interface(self):
+ super(Tr069Interface, self).reset_interface()
+ # Replace default namespace prefix (may not strictly be
+ # required, but makes it easier to debug)
+ del self.nsmap['tns']
+ self.nsmap['cwmp'] = self.get_tns()
+ self.prefmap[self.get_tns()] = 'cwmp'
+ # To validate against the xsd:<types>, the namespace
+ # prefix is expected to be the same
+ del self.nsmap['xs']
+ self.nsmap['xsd'] = 'http://www.w3.org/2001/XMLSchema'
+ self.prefmap['http://www.w3.org/2001/XMLSchema'] = 'xsd'
+
+
+class Tr069Application(Application):
+ """ Modified spyne application. """
+
+ def __init__(
+ self, services, tns, name=None, in_protocol=None,
+ out_protocol=None, config=None,
+ ):
+ super(Tr069Application, self).__init__(
+ services, tns, name, in_protocol, out_protocol, config,
+ )
+ # Use modified interface class
+ self.interface = Tr069Interface(self)
+
+
+class Tr069Soap11(Soap11):
+ """ Modified SOAP protocol. """
+
+ def __init__(self, *args, **kwargs):
+ super(Tr069Soap11, self).__init__(*args, **kwargs)
+ # Disabling type resolution as a workaround for
+ # https://github.com/arskom/spyne/issues/567
+ self.parse_xsi_type = False
+ # Bug in spyne is cleaning up the default XSD namespace
+ # and causes validation issues on TR-069 clients
+ self.cleanup_namespaces = False
+
+ def create_in_document(self, ctx, charset=None):
+ """
+ In TR-069, the ACS (e.g Magma) is an HTTP server, but acts as a client
+ for SOAP messages. This is done by the CPE (e.g ENodeB) sending an
+ empty HTTP request, and the ACS responding with a SOAP request in the
+ HTTP response. This code replaces an empty HTTP request with a string
+ that gets decoded to a call to the 'EmptyHttp' RPC .
+ """
+
+ # Try cp437 as default to ensure that we dont get any decoding errors,
+ # since it uses 1-byte encoding and has a 'full' char map
+ if not charset:
+ charset = 'cp437'
+
+ # Convert from generator to bytes before doing comparison
+ # Re-encode to chosen charset to remove invalid characters
+ in_string = b''.join(ctx.in_string).decode(charset, 'ignore')
+ ctx.in_string = [in_string.encode(charset, 'ignore')]
+ if ctx.in_string == [b'']:
+ ctx.in_string = [
+ b'<soap11env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0" xmlns:soap11env="http://schemas.xmlsoap.org/soap/envelope/">/n'
+ b' <soap11env:Body>/n'
+ b' <cwmp:EmptyHttp/>/n'
+ b' </soap11env:Body>/n'
+ b'</soap11env:Envelope>',
+ ]
+
+ super(Tr069Soap11, self).create_in_document(ctx, charset)
+
+ def decompose_incoming_envelope(self, ctx, message=XmlDocument.REQUEST):
+ """
+ For TR-069, the SOAP fault message (CPE->ACS) contains useful
+ information, and should not result in another fault response (ACS->CPE).
+ Strip the outer SOAP fault structure, so that the CWMP fault structure
+ is treated as a normal RPC call (to the 'Fault' function).
+ """
+ super(Tr069Soap11, self).decompose_incoming_envelope(ctx, message)
+
+ if ctx.in_body_doc.tag == '{%s}Fault' % self.ns_soap_env:
+ faultstring = ctx.in_body_doc.findtext('faultstring')
+ if not faultstring or 'CWMP fault' not in faultstring:
+ # Not a CWMP fault
+ return
+
+ # Strip SOAP fault structure, leaving inner CWMP fault structure
+ detail_elem = ctx.in_body_doc.find('detail')
+ if detail_elem is not None:
+ detail_children = list(detail_elem)
+ if len(detail_children):
+ if len(detail_children) > 1:
+ logger.warning(
+ "Multiple detail elements found in SOAP"
+ " fault - using first one",
+ )
+ ctx.in_body_doc = detail_children[0]
+ ctx.method_request_string = ctx.in_body_doc.tag
+ self.validate_body(ctx, message)
+
+ def get_call_handles(self, ctx):
+ """
+ Modified function to fix bug in receiving SOAP fault. In this case,
+ ctx.method_request_string is None, so 'startswith' errors out.
+ """
+ if ctx.method_request_string is None:
+ return []
+
+ return super(Tr069Soap11, self).get_call_handles(ctx)
+
+ def serialize(self, ctx, message):
+ # Workaround for issue https://github.com/magma/magma/issues/7869
+ # Updates to ctx.descriptor.out_message.Attributes.sub_name are taking
+ # effect on the descriptor. But when puled from _attrcache dictionary,
+ # it still has a stale value.
+ # Force repopulation of dictionary by deleting entry
+ # TODO Remove this code once we have a better fix
+ if (ctx.descriptor and ctx.descriptor.out_message in self._attrcache):
+ del self._attrcache[ctx.descriptor.out_message] # noqa: WPS529
+
+ super(Tr069Soap11, self).serialize(ctx, message)
+
+ # Keep XSD namespace
+ etree.cleanup_namespaces(ctx.out_document, keep_ns_prefixes=['xsd'])
diff --git a/tr069/tests/models_tests.py b/tr069/tests/models_tests.py
new file mode 100644
index 0000000..faa76ff
--- /dev/null
+++ b/tr069/tests/models_tests.py
@@ -0,0 +1,74 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+import unittest
+
+from tr069.models import DeviceIdStruct
+from spyne import ComplexModelBase
+
+
+class DeviceIdStructTests(unittest.TestCase):
+ def test_as_dict_memory_leak(self):
+ """
+ Test to ensure as_dict() doesn't leak model instances
+ """
+ thing = DeviceIdStruct(
+ Manufacturer='abc',
+ OUI='def',
+ ProductClass='ghi',
+ SerialNumber='jkl',
+ )
+ res = thing.as_dict()
+ self.assertEqual(
+ {
+ 'Manufacturer': 'abc',
+ 'OUI': 'def',
+ 'ProductClass': 'ghi',
+ 'SerialNumber': 'jkl',
+ },
+ res,
+ )
+ # inspect the spyne.util.memoize object that wraps the staticmethod
+ self.assertEqual(1, len(ComplexModelBase.get_flat_type_info.memo))
+
+ # should produce a different result and not grow the size of memo
+ thing.OUI = 'aaaa'
+ res = thing.as_dict()
+ self.assertEqual(
+ {
+ 'Manufacturer': 'abc',
+ 'OUI': 'aaaa',
+ 'ProductClass': 'ghi',
+ 'SerialNumber': 'jkl',
+ },
+ res,
+ )
+ self.assertEqual(1, len(ComplexModelBase.get_flat_type_info.memo))
+
+ # use a different object this time. Again should not grow memo
+ thing = DeviceIdStruct(
+ Manufacturer='abc',
+ OUI='def',
+ ProductClass='ghi',
+ SerialNumber='jkl',
+ )
+ res = thing.as_dict()
+ self.assertEqual(
+ {
+ 'Manufacturer': 'abc',
+ 'OUI': 'def',
+ 'ProductClass': 'ghi',
+ 'SerialNumber': 'jkl',
+ },
+ res,
+ )
+ self.assertEqual(1, len(ComplexModelBase.get_flat_type_info.memo))