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))