From 86b1bca73515b10eab71de50fcc879e894972861 Mon Sep 17 00:00:00 2001
From: Christoph Hellwig <hch@lst.de>
Date: Sun, 3 Apr 2016 17:44:35 +0200
Subject: [PATCH] nvmet,nvmetcli: add support for host NQN based access control

Signed-off-by: Christoph Hellwig <hch@lst.de>
---
 README.md           |  30 +++++++++----
 nvmet/nvme.py       | 105 ++++++++++++++++++++++++++++++++++++++++++++
 nvmet/test_nvmet.py |  73 ++++++++++++++++++++++++++++++
 nvmetcli            | 101 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 301 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 3ece072..75ca072 100644
--- a/README.md
+++ b/README.md
@@ -38,24 +38,38 @@ arguments.  Then in the nvmetcli prompt type:
 #
 
 > cd /subsystems
-/subsystems> create testnqn
+...> create testnqn
+
+#
+# Add access for a specific NVMe Host by it's NQN:
+#
+...> cd /hosts
+...> create hostnqn
+...> cd /subsystems/testnqn/allowed_hosts/
+...> create hostnqn
+
+#
+# Alternatively this allows any host to connect to the subsystsem.  Only
+# use this in tightly controller environments:
+#
+...> cd /subsystems/testnqn/
+...> set attr allow_any_host=1
 
 #
 # Create a new namespace.  If you do not specify a namespace ID the fist
 # unused one will be used.
 #
 
-/subsystems> cd testnqn/
-/subsystems/testnqn> cd namespaces 
-/subsystems/testnqn/namespaces> create 1
-/subsystems/testnqn/namespaces> cd 1
-/subsystems/t.../namespaces/1> set device path=/dev/ram1
-/subsystems/t.../namespaces/1> enable
+...> cd namespaces 
+...> create 1
+...> cd 1
+...> set device path=/dev/ram1
+...> enable
 
 
 Testing
 -------
 
-nvmetcli comes with a testsuite that tests itsels and the kernel configfs
+nvmetcli comes with a testsuite that tests itself and the kernel configfs
 interface for the NVMe target.  To run it make sure you have nose2 and
 the coverage plugin for it installed and simple run 'make test'.
diff --git a/nvmet/nvme.py b/nvmet/nvme.py
index 7629107..d991d9b 100644
--- a/nvmet/nvme.py
+++ b/nvmet/nvme.py
@@ -272,6 +272,15 @@ class Root(CFSNode):
     ports = property(_list_ports,
                 doc="Get the list of Ports.")
 
+    def _list_hosts(self):
+        self._check_self()
+
+        for h in os.listdir("%s/hosts/" % self._path):
+            yield Host(h, 'lookup')
+
+    hosts = property(_list_hosts,
+                     doc="Get the list of Hosts.")
+
     def save_to_file(self, savefile=None):
         '''
         Write the configuration in json format to a file.
@@ -300,6 +309,8 @@ class Root(CFSNode):
             s.delete()
         for p in self.ports:
             p.delete()
+        for h in self.hosts:
+            h.delete()
 
     def restore(self, config, clear_existing=False, abort_on_error=False):
         '''
@@ -323,6 +334,14 @@ class Root(CFSNode):
             def err_func(err_str):
                 errors.append(err_str + ", skipped")
 
+        # Create the hosts first because the subsystems reference them
+        for index, t in enumerate(config.get('hosts', [])):
+            if 'nqn' not in t:
+                err_func("'nqn' not defined in host %d" % index)
+                continue
+
+            Host.setup(t, err_func)
+
         for index, t in enumerate(config.get('subsystems', [])):
             if 'nqn' not in t:
                 err_func("'nqn' not defined in subsystem %d" % index)
@@ -360,6 +379,7 @@ class Root(CFSNode):
         d = super(Root, self).dump()
         d['subsystems'] = [s.dump() for s in self.subsystems]
         d['ports'] = [p.dump() for p in self.ports]
+        d['hosts'] = [h.dump() for h in self.hosts]
         return d
 
 
@@ -393,6 +413,7 @@ class Subsystem(CFSNode):
             nqn = self._generate_nqn()
 
         self.nqn = nqn
+        self.attr_groups = ['attr']
         self._path = "%s/subsystems/%s" % (self.configfs_dir, nqn)
         self._create_in_cfs(mode)
 
@@ -415,11 +436,39 @@ class Subsystem(CFSNode):
         self._check_self()
         for ns in self.namespaces:
             ns.delete()
+        for h in self.allowed_hosts:
+            self.remove_allowed_host(h)
         super(Subsystem, self).delete()
 
     namespaces = property(_list_namespaces,
                           doc="Get the list of Namespaces for the Subsystem.")
 
+    def _list_allowed_hosts(self):
+        return [os.path.basename(name)
+                for name in os.listdir("%s/allowed_hosts/" % self._path)]
+
+    allowed_hosts = property(_list_allowed_hosts,
+                             doc="Get the list of Allowed Hosts for the Subsystem.")
+
+    def add_allowed_host(self, nqn):
+        '''
+        Enable access for the host identified by I{nqn} to the Subsystem
+        '''
+        try:
+            os.symlink("%s/hosts/%s" % (self.configfs_dir, nqn),
+                       "%s/allowed_hosts/%s" % (self._path, nqn))
+        except Exception as e:
+            raise CFSError("Could not symlink %s in configFS: %s" % (nqn, e))
+
+    def remove_allowed_host(self, nqn):
+        '''
+        Disable access for the host identified by I{nqn} to the Subsystem
+        '''
+        try:
+            os.unlink("%s/allowed_hosts/%s" % (self._path, nqn))
+        except Exception as e:
+            raise CFSError("Could not unlink %s in configFS: %s" % (nqn, e))
+
     @classmethod
     def setup(cls, t, err_func):
         '''
@@ -440,11 +489,16 @@ class Subsystem(CFSNode):
 
         for ns in t.get('namespaces', []):
             Namespace.setup(s, ns, err_func)
+        for h in t.get('allowed_hosts', []):
+            s.add_allowed_host(h)
+
+        s._setup_attrs(t, err_func)
 
     def dump(self):
         d = super(Subsystem, self).dump()
         d['nqn'] = self.nqn
         d['namespaces'] = [ns.dump() for ns in self.namespaces]
+        d['allowed_hosts'] = self.allowed_hosts
         return d
 
 
@@ -600,6 +654,57 @@ class Port(CFSNode):
         return d
 
 
+class Host(CFSNode):
+    '''
+    This is an interface to a NVMe Host in configFS.
+    A Host is identified by its NQN.
+    '''
+
+    def __repr__(self):
+        return "<Host %s>" % self.nqn
+
+    def __init__(self, nqn, mode='any'):
+        '''
+        @param nqn: The Hosts's NQN.
+        @type nqn: string
+        @param mode:An optional string containing the object creation mode:
+            - I{'any'} means the configFS object will be either looked up
+              or created.
+            - I{'lookup'} means the object MUST already exist configFS.
+            - I{'create'} means the object must NOT already exist in configFS.
+        @type mode:string
+        @return: A Host object.
+        '''
+        super(Host, self).__init__()
+
+        self.nqn = nqn
+        self._path = "%s/hosts/%s" % (self.configfs_dir, nqn)
+        self._create_in_cfs(mode)
+
+    @classmethod
+    def setup(cls, t, err_func):
+        '''
+        Set up Host objects based upon t dict, from saved config.
+        Guard against missing or bad dict items, but keep going.
+        Call 'err_func' for each error.
+        '''
+
+        if 'nqn' not in t:
+            err_func("'nqn' not defined for Host")
+            return
+
+        try:
+            h = Host(t['nqn'])
+        except CFSError as e:
+            err_func("Could not create Host object: %s" % e)
+            return
+
+    def dump(self):
+        d = super(Host, self).dump()
+        d['nqn'] = self.nqn
+        return d
+
+
 def _test():
     from doctest import testmod
     testmod()
diff --git a/nvmet/test_nvmet.py b/nvmet/test_nvmet.py
index 4047348..d2dbb4a 100644
--- a/nvmet/test_nvmet.py
+++ b/nvmet/test_nvmet.py
@@ -245,6 +245,67 @@ class TestNvmet(unittest.TestCase):
         p.set_enable(1)
         p.delete()
 
+    def test_host(self):
+        root = nvme.Root()
+        root.clear_existing()
+        for p in root.hosts:
+            self.assertTrue(False, 'Found Host after clear')
+
+        # create mode
+        h1 = nvme.Host(nqn='foo', mode='create')
+        self.assertIsNotNone(h1)
+        self.assertEqual(len(list(root.hosts)), 1)
+
+        # any mode, should create
+        h2 = nvme.Host(nqn='bar', mode='any')
+        self.assertIsNotNone(h2)
+        self.assertEqual(len(list(root.hosts)), 2)
+
+        # duplicate
+        self.assertRaises(nvme.CFSError, nvme.Host,
+                          'foo', mode='create')
+        self.assertEqual(len(list(root.hosts)), 2)
+
+        # lookup using any, should not create
+        h = nvme.Host('foo', mode='any')
+        self.assertEqual(h1, h)
+        self.assertEqual(len(list(root.hosts)), 2)
+
+        # lookup only
+        h = nvme.Host('bar', mode='lookup')
+        self.assertEqual(h2, h)
+        self.assertEqual(len(list(root.hosts)), 2)
+
+        # and delete them all
+        for h in root.hosts:
+            h.delete()
+        self.assertEqual(len(list(root.hosts)), 0)
+
+    def test_allowed_hosts(self):
+        root = nvme.Root()
+
+        h = nvme.Host(nqn='hostnqn', mode='create')
+
+        s = nvme.Subsystem(nqn='testnqn', mode='create')
+
+        # add allowed_host
+        s.add_allowed_host(nqn='hostnqn')
+
+        # duplicate
+        self.assertRaises(nvme.CFSError, s.add_allowed_host, 'hostnqn')
+
+        # invalid
+        self.assertRaises(nvme.CFSError, s.add_allowed_host, 'invalid')
+
+        # remove again
+        s.remove_allowed_host('hostnqn')
+
+        # duplicate removal
+        self.assertRaises(nvme.CFSError, s.remove_allowed_host, 'hostnqn')
+
+        # invalid removal
+        self.assertRaises(nvme.CFSError, s.remove_allowed_host, 'foobar')
+
     def test_invalid_input(self):
         root = nvme.Root()
         root.clear_existing()
@@ -271,7 +332,13 @@ class TestNvmet(unittest.TestCase):
         root = nvme.Root()
         root.clear_existing()
 
+        h = nvme.Host(nqn='hostnqn', mode='create')
+
         s = nvme.Subsystem(nqn='testnqn', mode='create')
+        s.add_allowed_host(nqn='hostnqn')
+
+        s2 = nvme.Subsystem(nqn='testnqn2', mode='create')
+        s2.set_attr('attr', 'allow_any_host', 1)
 
         n = nvme.Namespace(s, nsid=42, mode='create')
         n.set_attr('device', 'path', '/dev/ram0')
@@ -300,10 +367,16 @@ class TestNvmet(unittest.TestCase):
         root.restore_from_file('test.json', True)
 
         # rebuild our view of the world
+        h = nvme.Host(nqn='hostnqn', mode='lookup')
         s = nvme.Subsystem(nqn='testnqn', mode='lookup')
+        s2 = nvme.Subsystem(nqn='testnqn2', mode='lookup')
         n = nvme.Namespace(s, nsid=42, mode='lookup')
         p = nvme.Port(root, portid=66, mode='lookup')
 
+        self.assertEqual(s.get_attr('attr', 'allow_any_host'), "0")
+        self.assertEqual(s2.get_attr('attr', 'allow_any_host'), "1")
+        self.assertIn('hostnqn', s.allowed_hosts)
+
         # and check everything is still the same
         self.assertTrue(n.get_enable())
         self.assertEqual(n.get_attr('device', 'path'), '/dev/ram0')
diff --git a/nvmetcli b/nvmetcli
index 91d01ca..c2ec343 100755
--- a/nvmetcli
+++ b/nvmetcli
@@ -95,6 +95,7 @@ class UIRootNode(UINode):
         self._children = set([])
         UISubsystemsNode(self)
         UIPortsNode(self)
+        UIHostsNode(self)
 
     def ui_command_restoreconfig(self, savefile=None, clear_existing=False):
         '''
@@ -151,6 +152,7 @@ class UISubsystemNode(UINode):
     def refresh(self):
         self._children = set([])
         UINamespacesNode(self)
+        UIAllowedHostsNode(self)
 
 
 class UINamespacesNode(UINode):
@@ -239,6 +241,66 @@ class UINamespaceNode(UINode):
                     "The Namespace could not be disabled.")
 
 
+class UIAllowedHostsNode(UINode):
+    def __init__(self, parent):
+        UINode.__init__(self, 'allowed_hosts', parent)
+
+    def refresh(self):
+        self._children = set([])
+        for host in self.parent.cfnode.allowed_hosts:
+            UIAllowedHostNode(self, host)
+
+    def ui_command_create(self, nqn):
+        '''
+        Grants access to parent subsystems to the host specified by I{nqn}.
+
+        SEE ALSO
+        ========
+        B{delete}
+        '''
+        self.parent.cfnode.add_allowed_host(nqn)
+        UIAllowedHostNode(self, nqn)
+
+    def ui_complete_create(self, parameters, text, current_param):
+        completions = []
+        if current_param == 'nqn':
+            for host in self.get_node('/hosts').children:
+                completions.append(host.cfnode.nqn)
+
+        if len(completions) == 1:
+            return [completions[0] + ' ']
+        else:
+            return completions
+
+    def ui_command_delete(self, nqn):
+        '''
+        Recursively deletes the namespace with the specified I{nsid}, and all
+        objects hanging under it.
+
+        SEE ALSO
+        ========
+        B{create}
+        '''
+        self.parent.cfnode.remove_allowed_host(nqn)
+        self.refresh()
+
+    def ui_complete_delete(self, parameters, text, current_param):
+        completions = []
+        if current_param == 'nqn':
+            for nqn in self.parent.cfnode.allowed_hosts:
+                completions.append(nqn)
+
+        if len(completions) == 1:
+            return [completions[0] + ' ']
+        else:
+            return completions
+
+
+class UIAllowedHostNode(UINode):
+    def __init__(self, parent, nqn):
+        UINode.__init__(self, nqn, parent)
+
+
 class UIPortsNode(UINode):
     def __init__(self, parent):
         UINode.__init__(self, 'ports', parent)
@@ -320,6 +382,45 @@ class UIPortNode(UINode):
                     "The Port could not be disabled.")
 
 
+class UIHostsNode(UINode):
+    def __init__(self, parent):
+        UINode.__init__(self, 'hosts', parent)
+
+    def refresh(self):
+        self._children = set([])
+        for host in self.parent.cfnode.hosts:
+            UIHostNode(self, host)
+
+    def ui_command_create(self, nqn):
+        '''
+        Creates a new NVMe host.
+
+        SEE ALSO
+        ========
+        B{delete}
+        '''
+        host = nvme.Host(nqn, mode='create')
+        UIHostNode(self, host)
+
+    def ui_command_delete(self, nqn):
+        '''
+        Recursively deletes the NVMe Host with the specified I{nqn}, and all
+        objects hanging under it.
+
+        SEE ALSO
+        ========
+        B{create}
+        '''
+        host = nvme.Host(nqn, mode='lookup')
+        host.delete()
+        self.refresh()
+
+
+class UIHostNode(UINode):
+    def __init__(self, parent, cfnode):
+        UINode.__init__(self, cfnode.nqn, parent, cfnode)
+
+
 def usage():
     print("syntax: %s save [file_to_save_to]" % sys.argv[0])
     print("        %s restore [file_to_restore_from]" % sys.argv[0])
-- 
GitLab