Repository: softlayer/softlayer-python
Pull request #824 (2017-05-18 18:11:53)

IPsec API Support and CLI Interaction
Adding IPSEC manager and CLI commands to interact with SoftLayer's IPSEC VPN service. Support is added to list, detail, update and configure tunnel contexts, to add and remove subnet associations and to add, remove and update address translation entries.

Unit testing coverage of 100% has been achieved on all but 1 file 'SoftLayer/CLI/vpn/ipsec/translation/update.py', which is at 94%. Overall project coverage is reported as 83.67%.

The following CLI commands have been added:
- ipsec list
- ipsec detail
- ipsec configure
- ipsec update
- ipsec subnet-add
- ipsec subnet-remove
- ipsec translation-add
- ipsec translation-remove
- ipsec translation-update


Patch:
diff --git a/SoftLayer/CLI/custom_types.py b/SoftLayer/CLI/custom_types.py
new file mode 100644
index 00000000..66167b68
--- /dev/null
+++ b/SoftLayer/CLI/custom_types.py
@@ -0,0 +1,32 @@
+"""
+    SoftLayer.CLI.custom_types
+    ~~~~~~~~~~~~~~~~~~~~~~~~
+    Custom type declarations extending click.ParamType
+
+    :license: MIT, see LICENSE for more details.
+"""
+
+import click
+
+
+class NetworkParamType(click.ParamType):
+    """Validates a network parameter type and converts to a tuple.
+
+    todo: Implement to ipaddress.ip_network once the ipaddress backport
+          module can be added as a dependency or is available on all
+          supported python versions.
+    """
+    name = 'network'
+
+    def convert(self, value, param, ctx):
+        try:
+            # Inlined from python standard ipaddress module
+            # https://docs.python.org/3/library/ipaddress.html
+            address = str(value).split('/')
+            if len(address) != 2:
+                raise ValueError("Only one '/' permitted in %r" % value)
+
+            ip_address, cidr = address
+            return (ip_address, int(cidr))
+        except ValueError:
+            self.fail('{} is not a valid network'.format(value), param, ctx)
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index 10f2a7da..3c4555db 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -123,6 +123,19 @@
     ('image:import', 'SoftLayer.CLI.image.import:cli'),
     ('image:export', 'SoftLayer.CLI.image.export:cli'),
 
+    ('ipsec', 'SoftLayer.CLI.vpn.ipsec'),
+    ('ipsec:configure', 'SoftLayer.CLI.vpn.ipsec.configure:cli'),
+    ('ipsec:detail', 'SoftLayer.CLI.vpn.ipsec.detail:cli'),
+    ('ipsec:list', 'SoftLayer.CLI.vpn.ipsec.list:cli'),
+    ('ipsec:subnet-add', 'SoftLayer.CLI.vpn.ipsec.subnet.add:cli'),
+    ('ipsec:subnet-remove', 'SoftLayer.CLI.vpn.ipsec.subnet.remove:cli'),
+    ('ipsec:translation-add', 'SoftLayer.CLI.vpn.ipsec.translation.add:cli'),
+    ('ipsec:translation-remove',
+     'SoftLayer.CLI.vpn.ipsec.translation.remove:cli'),
+    ('ipsec:translation-update',
+     'SoftLayer.CLI.vpn.ipsec.translation.update:cli'),
+    ('ipsec:update', 'SoftLayer.CLI.vpn.ipsec.update:cli'),
+
     ('loadbal', 'SoftLayer.CLI.loadbal'),
     ('loadbal:cancel', 'SoftLayer.CLI.loadbal.cancel:cli'),
     ('loadbal:create', 'SoftLayer.CLI.loadbal.create:cli'),
diff --git a/SoftLayer/CLI/vpn/__init__.py b/SoftLayer/CLI/vpn/__init__.py
new file mode 100644
index 00000000..a61d5119
--- /dev/null
+++ b/SoftLayer/CLI/vpn/__init__.py
@@ -0,0 +1,1 @@
+"""Virtual Private Networks"""
diff --git a/SoftLayer/CLI/vpn/ipsec/__init__.py b/SoftLayer/CLI/vpn/ipsec/__init__.py
new file mode 100644
index 00000000..72e48782
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/__init__.py
@@ -0,0 +1,1 @@
+"""IPSEC VPN"""
diff --git a/SoftLayer/CLI/vpn/ipsec/configure.py b/SoftLayer/CLI/vpn/ipsec/configure.py
new file mode 100644
index 00000000..1bda1347
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/configure.py
@@ -0,0 +1,31 @@
+"""Request network configuration of an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@environment.pass_env
+def cli(env, context_id):
+    """Request configuration of a tunnel context.
+
+    This action will update the advancedConfigurationFlag on the context
+    instance and further modifications against the context will be prevented
+    until all changes can be propgated to network devices.
+    """
+    manager = SoftLayer.IPSECManager(env.client)
+    # ensure context can be retrieved by given id
+    manager.get_tunnel_context(context_id)
+
+    succeeded = manager.apply_configuration(context_id)
+    if succeeded:
+        env.out('Configuration request received for context #{}'
+                .format(context_id))
+    else:
+        raise CLIHalt('Failed to enqueue configuration request for context #{}'
+                      .format(context_id))
diff --git a/SoftLayer/CLI/vpn/ipsec/detail.py b/SoftLayer/CLI/vpn/ipsec/detail.py
new file mode 100644
index 00000000..57586fec
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/detail.py
@@ -0,0 +1,196 @@
+"""List IPSEC VPN Tunnel Context Details."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@click.option('-i',
+              '--include',
+              default=[],
+              multiple=True,
+              type=click.Choice(['at', 'is', 'rs', 'sr', 'ss']),
+              help='Include additional resources')
+@environment.pass_env
+def cli(env, context_id, include):
+    """List IPSEC VPN tunnel context details.
+
+    Additional resources can be joined using multiple instances of the
+    include option, for which the following choices are available.
+
+    \b
+    at: address translations
+    is: internal subnets
+    rs: remote subnets
+    sr: statically routed subnets
+    ss: service subnets
+    """
+    mask = _get_tunnel_context_mask(('at' in include),
+                                    ('is' in include),
+                                    ('rs' in include),
+                                    ('sr' in include),
+                                    ('ss' in include))
+    manager = SoftLayer.IPSECManager(env.client)
+    context = manager.get_tunnel_context(context_id, mask=mask)
+
+    env.out('Context Details:')
+    env.fout(_get_context_table(context))
+
+    for relation in include:
+        if relation == 'at':
+            env.out('Address Translations:')
+            env.fout(_get_address_translations_table(
+                context.get('addressTranslations', [])))
+        elif relation == 'is':
+            env.out('Internal Subnets:')
+            env.fout(_get_subnets_table(context.get('internalSubnets', [])))
+        elif relation == 'rs':
+            env.out('Remote Subnets:')
+            env.fout(_get_subnets_table(context.get('customerSubnets', [])))
+        elif relation == 'sr':
+            env.out('Static Subnets:')
+            env.fout(_get_subnets_table(context.get('staticRouteSubnets', [])))
+        elif relation == 'ss':
+            env.out('Service Subnets:')
+            env.fout(_get_subnets_table(context.get('serviceSubnets', [])))
+
+
+def _get_address_translations_table(address_translations):
+    """Yields a formatted table to print address translations.
+
+    :param List[dict] address_translations: List of address translations.
+    :return Table: Formatted for address translation output.
+    """
+    table = formatting.Table(['id',
+                              'static IP address',
+                              'static IP address id',
+                              'remote IP address',
+                              'remote IP address id',
+                              'note'])
+    for address_translation in address_translations:
+        table.add_row([address_translation.get('id', ''),
+                       address_translation.get('internalIpAddressRecord', {})
+                       .get('ipAddress', ''),
+                       address_translation.get('internalIpAddressId', ''),
+                       address_translation.get('customerIpAddressRecord', {})
+                       .get('ipAddress', ''),
+                       address_translation.get('customerIpAddressId', ''),
+                       address_translation.get('notes', '')])
+    return table
+
+
+def _get_subnets_table(subnets):
+    """Yields a formatted table to print subnet details.
+
+    :param List[dict] subnets: List of subnets.
+    :return Table: Formatted for subnet output.
+    """
+    table = formatting.Table(['id',
+                              'network identifier',
+                              'cidr',
+                              'note'])
+    for subnet in subnets:
+        table.add_row([subnet.get('id', ''),
+                       subnet.get('networkIdentifier', ''),
+                       subnet.get('cidr', ''),
+                       subnet.get('note', '')])
+    return table
+
+
+def _get_tunnel_context_mask(address_translations=False,
+                             internal_subnets=False,
+                             remote_subnets=False,
+                             static_subnets=False,
+                             service_subnets=False):
+    """Yields a mask object for a tunnel context.
+
+    All exposed properties on the tunnel context service are included in
+    the constructed mask. Additional joins may be requested.
+
+    :param bool address_translations: Whether to join the context's address
+           translation entries.
+    :param bool internal_subnets: Whether to join the context's internal
+           subnet associations.
+    :param bool remote_subnets: Whether to join the context's remote subnet
+           associations.
+    :param bool static_subnets: Whether to join the context's statically
+           routed subnet associations.
+    :param bool service_subnets: Whether to join the SoftLayer service
+           network subnets.
+    :return string: Encoding for the requested mask object.
+    """
+    entries = ['id',
+               'accountId',
+               'advancedConfigurationFlag',
+               'createDate',
+               'customerPeerIpAddress',
+               'modifyDate',
+               'name',
+               'friendlyName',
+               'internalPeerIpAddress',
+               'phaseOneAuthentication',
+               'phaseOneDiffieHellmanGroup',
+               'phaseOneEncryption',
+               'phaseOneKeylife',
+               'phaseTwoAuthentication',
+               'phaseTwoDiffieHellmanGroup',
+               'phaseTwoEncryption',
+               'phaseTwoKeylife',
+               'phaseTwoPerfectForwardSecrecy',
+               'presharedKey']
+    if address_translations:
+        entries.append('addressTranslations[internalIpAddressRecord[ipAddress],'
+                       'customerIpAddressRecord[ipAddress]]')
+    if internal_subnets:
+        entries.append('internalSubnets')
+    if remote_subnets:
+        entries.append('customerSubnets')
+    if static_subnets:
+        entries.append('staticRouteSubnets')
+    if service_subnets:
+        entries.append('serviceSubnets')
+    return '[mask[{}]]'.format(','.join(entries))
+
+
+def _get_context_table(context):
+    """Yields a formatted table to print context details.
+
+    :param dict context: The tunnel context
+    :return Table: Formatted for tunnel context output
+    """
+    table = formatting.KeyValueTable(['name', 'value'])
+    table.align['name'] = 'r'
+    table.align['value'] = 'l'
+
+    table.add_row(['id', context.get('id', '')])
+    table.add_row(['name', context.get('name', '')])
+    table.add_row(['friendly name', context.get('friendlyName', '')])
+    table.add_row(['internal peer IP address',
+                   context.get('internalPeerIpAddress', '')])
+    table.add_row(['remote peer IP address',
+                   context.get('customerPeerIpAddress', '')])
+    table.add_row(['advanced configuration flag',
+                   context.get('advancedConfigurationFlag', '')])
+    table.add_row(['preshared key', context.get('presharedKey', '')])
+    table.add_row(['phase 1 authentication',
+                   context.get('phaseOneAuthentication', '')])
+    table.add_row(['phase 1 diffie hellman group',
+                   context.get('phaseOneDiffieHellmanGroup', '')])
+    table.add_row(['phase 1 encryption', context.get('phaseOneEncryption', '')])
+    table.add_row(['phase 1 key life', context.get('phaseOneKeylife', '')])
+    table.add_row(['phase 2 authentication',
+                   context.get('phaseTwoAuthentication', '')])
+    table.add_row(['phase 2 diffie hellman group',
+                   context.get('phaseTwoDiffieHellmanGroup', '')])
+    table.add_row(['phase 2 encryption', context.get('phaseTwoEncryption', '')])
+    table.add_row(['phase 2 key life', context.get('phaseTwoKeylife', '')])
+    table.add_row(['phase 2 perfect forward secrecy',
+                   context.get('phaseTwoPerfectForwardSecrecy', '')])
+    table.add_row(['created', context.get('createDate')])
+    table.add_row(['modified', context.get('modifyDate')])
+    return table
diff --git a/SoftLayer/CLI/vpn/ipsec/list.py b/SoftLayer/CLI/vpn/ipsec/list.py
new file mode 100644
index 00000000..ef3f7d77
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/list.py
@@ -0,0 +1,31 @@
+"""List IPSec VPN Tunnel Contexts."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+@click.command()
+@environment.pass_env
+def cli(env):
+    """List IPSec VPN tunnel contexts"""
+    manager = SoftLayer.IPSECManager(env.client)
+    contexts = manager.get_tunnel_contexts()
+
+    table = formatting.Table(['id',
+                              'name',
+                              'friendly name',
+                              'internal peer IP address',
+                              'remote peer IP address',
+                              'created'])
+    for context in contexts:
+        table.add_row([context.get('id', ''),
+                       context.get('name', ''),
+                       context.get('friendlyName', ''),
+                       context.get('internalPeerIpAddress', ''),
+                       context.get('customerPeerIpAddress', ''),
+                       context.get('createDate', '')])
+    env.fout(table)
diff --git a/SoftLayer/CLI/vpn/ipsec/subnet/__init__.py b/SoftLayer/CLI/vpn/ipsec/subnet/__init__.py
new file mode 100644
index 00000000..5ec029c5
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/subnet/__init__.py
@@ -0,0 +1,1 @@
+"""IPSEC VPN Subnets"""
diff --git a/SoftLayer/CLI/vpn/ipsec/subnet/add.py b/SoftLayer/CLI/vpn/ipsec/subnet/add.py
new file mode 100644
index 00000000..438dfc5f
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/subnet/add.py
@@ -0,0 +1,81 @@
+"""Add a subnet to an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI.custom_types import NetworkParamType
+from SoftLayer.CLI import environment
+from SoftLayer.CLI.exceptions import ArgumentError
+from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@click.option('-s',
+              '--subnet-id',
+              default=None,
+              type=int,
+              help='Subnet identifier to add')
+@click.option('-t',
+              '--type',
+              '--subnet-type',
+              required=True,
+              type=click.Choice(['internal', 'remote', 'service']),
+              help='Subnet type to add')
+@click.option('-n',
+              '--network',
+              '--network-identifier',
+              default=None,
+              type=NetworkParamType(),
+              help='Subnet network identifier to create')
+@environment.pass_env
+def cli(env, context_id, subnet_id, subnet_type, network_identifier):
+    """Add a subnet to an IPSEC tunnel context.
+
+    A subnet id may be specified to link to the existing tunnel context.
+
+    Otherwise, a network identifier in CIDR notation should be specified,
+    indicating that a subnet resource should first be created before associating
+    it with the tunnel context. Note that this is only supported for remote
+    subnets, which are also deleted upon failure to attach to a context.
+
+    A separate configuration request should be made to realize changes on
+    network devices.
+    """
+    create_remote = False
+    if subnet_id is None:
+        if network_identifier is None:
+            raise ArgumentError('Either a network identifier or subnet id '
+                                'must be provided.')
+        if subnet_type != 'remote':
+            raise ArgumentError('Unable to create {} subnets'
+                                .format(subnet_type))
+        create_remote = True
+
+    manager = SoftLayer.IPSECManager(env.client)
+    context = manager.get_tunnel_context(context_id)
+
+    if create_remote:
+        subnet = manager.create_remote_subnet(context['accountId'],
+                                              identifier=network_identifier[0],
+                                              cidr=network_identifier[1])
+        subnet_id = subnet['id']
+        env.out('Created subnet {}/{} #{}'
+                .format(network_identifier[0],
+                        network_identifier[1],
+                        subnet_id))
+
+    succeeded = False
+    if subnet_type == 'internal':
+        succeeded = manager.add_internal_subnet(context_id, subnet_id)
+    elif subnet_type == 'remote':
+        succeeded = manager.add_remote_subnet(context_id, subnet_id)
+    elif subnet_type == 'service':
+        succeeded = manager.add_service_subnet(context_id, subnet_id)
+
+    if succeeded:
+        env.out('Added {} subnet #{}'.format(subnet_type, subnet_id))
+    else:
+        raise CLIHalt('Failed to add {} subnet #{}'
+                      .format(subnet_type, subnet_id))
diff --git a/SoftLayer/CLI/vpn/ipsec/subnet/remove.py b/SoftLayer/CLI/vpn/ipsec/subnet/remove.py
new file mode 100644
index 00000000..2d8b34d9
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/subnet/remove.py
@@ -0,0 +1,51 @@
+"""Remove a subnet from an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@click.option('-s',
+              '--subnet-id',
+              required=True,
+              type=int,
+              help='Subnet identifier to remove')
+@click.option('-t',
+              '--type',
+              '--subnet-type',
+              required=True,
+              type=click.Choice(['internal', 'remote', 'service']),
+              help='Subnet type to add')
+@environment.pass_env
+def cli(env, context_id, subnet_id, subnet_type):
+    """Remove a subnet from an IPSEC tunnel context.
+
+    The subnet id to remove must be specified.
+
+    Remote subnets are deleted upon removal from a tunnel context.
+
+    A separate configuration request should be made to realize changes on
+    network devices.
+    """
+    manager = SoftLayer.IPSECManager(env.client)
+    # ensure context can be retrieved by given id
+    manager.get_tunnel_context(context_id)
+
+    succeeded = False
+    if subnet_type == 'internal':
+        succeeded = manager.remove_internal_subnet(context_id, subnet_id)
+    elif subnet_type == 'remote':
+        succeeded = manager.remove_remote_subnet(context_id, subnet_id)
+    elif subnet_type == 'service':
+        succeeded = manager.remove_service_subnet(context_id, subnet_id)
+
+    if succeeded:
+        env.out('Removed {} subnet #{}'.format(subnet_type, subnet_id))
+    else:
+        raise CLIHalt('Failed to remove {} subnet #{}'
+                      .format(subnet_type, subnet_id))
diff --git a/SoftLayer/CLI/vpn/ipsec/translation/__init__.py b/SoftLayer/CLI/vpn/ipsec/translation/__init__.py
new file mode 100644
index 00000000..18be2522
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/translation/__init__.py
@@ -0,0 +1,1 @@
+"""IPSEC VPN Address Translations"""
diff --git a/SoftLayer/CLI/vpn/ipsec/translation/add.py b/SoftLayer/CLI/vpn/ipsec/translation/add.py
new file mode 100644
index 00000000..a0b7a35e
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/translation/add.py
@@ -0,0 +1,44 @@
+"""Add an address translation to an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+# from SoftLayer.CLI.exceptions import ArgumentError
+# from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+# todo: Update to utilize custom IP address type
+@click.option('-s',
+              '--static-ip',
+              required=True,
+              help='Static IP address value')
+# todo: Update to utilize custom IP address type
+@click.option('-r',
+              '--remote-ip',
+              required=True,
+              help='Remote IP address value')
+@click.option('-n',
+              '--note',
+              default=None,
+              help='Note value')
+@environment.pass_env
+def cli(env, context_id, static_ip, remote_ip, note):
+    """Add an address translation to an IPSEC tunnel context.
+
+    A separate configuration request should be made to realize changes on
+    network devices.
+    """
+    manager = SoftLayer.IPSECManager(env.client)
+    # ensure context can be retrieved by given id
+    manager.get_tunnel_context(context_id)
+
+    translation = manager.create_translation(context_id,
+                                             static_ip=static_ip,
+                                             remote_ip=remote_ip,
+                                             notes=note)
+    env.out('Created translation from {} to {} #{}'
+            .format(static_ip, remote_ip, translation['id']))
diff --git a/SoftLayer/CLI/vpn/ipsec/translation/remove.py b/SoftLayer/CLI/vpn/ipsec/translation/remove.py
new file mode 100644
index 00000000..a6c523a3
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/translation/remove.py
@@ -0,0 +1,33 @@
+"""Remove a translation entry from an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@click.option('-t',
+              '--translation-id',
+              required=True,
+              type=int,
+              help='Translation identifier to remove')
+@environment.pass_env
+def cli(env, context_id, translation_id):
+    """Remove a translation entry from an IPSEC tunnel context.
+
+    A separate configuration request should be made to realize changes on
+    network devices.
+    """
+    manager = SoftLayer.IPSECManager(env.client)
+    # ensure translation can be retrieved by given id
+    manager.get_translation(context_id, translation_id)
+
+    succeeded = manager.remove_translation(context_id, translation_id)
+    if succeeded:
+        env.out('Removed translation #{}'.format(translation_id))
+    else:
+        raise CLIHalt('Failed to remove translation #{}'.format(translation_id))
diff --git a/SoftLayer/CLI/vpn/ipsec/translation/update.py b/SoftLayer/CLI/vpn/ipsec/translation/update.py
new file mode 100644
index 00000000..b78585db
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/translation/update.py
@@ -0,0 +1,48 @@
+"""Update an address translation for an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@click.option('-t',
+              '--translation-id',
+              required=True,
+              type=int,
+              help='Translation identifier to update')
+# todo: Update to utilize custom IP address type
+@click.option('-s',
+              '--static-ip',
+              default=None,
+              help='Static IP address value')
+# todo: Update to utilize custom IP address type
+@click.option('-r',
+              '--remote-ip',
+              default=None,
+              help='Remote IP address value')
+@click.option('-n',
+              '--note',
+              default=None,
+              help='Note value')
+@environment.pass_env
+def cli(env, context_id, translation_id, static_ip, remote_ip, note):
+    """Update an address translation for an IPSEC tunnel context.
+
+    A separate configuration request should be made to realize changes on
+    network devices.
+    """
+    manager = SoftLayer.IPSECManager(env.client)
+    succeeded = manager.update_translation(context_id,
+                                           translation_id,
+                                           static_ip=static_ip,
+                                           remote_ip=remote_ip,
+                                           notes=note)
+    if succeeded:
+        env.out('Updated translation #{}'.format(translation_id))
+    else:
+        raise CLIHalt('Failed to update translation #{}'.format(translation_id))
diff --git a/SoftLayer/CLI/vpn/ipsec/update.py b/SoftLayer/CLI/vpn/ipsec/update.py
new file mode 100644
index 00000000..68e09b0a
--- /dev/null
+++ b/SoftLayer/CLI/vpn/ipsec/update.py
@@ -0,0 +1,102 @@
+"""Updates an IPSEC tunnel context."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI.exceptions import CLIHalt
+
+
+@click.command()
+@click.argument('context_id', type=int)
+@click.option('--friendly-name',
+              default=None,
+              help='Friendly name value')
+# todo: Update to utilize custom IP address type
+@click.option('--remote-peer',
+              default=None,
+              help='Remote peer IP address value')
+@click.option('--preshared-key',
+              default=None,
+              help='Preshared key value')
+@click.option('--p1-auth',
+              '--phase1-auth',
+              default=None,
+              type=click.Choice(['MD5', 'SHA1', 'SHA256']),
+              help='Phase 1 authentication value')
+@click.option('--p1-crypto',
+              '--phase1-crypto',
+              default=None,
+              type=click.Choice(['DES', '3DES', 'AES128', 'AES192', 'AES256']),
+              help='Phase 1 encryption value')
+@click.option('--p1-dh',
+              '--phase1-dh',
+              default=None,
+              type=click.Choice(['0', '1', '2', '5']),
+              help='Phase 1 diffie hellman group value')
+@click.option('--p1-key-ttl',
+              '--phase1-key-ttl',
+              default=None,
+              type=click.IntRange(120, 172800),
+              help='Phase 1 key life value')
+@click.option('--p2-auth',
+              '--phase2-auth',
+              default=None,
+              type=click.Choice(['MD5', 'SHA1', 'SHA256']),
+              help='Phase 2 authentication value')
+@click.option('--p2-crypto',
+              '--phase2-crypto',
+              default=None,
+              type=click.Choice(['DES', '3DES', 'AES128', 'AES192', 'AES256']),
+              help='Phase 2 encryption value')
+@click.option('--p2-dh',
+              '--phase2-dh',
+              default=None,
+              type=click.Choice(['0', '1', '2', '5']),
+              help='Phase 2 diffie hellman group value')
+@click.option('--p2-forward-secrecy',
+              '--phase2-forward-secrecy',
+              default=None,
+              type=click.IntRange(0, 1),
+              help='Phase 2 perfect forward secrecy value')
+@click.option('--p2-key-ttl',
+              '--phase2-key-ttl',
+              default=None,
+              type=click.IntRange(120, 172800),
+              help='Phase 2 key life value')
+@environment.pass_env
+def cli(env, context_id, friendly_name, remote_peer, preshared_key,
+        phase1_auth, phase1_crypto, phase1_dh, phase1_key_ttl, phase2_auth,
+        phase2_crypto, phase2_dh, phase2_forward_secrecy, phase2_key_ttl):
+    """Update tunnel context properties.
+
+    Updates are made atomically, so either all are accepted or none are.
+
+    Key life values must be in the range 120-172800.
+
+    Phase 2 perfect forward secrecy must be in the range 0-1.
+
+    A separate configuration request should be made to realize changes on
+    network devices.
+    """
+    manager = SoftLayer.IPSECManager(env.client)
+    succeeded = manager.update_tunnel_context(
+        context_id,
+        friendly_name=friendly_name,
+        remote_peer=remote_peer,
+        preshared_key=preshared_key,
+        phase1_auth=phase1_auth,
+        phase1_crypto=phase1_crypto,
+        phase1_dh=phase1_dh,
+        phase1_key_ttl=phase1_key_ttl,
+        phase2_auth=phase2_auth,
+        phase2_crypto=phase2_crypto,
+        phase2_dh=phase2_dh,
+        phase2_forward_secrecy=phase2_forward_secrecy,
+        phase2_key_ttl=phase2_key_ttl
+    )
+    if succeeded:
+        env.out('Updated context #{}'.format(context_id))
+    else:
+        raise CLIHalt('Failed to update context #{}'.format(context_id))
diff --git a/SoftLayer/managers/__init__.py b/SoftLayer/managers/__init__.py
index 4b63b772..f404d7b9 100644
--- a/SoftLayer/managers/__init__.py
+++ b/SoftLayer/managers/__init__.py
@@ -14,6 +14,7 @@
 from SoftLayer.managers.firewall import FirewallManager
 from SoftLayer.managers.hardware import HardwareManager
 from SoftLayer.managers.image import ImageManager
+from SoftLayer.managers.ipsec import IPSECManager
 from SoftLayer.managers.load_balancer import LoadBalancerManager
 from SoftLayer.managers.messaging import MessagingManager
 from SoftLayer.managers.metadata import MetadataManager
@@ -33,6 +34,7 @@
     'FirewallManager',
     'HardwareManager',
     'ImageManager',
+    'IPSECManager',
     'LoadBalancerManager',
     'MessagingManager',
     'MetadataManager',
diff --git a/SoftLayer/managers/ipsec.py b/SoftLayer/managers/ipsec.py
new file mode 100644
index 00000000..130623c4
--- /dev/null
+++ b/SoftLayer/managers/ipsec.py
@@ -0,0 +1,290 @@
+"""
+    SoftLayer.ipsec
+    ~~~~~~~~~~~~~~~~~~
+    IPSec VPN Manager
+
+    :license: MIT, see LICENSE for more details.
+"""
+
+from SoftLayer.exceptions import SoftLayerAPIError
+from SoftLayer import utils
+
+
+class IPSECManager(utils.IdentifierMixin, object):
+    """Manage SoftLayer IPSEC VPN tunnel contexts.
+
+    This provides helpers to manage IPSEC contexts, private and remote subnets,
+    and NAT translations.
+
+    :param SoftLayer.API.BaseClient client: the client instance
+    :param SoftLayer.API.BaseClient account: account service client
+    :param SoftLayer.API.BaseClient context: tunnel context client
+    :param SoftLayer.API.BaseClient customer_subnet: remote subnet client
+    """
+
+    def __init__(self, client):
+        self.client = client
+        self.account = client['Account']
+        self.context = client['Network_Tunnel_Module_Context']
+        self.remote_subnet = client['Network_Customer_Subnet']
+
+    def add_internal_subnet(self, context_id, subnet_id):
+        """Add an internal subnet to a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int subnet_id: The id-value representing the internal subnet.
+        :return bool: True if internal subnet addition was successful.
+        """
+        return self.context.addPrivateSubnetToNetworkTunnel(subnet_id,
+                                                            id=context_id)
+
+    def add_remote_subnet(self, context_id, subnet_id):
+        """Adds a remote subnet to a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int subnet_id: The id-value representing the remote subnet.
+        :return bool: True if remote subnet addition was successful.
+        """
+        return self.context.addCustomerSubnetToNetworkTunnel(subnet_id,
+                                                             id=context_id)
+
+    def add_service_subnet(self, context_id, subnet_id):
+        """Adds a service subnet to a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int subnet_id: The id-value representing the service subnet.
+        :return bool: True if service subnet addition was successful.
+        """
+        return self.context.addServiceSubnetToNetworkTunnel(subnet_id,
+                                                            id=context_id)
+
+    def apply_configuration(self, context_id):
+        """Requests network configuration for a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :return bool: True if the configuration request was successfully queued.
+        """
+        return self.context.applyConfigurationsToDevice(id=context_id)
+
+    def create_remote_subnet(self, account_id, identifier, cidr):
+        """Creates a remote subnet on the given account.
+
+        :param string account_id: The account identifier.
+        :param string identifier: The network identifier of the remote subnet.
+        :param string cidr: The CIDR value of the remote subnet.
+        :return dict: Mapping of properties for the new remote subnet.
+        """
+        return self.remote_subnet.createObject({
+            'accountId': account_id,
+            'cidr': cidr,
+            'networkIdentifier': identifier
+        })
+
+    def create_translation(self, context_id, static_ip, remote_ip, notes):
+        """Creates an address translation on a tunnel context/
+
+        :param int context_id: The id-value representing the context instance.
+        :param string static_ip: The IP address value representing the
+               internal side of the translation entry,
+        :param string remote_ip: The IP address value representing the remote
+               side of the translation entry,
+        :param string notes: The notes to supply with the translation entry,
+        :return dict: Mapping of properties for the new translation entry.
+        """
+        return self.context.createAddressTranslation({
+            'customerIpAddress': remote_ip,
+            'internalIpAddress': static_ip,
+            'notes': notes
+        }, id=context_id)
+
+    def delete_remote_subnet(self, subnet_id):
+        """Deletes a remote subnet from the current account.
+
+        :param string subnet_id: The id-value representing the remote subnet.
+        :return bool: True if subnet deletion was successful.
+        """
+        return self.remote_subnet.deleteObject(id=subnet_id)
+
+    def get_tunnel_context(self, context_id, **kwargs):
+        """Retrieves the network tunnel context instance.
+
+        :param int context_id: The id-value representing the context instance.
+        :return dict: Mapping of properties for the tunnel context.
+        :raise SoftLayerAPIError: If a context cannot be found.
+        """
+        _filter = utils.NestedDict(kwargs.get('filter') or {})
+        _filter['networkTunnelContexts']['id'] = utils.query_filter(context_id)
+
+        kwargs['filter'] = _filter.to_dict()
+        contexts = self.account.getNetworkTunnelContexts(**kwargs)
+        if len(contexts) == 0:
+            raise SoftLayerAPIError('SoftLayer_Exception_ObjectNotFound',
+                                    'Unable to find object with id of \'{}\''
+                                    .format(context_id))
+        return contexts[0]
+
+    def get_translation(self, context_id, translation_id):
+        """Retrieves a translation entry for the given id values.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int translation_id: The id-value representing the translation
+               instance.
+        :return dict: Mapping of properties for the translation entry.
+        :raise SoftLayerAPIError: If a translation cannot be found.
+        """
+        translation = next((x for x in self.get_translations(context_id)
+                            if x['id'] == translation_id), None)
+        if translation is None:
+            raise SoftLayerAPIError('SoftLayer_Exception_ObjectNotFound',
+                                    'Unable to find object with id of \'{}\''
+                                    .format(translation_id))
+        return translation
+
+    def get_translations(self, context_id):
+        """Retrieves all translation entries for a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :return list(dict): Translations associated with the given context
+        """
+        _mask = ('[mask[addressTranslations[customerIpAddressRecord,'
+                 'internalIpAddressRecord]]]')
+        context = self.get_tunnel_context(context_id, mask=_mask)
+        # Pull the internal and remote IP addresses into the translation
+        for translation in context.get('addressTranslations', []):
+            remote_ip = translation.get('customerIpAddressRecord', {})
+            internal_ip = translation.get('internalIpAddressRecord', {})
+            translation['customerIpAddress'] = remote_ip.get('ipAddress', '')
+            translation['internalIpAddress'] = internal_ip.get('ipAddress', '')
+            translation.pop('customerIpAddressRecord', None)
+            translation.pop('internalIpAddressRecord', None)
+        return context['addressTranslations']
+
+    def get_tunnel_contexts(self, **kwargs):
+        """Retrieves network tunnel module context instances.
+
+        :return list(dict): Contexts associated with the current account.
+        """
+        return self.account.getNetworkTunnelContexts(**kwargs)
+
+    def remove_internal_subnet(self, context_id, subnet_id):
+        """Remove an internal subnet from a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int subnet_id: The id-value representing the internal subnet.
+        :return bool: True if internal subnet removal was successful.
+        """
+        return self.context.removePrivateSubnetFromNetworkTunnel(subnet_id,
+                                                                 id=context_id)
+
+    def remove_remote_subnet(self, context_id, subnet_id):
+        """Removes a remote subnet from a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int subnet_id: The id-value representing the remote subnet.
+        :return bool: True if remote subnet removal was successful.
+        """
+        return self.context.removeCustomerSubnetFromNetworkTunnel(subnet_id,
+                                                                  id=context_id)
+
+    def remove_service_subnet(self, context_id, subnet_id):
+        """Removes a service subnet from a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int subnet_id: The id-value representing the service subnet.
+        :return bool: True if service subnet removal was successful.
+        """
+        return self.context.removeServiceSubnetFromNetworkTunnel(subnet_id,
+                                                                 id=context_id)
+
+    def remove_translation(self, context_id, translation_id):
+        """Removes a translation entry from a tunnel context.
+
+        :param int context_id: The id-value representing the context instance.
+        :param int translation_id: The id-value representing the translation.
+        :return bool: True if translation entry removal was successful.
+        """
+        return self.context.deleteAddressTranslation(translation_id,
+                                                     id=context_id)
+
+    def update_translation(self, context_id, translation_id, static_ip=None,
+                           remote_ip=None, notes=None):
+        """Updates an address translation entry using the given values.
+
+        :param int context_id: The id-value representing the context instance.
+        :param dict template: A key-value mapping of translation properties.
+        :param string static_ip: The static IP address value to update.
+        :param string remote_ip: The remote IP address value to update.
+        :param string notes: The notes value to update.
+        :return bool: True if the update was successful.
+        """
+        translation = self.get_translation(context_id, translation_id)
+
+        if static_ip is not None:
+            translation['internalIpAddress'] = static_ip
+            translation.pop('internalIpAddressId', None)
+        if remote_ip is not None:
+            translation['customerIpAddress'] = remote_ip
+            translation.pop('customerIpAddressId', None)
+        if notes is not None:
+            translation['notes'] = notes
+        # todo: Update this signature to return the updated translation
+        #       once internal and customer IP addresses can be fetched
+        #       and set on the translation object, i.e. that which is
+        #       currently being handled in get_translations
+        self.context.editAddressTranslation(translation, id=context_id)
+        return True
+
+    def update_tunnel_context(self, context_id, friendly_name=None,
+                              remote_peer=None, preshared_key=None,
+                              phase1_auth=None, phase1_crypto=None,
+                              phase1_dh=None, phase1_key_ttl=None,
+                              phase2_auth=None, phase2_crypto=None,
+                              phase2_dh=None, phase2_forward_secrecy=None,
+                              phase2_key_ttl=None):
+        """Updates a tunnel context using the given values.
+
+        :param string context_id: The id-value representing the context.
+        :param string friendly_name: The friendly name value to update.
+        :param string remote_peer: The remote peer IP address value to update.
+        :param string preshared_key: The preshared key value to update.
+        :param string phase1_auth: The phase 1 authentication value to update.
+        :param string phase1_crypto: The phase 1 encryption value to update.
+        :param string phase1_dh: The phase 1 diffie hellman group value
+               to update.
+        :param string phase1_key_ttl: The phase 1 key life value to update.
+        :param string phase2_auth: The phase 2 authentication value to update.
+        :param string phase2_crypto: The phase 2 encryption value to update.
+        :param string phase2_df: The phase 2 diffie hellman group value
+               to update.
+        :param string phase2_forward_secriecy: The phase 2 perfect forward
+               secrecy value to update.
+        :param string phase2_key_ttl: The phase 2 key life value to update.
+        :return bool: True if the update was successful.
+        """
+        context = self.get_tunnel_context(context_id)
+
+        if friendly_name is not None:
+            context['friendlyName'] = friendly_name
+        if remote_peer is not None:
+            context['customerPeerIpAddress'] = remote_peer
+        if preshared_key is not None:
+            context['presharedKey'] = preshared_key
+        if phase1_auth is not None:
+            context['phaseOneAuthentication'] = phase1_auth
+        if phase1_crypto is not None:
+            context['phaseOneEncryption'] = phase1_crypto
+        if phase1_dh is not None:
+            context['phaseOneDiffieHellmanGroup'] = phase1_dh
+        if phase1_key_ttl is not None:
+            context['phaseOneKeylife'] = phase1_key_ttl
+        if phase2_auth is not None:
+            context['phaseTwoAuthentication'] = phase2_auth
+        if phase2_crypto is not None:
+            context['phaseTwoEncryption'] = phase2_crypto
+        if phase2_dh is not None:
+            context['phaseTwoDiffieHellmanGroup'] = phase2_dh
+        if phase2_forward_secrecy is not None:
+            context['phaseTwoPerfectForwardSecrecy'] = phase2_forward_secrecy
+        if phase2_key_ttl is not None:
+            context['phaseTwoKeylife'] = phase2_key_ttl
+        return self.context.editObject(context, id=context_id)
diff --git a/docs/api/managers/ipsec.rst b/docs/api/managers/ipsec.rst
new file mode 100644
index 00000000..d3c326a7
--- /dev/null
+++ b/docs/api/managers/ipsec.rst
@@ -0,0 +1,5 @@
+.. _ipsec:
+
+.. automodule:: SoftLayer.managers.ipsec
+   :members:
+   :inherited-members:
diff --git a/docs/api/managers/iscsi.rst b/docs/api/managers/iscsi.rst
deleted file mode 100644
index 1b6f20f5..00000000
--- a/docs/api/managers/iscsi.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-.. _iscsi:
-
-.. automodule:: SoftLayer.managers.iscsi
-   :members:
-   :inherited-members:
diff --git a/docs/cli.rst b/docs/cli.rst
index 32249d29..ecf6c86d 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -12,6 +12,7 @@ functionality not fully documented here.
 .. toctree::
    :maxdepth: 2
 
+   cli/ipsec
    cli/vs
 
 
diff --git a/docs/cli/ipsec.rst b/docs/cli/ipsec.rst
new file mode 100644
index 00000000..2786a5ed
--- /dev/null
+++ b/docs/cli/ipsec.rst
@@ -0,0 +1,220 @@
+.. _cli_ipsec:
+
+Interacting with IPSEC Tunnels
+==============================
+The IPSEC :ref:`cli` commands can be used to configure an existing IPSEC tunnel context. Subnets in the SoftLayer private network can be associated to the tunnel context along with user-defined remote subnets. Address translation entries may also be defined to provide NAT functionality from static subnet IP addresses associated with the tunnel context to user-defined remote subnet IP addresses.
+
+.. note::
+
+    Most CLI actions that affect an IPSEC tunnel context do not result in configuration changes to SoftLayer network devices. A separate *configure* command is available to issue a device configuration request.
+
+To see more information about the IPSEC tunnel context module and API internaction, see :doc:`IPSEC Module<../api/managers/ipsec>` documentation.
+
+.. _cli_ipsec_list:
+
+ipsec list
+----------
+A list of all IPSEC tunnel contexts associated with the current user's account can be retrieved via the ``ipsec list`` command. This provides a brief overview of all tunnel contexts and can be used to retrieve an individual context's identifier, which all other CLI commands require.
+::
+
+    $ slcli ipsec list
+    :.....:..........:...............:..........................:........................:...........................:
+    :  id :   name   : friendly name : internal peer IP address : remote peer IP address :          created          :
+    :.....:..........:...............:..........................:........................:...........................:
+    : 445 : ipsec038 :  ipsec tunnel :      173.192.250.79      :      158.85.80.22      : 2012-03-05T14:07:34-06:00 :
+    :.....:..........:...............:..........................:........................:...........................:
+
+.. _cli_ipsec_detail:
+
+ipsec detail
+------------
+More detailed information can be retrieved for an individual context using the ``ipsec detail`` command. Using the detail command, information about associated internal subnets, remote subnets, static subnets, service subnets and address translations may also be retrieved using multiple instances of the ``-i|--include`` option.
+::
+
+    $ slcli ipsec detail 445 -i at -i is -i rs -i sr -i ss
+    Context Details:
+    :.................................:...........................:
+    :                            name : value                     :
+    :.................................:...........................:
+    :                              id : 445                       :
+    :                            name : ipsec038                  :
+    :                   friendly name : ipsec tunnel              :
+    :        internal peer IP address : 173.192.250.79            :
+    :          remote peer IP address : 158.85.80.22              :
+    :     advanced configuration flag : 0                         :
+    :                   preshared key : secret                    :
+    :          phase 1 authentication : MD5                       :
+    :    phase 1 diffie hellman group : 0                         :
+    :              phase 1 encryption : DES                       :
+    :                phase 1 key life : 240                       :
+    :          phase 2 authentication : MD5                       :
+    :    phase 2 diffie hellman group : 1                         :
+    :              phase 2 encryption : DES                       :
+    :                phase 2 key life : 240                       :
+    : phase 2 perfect forward secrecy : 1                         :
+    :                         created : 2012-03-05T14:07:34-06:00 :
+    :                        modified : 2017-05-17T12:01:33-06:00 :
+    :.................................:...........................:
+    Address Translations:
+    :.......:...................:......................:...................:......................:.................:
+    :   id  : static IP address : static IP address id : remote IP address : remote IP address id :       note      :
+    :.......:...................:......................:...................:......................:.................:
+    : 15920 :    10.1.249.86    :       9791681        :    158.85.80.22   :        98828         :  windows server :
+    : 15918 :    10.1.249.84    :       9791679        :    158.85.80.20   :        98824         :   unix server   :
+    :.......:...................:......................:...................:......................:.................:
+    Internal Subnets:
+    :........:....................:......:......:
+    :   id   : network identifier : cidr : note :
+    :........:....................:......:......:
+    : 180767 :    10.28.67.128    :  26  :      :
+    :........:....................:......:......:
+    Remote Subnets:
+    :......:....................:......:......:
+    :  id  : network identifier : cidr : note :
+    :......:....................:......:......:
+    : 7852 :    158.85.80.20    :  30  :      :
+    :......:....................:......:......:
+    Static Subnets:
+    :........:....................:......:......:
+    :   id   : network identifier : cidr : note :
+    :........:....................:......:......:
+    : 231807 :    10.1.249.84     :  30  :      :
+    :........:....................:......:......:
+    Service Subnets:
+    :........:....................:......:......:
+    :   id   : network identifier : cidr : note :
+    :........:....................:......:......:
+    : 162079 :     10.0.80.0      :  25  :      :
+    :........:....................:......:......:
+
+.. _cli_ipsec_update:
+
+ipsec update
+------------
+Most values listed in the tunnel context detail printout can be modified using the ``ipsec update`` command. The following is given when executing with the ``-h|--help`` option and highlights all properties that may be modified.
+::
+
+    $ slcli ipsec update -h
+    Usage: slcli ipsec update [OPTIONS] CONTEXT_ID
+
+      Update tunnel context properties.
+
+      Updates are made atomically, so either all are accepted or none are.
+
+      Key life values must be in the range 120-172800.
+
+      Phase 2 perfect forward secrecy must be in the range 0-1.
+
+      A separate configuration request should be made to realize changes on
+      network devices.
+
+    Options:
+      --friendly-name TEXT            Friendly name value
+      --remote-peer TEXT              Remote peer IP address value
+      --preshared-key TEXT            Preshared key value
+      --p1-auth, --phase1-auth [MD5|SHA1|SHA256]
+                                      Phase 1 authentication value
+      --p1-crypto, --phase1-crypto [DES|3DES|AES128|AES192|AES256]
+                                      Phase 1 encryption value
+      --p1-dh, --phase1-dh [0|1|2|5]  Phase 1 diffie hellman group value
+      --p1-key-ttl, --phase1-key-ttl INTEGER RANGE
+                                      Phase 1 key life value
+      --p2-auth, --phase2-auth [MD5|SHA1|SHA256]
+                                      Phase 2 authentication value
+      --p2-crypto, --phase2-crypto [DES|3DES|AES128|AES192|AES256]
+                                      Phase 2 encryption value
+      --p2-dh, --phase2-dh [0|1|2|5]  Phase 2 diffie hellman group value
+      --p2-forward-secrecy, --phase2-forward-secrecy INTEGER RANGE
+                                      Phase 2 perfect forward secrecy value
+      --p2-key-ttl, --phase2-key-ttl INTEGER RANGE
+                                      Phase 2 key life value
+      -h, --help                      Show this message and exit.
+
+.. _cli_ipsec_configure:
+
+ipsec configure
+---------------
+A request to configure SoftLayer network devices for a given tunnel context can be issued using the ``ipsec configure`` command.
+
+.. note::
+
+     Once a configuration request is received, the IPSEC tunnel context will be placed into an unmodifiable state, and further changes against the tunnel context will be prevented. Once configuration changes have been made, the tunnel context may again be modified. The unmodifiable state of a tunnel context is indicated by an *advanced configuration flag* value of 1.
+
+.. _cli_ipsec_subnet_add:
+
+ipsec subnet-add
+----------------
+Internal, remote and service subnets can be associated to an IPSEC tunnel context using the ``ipsec subnet-add`` command. Additionally, remote subnets can be created using this same command, which will then be associated to the targeted tunnel context.
+
+.. note::
+
+    The targeted subnet type must be specified. A subnet id must be provided when associating internal and service subnets. Either a subnet id or a network identifier must be provided when associating remote subnets. If a network identifier is provided when associating a remote subnet, that subnet will first be created and then associated to the tunnel context.
+
+The following is an exmaple of associating an internal subnet to a tunnel context.
+::
+
+    $ slcli ipsec subnet-add 445 --subnet-id 180767 --subnet-type internal
+    Added internal subnet #180767
+
+The following is an example of creating and associating a remote subnet to a tunnel context.
+::
+
+    $ slcli ipsec subnet-add 445 --subnet-type remote --network 50.100.0.0/26
+    Created subnet 50.100.0.0/26 #21268
+    Added remote subnet #21268
+
+.. _cli_ipsec_subnet_remove:
+
+ipsec subnet-remove
+-------------------
+Internal, remote and service subnets can be disassociated from an IPSEC tunnel context via the ``ipsec subnet-remove`` command.
+
+.. note::
+
+    The targeted subnet id and type must be specified. When disassociating remote subnets, that subnet record will also be deleted.
+
+The following is an example of disassociating an internal subnet from a tunnel context.
+::
+
+    $ slcli ipsec subnet-remove 445 --subnet-id 180767 --subnet-type internal
+    Removed internal subnet #180767
+
+.. _cli_ipsec_translation_add:
+
+ipsec translation-add
+---------------------
+Address translation entries can be added to a tunnel context to provide NAT functionality from a statically routed subnet associated with the tunnel context to a remote subnet. This action is performed with the ``ipsec translation-add`` command.
+
+.. note::
+
+    Both static and remote IP address values must be specified. An optional note value may also be provided.
+
+The following is an example of adding a new address translation entry.
+::
+
+    $ slcli ipsec translation-add 445 --static-ip 10.1.249.87 --remote-ip 50.100.0.10 --note 'email server'
+    Created translation from 10.1.249.87 to 50.100.0.10 #15922
+
+.. _cli_ipsec_translation_remove:
+
+ipsec translation-remove
+------------------------
+Address translation entries can be removed using the ``ipsec translation-remove`` command.
+
+The following is an example of removing an address translation entry.
+::
+
+    $ slcli ipsec translation-remove 445 --translation-id 15922
+    Removed translation #15922
+
+.. _cli_ipsec_translation_update:
+
+ipsec translation-update
+------------------------
+Address translation entries may also be modified using the ``ipsec translation-update`` command.
+
+The following is an example of updating an existing address translation entry.
+::
+
+    $ slcli ipsec translation-update 445 --translation-id 15924 --static-ip 10.1.249.86 --remote-ip 50.100.0.8 --note 'new email server'
+    Updated translation #15924


Test patch:
diff --git a/tests/CLI/custom_types_tests.py b/tests/CLI/custom_types_tests.py
new file mode 100644
index 00000000..5342b692
--- /dev/null
+++ b/tests/CLI/custom_types_tests.py
@@ -0,0 +1,31 @@
+"""
+    SoftLayer.tests.CLI.custom_types_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+"""
+
+import click
+
+from SoftLayer.CLI.custom_types import NetworkParamType
+from SoftLayer import testing
+
+
+class CustomTypesTests(testing.TestCase):
+
+    def test_network_param_convert(self):
+        param = NetworkParamType()
+        (ip_address, cidr) = param.convert('10.0.0.0/24', None, None)
+        self.assertEqual(ip_address, '10.0.0.0')
+        self.assertEqual(cidr, 24)
+
+    def test_network_param_convert_fails(self):
+        param = NetworkParamType()
+        self.assertRaises(click.exceptions.BadParameter,
+                          lambda: param.convert('10.0.0.0//24', None, None))
+        self.assertRaises(click.exceptions.BadParameter,
+                          lambda: param.convert('10.0.0.0', None, None))
+        self.assertRaises(click.exceptions.BadParameter,
+                          lambda: param.convert('what is it', None, None))
+        self.assertRaises(click.exceptions.BadParameter,
+                          lambda: param.convert('10.0.0.0/hi', None, None))
diff --git a/tests/CLI/modules/ipsec_tests.py b/tests/CLI/modules/ipsec_tests.py
new file mode 100644
index 00000000..502a244d
--- /dev/null
+++ b/tests/CLI/modules/ipsec_tests.py
@@ -0,0 +1,513 @@
+"""
+    SoftLayer.tests.CLI.modules.ipsec_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+"""
+
+import json
+
+from SoftLayer.CLI.exceptions import ArgumentError
+from SoftLayer.CLI.exceptions import CLIHalt
+from SoftLayer import testing
+
+
+class IPSECTests(testing.TestCase):
+
+    def test_ipsec_configure(self):
+        mock_account = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        mock_account.return_value = [{'id': 445}]
+
+        mock_tunnel = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'applyConfigurationsToDevice')
+        mock_tunnel.return_value = True
+
+        result = self.run_command(['ipsec', 'configure', '445'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'applyConfigurationsToDevice',
+                                identifier=445)
+        self.assertEqual('Configuration request received for context #445\n',
+                         result.output)
+
+    def test_ipsec_configure_fails(self):
+        mock_account = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        mock_account.return_value = [{'id': 445}]
+
+        mock_tunnel = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'applyConfigurationsToDevice')
+        mock_tunnel.return_value = False
+
+        result = self.run_command(['ipsec', 'configure', '445'])
+        self.assertIsInstance(result.exception, CLIHalt)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'applyConfigurationsToDevice',
+                                identifier=445)
+        self.assertEqual(('Failed to enqueue configuration request for '
+                          'context #445\n'),
+                         result.output)
+
+    def test_ipsec_detail(self):
+        _mask = ('[mask[id,accountId,advancedConfigurationFlag,createDate,'
+                 'customerPeerIpAddress,modifyDate,name,friendlyName,'
+                 'internalPeerIpAddress,phaseOneAuthentication,'
+                 'phaseOneDiffieHellmanGroup,phaseOneEncryption,'
+                 'phaseOneKeylife,phaseTwoAuthentication,'
+                 'phaseTwoDiffieHellmanGroup,phaseTwoEncryption,'
+                 'phaseTwoKeylife,phaseTwoPerfectForwardSecrecy,presharedKey,'
+                 'addressTranslations[internalIpAddressRecord[ipAddress],'
+                 'customerIpAddressRecord[ipAddress]],internalSubnets,'
+                 'customerSubnets,staticRouteSubnets,serviceSubnets]]')
+        mock = self.set_mock('SoftLayer_Account',
+                             'getNetworkTunnelContexts')
+        mock.return_value = [{
+            'id': 445,
+            'name': 'der tunnel',
+            'friendlyName': 'the tunnel',
+            'internalPeerIpAddress': '10.0.0.1',
+            'customerPeerIpAddress': '50.0.0.1',
+            'advancedConfigurationFlag': 0,
+            'presharedKey': 'secret',
+            'phaseOneAuthentication': 'MD5',
+            'phaseOneDiffieHellmanGroup': 1,
+            'phaseOneEncryption': 'DES',
+            'phaseOneKeylife': 600,
+            'phaseTwoAuthentication': 'MD5',
+            'phaseTwoDiffieHellmanGroup': 1,
+            'phaseTwoEncryption': 'DES',
+            'phaseTwoKeylife': 600,
+            'phaseTwoPerfectForwardSecrecy': 0,
+            'createDate': '2017-05-17T12:00:00-06:00',
+            'modifyDate': '2017-05-17T12:01:00-06:00',
+            'addressTranslations': [{
+                'id': 872341,
+                'internalIpAddressId': 982341,
+                'internalIpAddressRecord': {'ipAddress': '10.0.0.1'},
+                'customerIpAddressId': 872422,
+                'customerIpAddressRecord': {'ipAddress': '50.0.0.1'},
+                'notes': 'surprise!'
+            }],
+            'internalSubnets': [{
+                'id': 324113,
+                'networkIdentifier': '10.20.0.0',
+                'cidr': 29,
+                'note': 'Private Network'
+            }],
+            'customerSubnets': [{
+                'id': 873411,
+                'networkIdentifier': '50.0.0.0',
+                'cidr': 26,
+                'note': 'Offsite Network'
+            }],
+            'serviceSubnets': [{
+                'id': 565312,
+                'networkIdentifier': '100.10.0.0',
+                'cidr': 26,
+                'note': 'Service Network'
+            }],
+            'staticRouteSubnets': [{
+                'id': 998232,
+                'networkIdentifier': '50.50.0.0',
+                'cidr': 29,
+                'note': 'Static Network'
+            }]
+        }]
+        result = self.run_command(['ipsec', 'detail', '445', '-iat', '-iis',
+                                   '-irs', '-isr', '-iss'])
+        empty, output = result.output.split('Context Details:\n')
+        context, output = output.split('Address Translations:\n')
+        translations, output = output.split('Internal Subnets:\n')
+        internal_subnets, output = output.split('Remote Subnets:\n')
+        remote_subnets, output = output.split('Static Subnets:\n')
+        static_subnets, service_subnets = output.split('Service Subnets:\n')
+
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account',
+                                'getNetworkTunnelContexts',
+                                mask=_mask)
+        self.assertEqual({'id': 445,
+                          'name': 'der tunnel',
+                          'friendly name': 'the tunnel',
+                          'internal peer IP address': '10.0.0.1',
+                          'remote peer IP address': '50.0.0.1',
+                          'advanced configuration flag': 0,
+                          'preshared key': 'secret',
+                          'phase 1 authentication': 'MD5',
+                          'phase 1 diffie hellman group': 1,
+                          'phase 1 encryption': 'DES',
+                          'phase 1 key life': 600,
+                          'phase 2 authentication': 'MD5',
+                          'phase 2 diffie hellman group': 1,
+                          'phase 2 encryption': 'DES',
+                          'phase 2 key life': 600,
+                          'phase 2 perfect forward secrecy': 0,
+                          'created': '2017-05-17T12:00:00-06:00',
+                          'modified': '2017-05-17T12:01:00-06:00'},
+                         json.loads(context))
+        self.assertEqual([{'id': 872341,
+                           'remote IP address': '50.0.0.1',
+                           'remote IP address id': 872422,
+                           'static IP address': '10.0.0.1',
+                           'static IP address id': 982341,
+                           'note': 'surprise!'}],
+                         json.loads(translations))
+        self.assertEqual([{'id': 324113,
+                           'network identifier': '10.20.0.0',
+                           'cidr': 29,
+                           'note': 'Private Network'}],
+                         json.loads(internal_subnets))
+        self.assertEqual([{'id': 873411,
+                           'network identifier': '50.0.0.0',
+                           'cidr': 26,
+                           'note': 'Offsite Network'}],
+                         json.loads(remote_subnets))
+        self.assertEqual([{'id': 998232,
+                           'network identifier': '50.50.0.0',
+                           'cidr': 29,
+                           'note': 'Static Network'}],
+                         json.loads(static_subnets))
+        self.assertEqual([{'id': 565312,
+                           'network identifier': '100.10.0.0',
+                           'cidr': 26,
+                           'note': 'Service Network'}],
+                         json.loads(service_subnets))
+
+    def test_ipsec_list(self):
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = [{'id': 445,
+                              'name': 'der tunnel',
+                              'friendlyName': 'the tunnel',
+                              'internalPeerIpAddress': '10.0.0.1',
+                              'customerPeerIpAddress': '50.0.0.1',
+                              'advancedConfigurationFlag': 0,
+                              'presharedKey': 'secret',
+                              'phaseOneAuthentication': 'MD5',
+                              'phaseOneDiffieHellmanGroup': 1,
+                              'phaseOneEncryption': 'DES',
+                              'phaseOneKeylife': 600,
+                              'phaseTwoAuthentication': 'MD5',
+                              'phaseTwoDiffieHellmanGroup': 1,
+                              'phaseTwoEncryption': 'DES',
+                              'phaseTwoKeylife': 600,
+                              'phaseTwoPerfectForwardSecrecy': 0,
+                              'createDate': '2017-05-17T12:00:00-06:00',
+                              'modifyDate': '2017-05-17T12:01:00-06:00'}]
+        result = self.run_command(['ipsec', 'list'])
+
+        self.assert_no_fail(result)
+        self.assertEqual([{
+            'id': 445,
+            'name': 'der tunnel',
+            'friendly name': 'the tunnel',
+            'internal peer IP address': '10.0.0.1',
+            'remote peer IP address': '50.0.0.1',
+            'created': '2017-05-17T12:00:00-06:00'
+        }], json.loads(result.output))
+
+    def test_ipsec_update(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445,
+                                      'name': 'der tunnel',
+                                      'friendlyName': 'the tunnel',
+                                      'internalPeerIpAddress': '10.0.0.1',
+                                      'customerPeerIpAddress': '50.0.0.1',
+                                      'advancedConfigurationFlag': 0,
+                                      'presharedKey': 'secret',
+                                      'phaseOneAuthentication': 'MD5',
+                                      'phaseOneDiffieHellmanGroup': 1,
+                                      'phaseOneEncryption': 'DES',
+                                      'phaseOneKeylife': 600,
+                                      'phaseTwoAuthentication': 'MD5',
+                                      'phaseTwoDiffieHellmanGroup': 1,
+                                      'phaseTwoEncryption': 'DES',
+                                      'phaseTwoKeylife': 600,
+                                      'phaseTwoPerfectForwardSecrecy': 0}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'editObject')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'update', '445',
+                                   '--friendly-name=ipsec tunnel',
+                                   '--remote-peer=50.0.0.2',
+                                   '--preshared-key=enigma',
+                                   '--p1-auth=SHA256', '--p1-crypto=AES256',
+                                   '--p1-dh=5', '--p1-key-ttl=120',
+                                   '--p2-auth=SHA1', '--p2-crypto=AES192',
+                                   '--p2-dh=2', '--p2-forward-secrecy=1',
+                                   '--p2-key-ttl=240'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Updated context #445\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'editObject',
+                                identifier=445,
+                                args=({'id': 445,
+                                       'name': 'der tunnel',
+                                       'friendlyName': 'ipsec tunnel',
+                                       'internalPeerIpAddress': '10.0.0.1',
+                                       'customerPeerIpAddress': '50.0.0.2',
+                                       'advancedConfigurationFlag': 0,
+                                       'presharedKey': 'enigma',
+                                       'phaseOneAuthentication': 'SHA256',
+                                       'phaseOneDiffieHellmanGroup': '5',
+                                       'phaseOneEncryption': 'AES256',
+                                       'phaseOneKeylife': 120,
+                                       'phaseTwoAuthentication': 'SHA1',
+                                       'phaseTwoDiffieHellmanGroup': '2',
+                                       'phaseTwoEncryption': 'AES192',
+                                       'phaseTwoKeylife': 240,
+                                       'phaseTwoPerfectForwardSecrecy': 1},))
+
+    def test_ipsec_update_fails(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'editObject')
+        tunnel_mock.return_value = False
+
+        result = self.run_command(['ipsec', 'update', '445'])
+        self.assertIsInstance(result.exception, CLIHalt)
+        self.assertEqual('Failed to update context #445\n', result.output)
+
+    def test_ipsec_subnet_add_internal(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'addPrivateSubnetToNetworkTunnel')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'subnet-add', '445', '-tinternal',
+                                   '-s234716'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Added internal subnet #234716\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'addPrivateSubnetToNetworkTunnel',
+                                identifier=445,
+                                args=(234716,))
+
+    def test_ipsec_subnet_add_remote(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445, 'accountId': 999000}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'addCustomerSubnetToNetworkTunnel')
+        tunnel_mock.return_value = True
+
+        subnet_mock = self.set_mock('SoftLayer_Network_Customer_Subnet',
+                                    'createObject')
+        subnet_mock.return_value = {'id': 234716}
+
+        result = self.run_command(['ipsec', 'subnet-add', '445', '-tremote',
+                                   '-n50.0.0.0/26'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output,
+                         ('Created subnet 50.0.0.0/26 #234716\n'
+                          'Added remote subnet #234716\n'))
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'addCustomerSubnetToNetworkTunnel',
+                                identifier=445,
+                                args=(234716,))
+        self.assert_called_with('SoftLayer_Network_Customer_Subnet',
+                                'createObject',
+                                args=({'networkIdentifier': '50.0.0.0',
+                                       'cidr': 26,
+                                       'accountId': 999000},))
+
+    def test_ipsec_subnet_add_service(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'addServiceSubnetToNetworkTunnel')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'subnet-add', '445', '-tservice',
+                                   '-s234716'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Added service subnet #234716\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'addServiceSubnetToNetworkTunnel',
+                                identifier=445,
+                                args=(234716,))
+
+    def test_ipsec_subnet_add_without_id_or_network(self):
+        result = self.run_command(['ipsec', 'subnet-add', '445', '-tinternal'])
+        self.assertIsInstance(result.exception, ArgumentError)
+
+    def test_ipsec_subnet_add_internal_with_network(self):
+        result = self.run_command(['ipsec', 'subnet-add', '445', '-tinternal',
+                                   '-n50.0.0.0/26'])
+        self.assertIsInstance(result.exception, ArgumentError)
+
+    def test_ipsec_subnet_add_fails(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'addPrivateSubnetToNetworkTunnel')
+        tunnel_mock.return_value = False
+
+        result = self.run_command(['ipsec', 'subnet-add', '445', '-tinternal',
+                                   '-s234716'])
+        self.assertIsInstance(result.exception, CLIHalt)
+        self.assertEqual(result.output,
+                         'Failed to add internal subnet #234716\n')
+
+    def test_ipsec_subnet_remove_internal(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'removePrivateSubnetFromNetworkTunnel')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'subnet-remove', '445',
+                                   '-tinternal', '-s234716'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Removed internal subnet #234716\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'removePrivateSubnetFromNetworkTunnel',
+                                identifier=445,
+                                args=(234716,))
+
+    def test_ipsec_subnet_remove_remote(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'removeCustomerSubnetFromNetworkTunnel')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'subnet-remove', '445',
+                                   '-tremote', '-s234716'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Removed remote subnet #234716\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'removeCustomerSubnetFromNetworkTunnel',
+                                identifier=445,
+                                args=(234716,))
+
+    def test_ipsec_subnet_remove_service(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'removeServiceSubnetFromNetworkTunnel')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'subnet-remove', '445',
+                                   '-tservice', '-s234716'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Removed service subnet #234716\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'removeServiceSubnetFromNetworkTunnel',
+                                identifier=445,
+                                args=(234716,))
+
+    def test_ipsec_subnet_remove_fails(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'removePrivateSubnetFromNetworkTunnel')
+        tunnel_mock.return_value = False
+
+        result = self.run_command(['ipsec', 'subnet-remove', '445',
+                                   '-tinternal', '-s234716'])
+        self.assertIsInstance(result.exception, CLIHalt)
+        self.assertEqual(result.output,
+                         'Failed to remove internal subnet #234716\n')
+
+    def test_ipsec_translation_add(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'createAddressTranslation')
+        tunnel_mock.return_value = {'id': 872843}
+
+        result = self.run_command(['ipsec', 'translation-add', '445',
+                                   '-s10.50.0.0', '-r50.50.0.0', '-nlost'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output,
+                         ('Created translation from 10.50.0.0 to 50.50.0.0 '
+                          '#872843\n'))
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'createAddressTranslation',
+                                identifier=445,
+                                args=({'customerIpAddress': '50.50.0.0',
+                                       'internalIpAddress': '10.50.0.0',
+                                       'notes': 'lost'},))
+
+    def test_ipsec_translation_remove(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445,
+                                      'addressTranslations': [{'id': 872843}]}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'deleteAddressTranslation')
+        tunnel_mock.return_value = True
+
+        result = self.run_command(['ipsec', 'translation-remove', '445',
+                                   '-t872843'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Removed translation #872843\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'deleteAddressTranslation',
+                                identifier=445,
+                                args=(872843,))
+
+    def test_ipsec_translation_remove_fails(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445,
+                                      'addressTranslations': [{'id': 872843}]}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'deleteAddressTranslation')
+        tunnel_mock.return_value = False
+
+        result = self.run_command(['ipsec', 'translation-remove', '445',
+                                   '-t872843'])
+        self.assertIsInstance(result.exception, CLIHalt)
+        self.assertEqual(result.output,
+                         'Failed to remove translation #872843\n')
+
+    def test_ipsec_translation_update(self):
+        account_mock = self.set_mock('SoftLayer_Account',
+                                     'getNetworkTunnelContexts')
+        account_mock.return_value = [{'id': 445,
+                                      'addressTranslations': [{'id': 872843}]}]
+
+        tunnel_mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                                    'editAddressTranslation')
+        tunnel_mock.return_value = {'id': 872843}
+
+        result = self.run_command(['ipsec', 'translation-update', '445',
+                                   '-t872843', '-s10.50.0.1', '-r50.50.0.1',
+                                   '-nlost'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.output, 'Updated translation #872843\n')
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'editAddressTranslation',
+                                identifier=445,
+                                args=({'id': 872843,
+                                       'internalIpAddress': '10.50.0.1',
+                                       'customerIpAddress': '50.50.0.1',
+                                       'notes': 'lost'},))
diff --git a/tests/managers/ipsec_tests.py b/tests/managers/ipsec_tests.py
new file mode 100644
index 00000000..05c6c976
--- /dev/null
+++ b/tests/managers/ipsec_tests.py
@@ -0,0 +1,302 @@
+"""
+    SoftLayer.tests.managers.ipsec_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+"""
+
+from mock import MagicMock
+
+import SoftLayer
+from SoftLayer.exceptions import SoftLayerAPIError
+from SoftLayer import testing
+
+
+class IPSECTests(testing.TestCase):
+
+    def set_up(self):
+        self.ipsec = SoftLayer.IPSECManager(self.client)
+
+    def test_add_internal_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'addPrivateSubnetToNetworkTunnel')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.add_internal_subnet(445, 565787), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'addPrivateSubnetToNetworkTunnel',
+                                args=(565787,),
+                                identifier=445)
+
+    def test_add_remote_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'addCustomerSubnetToNetworkTunnel')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.add_remote_subnet(445, 565787), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'addCustomerSubnetToNetworkTunnel',
+                                args=(565787,),
+                                identifier=445)
+
+    def test_add_service_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'addServiceSubnetToNetworkTunnel')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.add_service_subnet(445, 565787), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'addServiceSubnetToNetworkTunnel',
+                                args=(565787,),
+                                identifier=445)
+
+    def test_apply_configuration(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'applyConfigurationsToDevice')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.apply_configuration(445), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'applyConfigurationsToDevice',
+                                args=(),
+                                identifier=445)
+
+    def test_create_remote_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Customer_Subnet',
+                             'createObject')
+        mock.return_value = {'id': 565787,
+                             'networkIdentifier': '50.0.0.0',
+                             'cidr': 29,
+                             'accountId': 999000}
+        result = self.ipsec.create_remote_subnet(999000, '50.0.0.0', 29)
+        self.assertEqual(result, mock.return_value)
+        self.assert_called_with('SoftLayer_Network_Customer_Subnet',
+                                'createObject',
+                                args=({'networkIdentifier': '50.0.0.0',
+                                       'cidr': 29,
+                                       'accountId': 999000},))
+
+    def test_create_translation(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'createAddressTranslation')
+        mock.return_value = {'id': 787989,
+                             'customerIpAddress': '50.0.0.0',
+                             'customerIpAddressId': 672634,
+                             'internalIpAddress': '10.0.0.0',
+                             'internalIpAddressId': 871231,
+                             'notes': 'first translation'}
+        result = self.ipsec.create_translation(445,
+                                               '10.0.0.0',
+                                               '50.0.0.0',
+                                               'first translation')
+        self.assertEqual(result, mock.return_value)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'createAddressTranslation',
+                                args=({'customerIpAddress': '50.0.0.0',
+                                       'internalIpAddress': '10.0.0.0',
+                                       'notes': 'first translation'},),
+                                identifier=445)
+
+    def test_delete_remote_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Customer_Subnet',
+                             'deleteObject')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.delete_remote_subnet(565787), True)
+        self.assert_called_with('SoftLayer_Network_Customer_Subnet',
+                                'deleteObject',
+                                identifier=565787)
+
+    def test_get_tunnel_context(self):
+        _filter = {'networkTunnelContexts': {'id': {'operation': 445}}}
+        _mask = '[mask[id]]'
+
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = [{'id': 445}]
+        result = self.ipsec.get_tunnel_context(445, mask=_mask)
+        self.assertEqual(result, mock.return_value[0])
+        self.assert_called_with('SoftLayer_Account',
+                                'getNetworkTunnelContexts',
+                                filter=_filter,
+                                mask=_mask)
+
+    def test_get_tunnel_context_raises_error(self):
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = []
+        self.assertRaises(SoftLayerAPIError,
+                          self.ipsec.get_tunnel_context,
+                          445)
+
+    def test_get_translation(self):
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = [{'id': 445, 'addressTranslations':
+                             [{'id': 234123}, {'id': 872341}]}]
+        self.assertEqual(self.ipsec.get_translation(445, 872341),
+                         {'id': 872341,
+                          'customerIpAddress': '',
+                          'internalIpAddress': ''})
+        self.assert_called_with('SoftLayer_Account',
+                                'getNetworkTunnelContexts')
+
+    def test_get_translation_raises_error(self):
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = [{'id': 445, 'addressTranslations':
+                             [{'id': 234123}]}]
+        self.assertRaises(SoftLayerAPIError,
+                          self.ipsec.get_translation,
+                          445,
+                          872341)
+
+    def test_get_translations(self):
+        _mask = ('[mask[addressTranslations[customerIpAddressRecord,'
+                 'internalIpAddressRecord]]]')
+        _filter = {'networkTunnelContexts': {'id': {'operation': 445}}}
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = [{'id': 445,
+                              'addressTranslations': [{
+                                  'id': 234123,
+                                  'customerIpAddressRecord':
+                                      {'ipAddress': '50.0.0.0'},
+                                  'customerIpAddressId': 234112,
+                                  'internalIpAddressRecord':
+                                      {'ipAddress': '10.0.0.0'},
+                                  'internalIpAddressId': 234442
+                              }]}]
+        self.assertEqual(self.ipsec.get_translations(445),
+                         [{'id': 234123,
+                           'customerIpAddress': '50.0.0.0',
+                           'customerIpAddressId': 234112,
+                           'internalIpAddress': '10.0.0.0',
+                           'internalIpAddressId': 234442}])
+        self.assert_called_with('SoftLayer_Account',
+                                'getNetworkTunnelContexts',
+                                filter=_filter,
+                                mask=_mask)
+
+    def test_get_tunnel_contexts(self):
+        _mask = '[mask[addressTranslations]]'
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
+        mock.return_value = [{'id': 445}, {'id': 446}]
+        self.assertEqual(self.ipsec.get_tunnel_contexts(mask=_mask),
+                         mock.return_value)
+        self.assert_called_with('SoftLayer_Account',
+                                'getNetworkTunnelContexts',
+                                mask=_mask)
+
+    def test_remove_internal_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'removePrivateSubnetFromNetworkTunnel')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.remove_internal_subnet(445, 565787), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'removePrivateSubnetFromNetworkTunnel',
+                                args=(565787,),
+                                identifier=445)
+
+    def test_remove_remote_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'removeCustomerSubnetFromNetworkTunnel')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.remove_remote_subnet(445, 565787), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'removeCustomerSubnetFromNetworkTunnel',
+                                args=(565787,),
+                                identifier=445)
+
+    def test_remove_service_subnet(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'removeServiceSubnetFromNetworkTunnel')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.remove_service_subnet(445, 565787), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'removeServiceSubnetFromNetworkTunnel',
+                                args=(565787,),
+                                identifier=445)
+
+    def test_remove_translation(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'deleteAddressTranslation')
+        mock.return_value = True
+        self.assertEqual(self.ipsec.remove_translation(445, 787547), True)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'deleteAddressTranslation',
+                                args=(787547,),
+                                identifier=445)
+
+    def test_update_translation(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'editAddressTranslation')
+        mock.return_value = True
+        translation = {'id': 234123,
+                       'customerIpAddress': '50.0.0.0',
+                       'customerIpAddressId': 234112,
+                       'internalIpAddress': '10.0.0.0',
+                       'internalIpAddressId': 234442}
+        self.ipsec.get_translation = MagicMock(return_value=translation)
+
+        result = self.ipsec.update_translation(445,
+                                               234123,
+                                               static_ip='10.0.0.2',
+                                               remote_ip='50.0.0.2',
+                                               notes='do not touch')
+        self.assertEqual(result, True)
+        self.ipsec.get_translation.assert_called_with(445, 234123)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'editAddressTranslation',
+                                args=({'id': 234123,
+                                       'customerIpAddress': '50.0.0.2',
+                                       'internalIpAddress': '10.0.0.2',
+                                       'notes': 'do not touch'},),
+                                identifier=445)
+
+    def test_update_tunnel_context(self):
+        mock = self.set_mock('SoftLayer_Network_Tunnel_Module_Context',
+                             'editObject')
+        mock.return_value = True
+        context = {'id': 445,
+                   'name': 'der tunnel',
+                   'friendlyName': 'the tunnel',
+                   'internalPeerIpAddress': '10.0.0.1',
+                   'customerPeerIpAddress': '50.0.0.1',
+                   'advancedConfigurationFlag': 0,
+                   'presharedKey': 'secret',
+                   'phaseOneAuthentication': 'MD5',
+                   'phaseOneDiffieHellmanGroup': 1,
+                   'phaseOneEncryption': 'DES',
+                   'phaseOneKeylife': 600,
+                   'phaseTwoAuthentication': 'MD5',
+                   'phaseTwoDiffieHellmanGroup': 1,
+                   'phaseTwoEncryption': 'DES',
+                   'phaseTwoKeylife': 600,
+                   'phaseTwoPerfectForwardSecrecy': 0}
+        self.ipsec.get_tunnel_context = MagicMock(return_value=context)
+
+        result = self.ipsec.update_tunnel_context(445,
+                                                  friendly_name='ipsec tunnel',
+                                                  remote_peer='50.0.0.2',
+                                                  preshared_key='enigma',
+                                                  phase1_auth='SHA256',
+                                                  phase1_dh=5,
+                                                  phase1_crypto='AES256',
+                                                  phase1_key_ttl=120,
+                                                  phase2_auth='SHA128',
+                                                  phase2_dh=2,
+                                                  phase2_crypto='AES192',
+                                                  phase2_key_ttl=240,
+                                                  phase2_forward_secrecy=1)
+        self.assertEqual(result, True)
+        self.ipsec.get_tunnel_context.assert_called_with(445)
+        self.assert_called_with('SoftLayer_Network_Tunnel_Module_Context',
+                                'editObject',
+                                args=({'id': 445,
+                                       'name': 'der tunnel',
+                                       'friendlyName': 'ipsec tunnel',
+                                       'internalPeerIpAddress': '10.0.0.1',
+                                       'customerPeerIpAddress': '50.0.0.2',
+                                       'advancedConfigurationFlag': 0,
+                                       'presharedKey': 'enigma',
+                                       'phaseOneAuthentication': 'SHA256',
+                                       'phaseOneDiffieHellmanGroup': 5,
+                                       'phaseOneEncryption': 'AES256',
+                                       'phaseOneKeylife': 120,
+                                       'phaseTwoAuthentication': 'SHA128',
+                                       'phaseTwoDiffieHellmanGroup': 2,
+                                       'phaseTwoEncryption': 'AES192',
+                                       'phaseTwoKeylife': 240,
+                                       'phaseTwoPerfectForwardSecrecy': 1},),
+                                identifier=445)