From 1efaec94028541f6f9f2caa8c30e0955acf1a887 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Fri, 14 Mar 2025 11:27:47 -0400 Subject: [PATCH] stacd: add authentication support (DHCHAP) Signed-off-by: Martin Belanger --- .github/workflows/pylint.yml | 4 +-- coverage.sh.in | 2 +- doc/standard-conf.xml | 15 ++++++++++ etc/stas/stacd.conf | 8 ++++++ staslib/conf.py | 1 + staslib/ctrl.py | 36 +++++++++++++++++------- staslib/gutil.py | 2 +- staslib/trid.py | 1 + utils/nvmet/auth.conf | 34 +++++++++++++++++++++++ utils/nvmet/nvmet.py | 54 ++++++++++++++++++++++++++++++------ 10 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 utils/nvmet/auth.conf diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index ff2cee1..985a929 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -59,8 +59,8 @@ jobs: - name: "INSTALL: pip packages" run: | - pip install pylint --upgrade - pip install PyGObject --upgrade + pip install pylint + # pip install PyGObject - name: "BUILD: [libnvme, nvme-stas]" uses: BSFishy/meson-build@v1.0.3 diff --git a/coverage.sh.in b/coverage.sh.in index 51d1106..0d2478e 100755 --- a/coverage.sh.in +++ b/coverage.sh.in @@ -377,7 +377,7 @@ persistent-connections = false zeroconf-connections-persistence = 1:01 [Controllers] -controller = transport = tcp ; traddr = localhost ; ; ; kato=31; dhchap-ctrl-secret=not-so-secret +controller = transport = tcp ; traddr = localhost ; ; ; kato=31; dhchap-ctrl-secret=DHHC-1:00:not-so-secret/not-so-secret/not-so-secret/not-so: ; dhchap-secret=DHHC-1:00:very-secret/very-secret/very-secret/very-secret/: controller=transport=tcp;traddr=1.1.1.1 controller=transport=tcp;traddr=100.100.100.100 controller=transport=tcp;traddr=2607:f8b0:4002:c2c::71 diff --git a/doc/standard-conf.xml b/doc/standard-conf.xml index 50d4fe5..36cb19f 100644 --- a/doc/standard-conf.xml +++ b/doc/standard-conf.xml @@ -376,6 +376,21 @@ + + dhchap-secret= + + + NVMe In-band authentication host secret (i.e. key); + needs to be in ASCII format as specified in NVMe 2.0 + section 8.13.5.8 Secret representation. If this + option is not specified, the default is read + from /etc/stas/sys.conf (see the 'key' parameter + under the [Host] section). In-band authentication + is attempted when this is present. + + + + dhchap-ctrl-secret= diff --git a/etc/stas/stacd.conf b/etc/stas/stacd.conf index 9fbc1c3..c1bcbed 100644 --- a/etc/stas/stacd.conf +++ b/etc/stas/stacd.conf @@ -235,6 +235,14 @@ # This forces the connection to be made on a specific interface # instead of letting the system decide. # +# dhchap-secret [OPTIONAL] +# NVMe In-band authentication host secret (i.e. key); needs to be +# in ASCII format as specified in NVMe 2.0 section 8.13.5.8 Secret +# representation. If this option is not specified, the default is +# read from /etc/stas/sys.conf (see the 'key' parameter under the +# [Host] section). In-band authentication is attempted when this +# is present. +# # dhchap-ctrl-secret [OPTIONAL] # NVMe In-band authentication controller secret (i.e. key) for # bi-directional authentication; needs to be in ASCII format as diff --git a/staslib/conf.py b/staslib/conf.py index 053c5e9..3be6f50 100644 --- a/staslib/conf.py +++ b/staslib/conf.py @@ -341,6 +341,7 @@ def get_controllers(self): 'host-traddr': [TRADDR], 'host-iface': [IFACE], 'host-nqn': [NQN], + 'dhchap-secret': [KEY], 'dhchap-ctrl-secret': [KEY], 'hdr-digest': [BOOL] 'data-digest': [BOOL] diff --git a/staslib/ctrl.py b/staslib/ctrl.py index e4cda6b..e18414e 100644 --- a/staslib/ctrl.py +++ b/staslib/ctrl.py @@ -221,23 +221,39 @@ def _do_connect(self): host_traddr=self.tid.host_traddr if self.tid.host_traddr else None, host_iface=host_iface, ) - self._ctrl.discovery_ctrl_set(self._discovery_ctrl) + self._ctrl.discovery_ctrl = self._discovery_ctrl - # Set the DHCHAP key on the controller - # NOTE that this will eventually have to + # Set the DHCHAP host key on the controller + # NOTE that this may eventually have to # change once we have support for AVE (TP8019) - ctrl_dhchap_key = self.tid.cfg.get('dhchap-ctrl-secret') - if ctrl_dhchap_key and self._nvme_options.dhchap_ctrlkey_supp: - has_dhchap_key = hasattr(self._ctrl, 'dhchap_key') - if not has_dhchap_key: + # This is used for in-band authentication + dhchap_host_key = self.tid.cfg.get('dhchap-secret') + if dhchap_host_key and self._nvme_options.dhchap_hostkey_supp: + try: + self._ctrl.dhchap_host_key = dhchap_host_key + except AttributeError: logging.warning( - '%s | %s - libnvme-%s does not allow setting the controller DHCHAP key. Please upgrade libnvme.', + '%s | %s - libnvme-%s does not allow setting the host DHCHAP key on the controller. Please upgrade libnvme.', + self.id, + self.device, + defs.LIBNVME_VERSION, + ) + + # Set the DHCHAP controller key on the controller + # NOTE that this may eventually have to + # change once we have support for AVE (TP8019) + # This is used for bidirectional authentication + dhchap_ctrl_key = self.tid.cfg.get('dhchap-ctrl-secret') + if dhchap_ctrl_key and self._nvme_options.dhchap_ctrlkey_supp: + try: + self._ctrl.dhchap_key = dhchap_ctrl_key + except AttributeError: + logging.warning( + '%s | %s - libnvme-%s does not allow setting the controller DHCHAP key on the controller. Please upgrade libnvme.', self.id, self.device, defs.LIBNVME_VERSION, ) - else: - self._ctrl.dhchap_key = ctrl_dhchap_key # Audit existing nvme devices. If we find a match, then # we'll just borrow that device instead of creating a new one. diff --git a/staslib/gutil.py b/staslib/gutil.py index 4cdc087..0922fce 100644 --- a/staslib/gutil.py +++ b/staslib/gutil.py @@ -123,7 +123,7 @@ def resolve_ctrl_async(self, cancellable, controllers_in: list, callback): The callback @callback will be called once all hostnames have been resolved. - @param controllers: List of trid.TID + @param controllers_in: List of trid.TID ''' pending_resolution_count = 0 controllers_out = [] diff --git a/staslib/trid.py b/staslib/trid.py index e814f4e..cb4a5de 100644 --- a/staslib/trid.py +++ b/staslib/trid.py @@ -33,6 +33,7 @@ def __init__(self, cid: dict): 'host-nqn': str, # [optional] # Connection parameters + 'dhchap-secret': str, # [optional] 'dhchap-ctrl-secret': str, # [optional] 'hdr-digest': str, # [optional] 'data-digest': str, # [optional] diff --git a/utils/nvmet/auth.conf b/utils/nvmet/auth.conf new file mode 100644 index 0000000..b4c23e6 --- /dev/null +++ b/utils/nvmet/auth.conf @@ -0,0 +1,34 @@ +# Config file format: Python, i.e. dict(), list(), int, str, etc... +# port ids (id) are integers 0...N +# namespaces are integers 0..N +# subsysnqn can be integers or strings +{ + 'ports': [ + { + 'id': 1, + #'adrfam': 'ipv6', + #'traddr': '::', + 'adrfam': 'ipv4', + 'traddr': '0.0.0.0', + 'trsvcid': 4420, + 'trtype': 'tcp', + } + ], + + 'subsystems': [ + { + 'subsysnqn': 'nqn.1988-11.com.dell:PowerSANxxx:01:20210225100113-454f73093ceb4847a7bdfc6e34ae8e28', + 'port': 1, + 'namespaces': [1], + 'allowed_hosts': [ + { + # Must match with the NQN and key configured on the host + # Key was generated with: + # nvme gen-dhchap-key ... + 'nqn': 'nqn.2014-08.org.nvmexpress:uuid:46ba5037-7ce5-41fa-9452-48477bf00080', + 'key': 'DHHC-1:00:2kx1hDTUPdvwtxHYUXFRl8pzn5hYZH7K3Z77IYM4hNN6/fQT:', + }, + ], + }, + ] +} diff --git a/utils/nvmet/nvmet.py b/utils/nvmet/nvmet.py index baf6560..9049d63 100755 --- a/utils/nvmet/nvmet.py +++ b/utils/nvmet/nvmet.py @@ -52,19 +52,26 @@ def _get_loaded_nvmet_modules(): return output -def _runcmd(cmd: list, quiet=False): +def _runcmd(cmd: list, quiet=False, capture_output=False): if not quiet: print(' '.join(cmd)) if args.dry_run: return - subprocess.run(cmd) + + try: + cp = subprocess.run(cmd, capture_output=capture_output, text=True) + except TypeError: + # For older Python versions that don't support "capture_output" or "text" + cp = subprocess.run(cmd, stdout=subprocess.PIPE, universal_newlines=True) + + return cp.stdout if capture_output else None def _modprobe(module: str, args: list = None, quiet=False): cmd = ['/usr/sbin/modprobe', module] if args: cmd.extend(args) - _runcmd(cmd, quiet) + _runcmd(cmd, quiet=quiet) def _mkdir(dname: str): @@ -93,12 +100,32 @@ def _symlink(port: str, subsysnqn: str): link.symlink_to(target) -def _create_subsystem(subsysnqn: str) -> str: +def _symlink_allowed_hosts(hostnqn: str, subsysnqn: str): + print( + f'$( cd "/sys/kernel/config/nvmet/subsystems/{subsysnqn}/allowed_hosts" && ln -s "../../../hosts/{hostnqn}" "{hostnqn}" )' + ) + if args.dry_run: + return + target = os.path.join('/sys/kernel/config/nvmet/hosts', hostnqn) + link = pathlib.Path(os.path.join('/sys/kernel/config/nvmet/subsystems', subsysnqn, 'allowed_hosts', hostnqn)) + link.symlink_to(target) + + +def _create_subsystem(subsysnqn: str, allowed_hosts: list) -> str: print(f'###{Fore.GREEN} Create subsystem: {subsysnqn}{Style.RESET_ALL}') dname = os.path.join('/sys/kernel/config/nvmet/subsystems/', subsysnqn) _mkdir(dname) - _echo(1, os.path.join(dname, 'attr_allow_any_host')) - return dname + _echo(0 if allowed_hosts else 1, os.path.join(dname, 'attr_allow_any_host')) + + # Configure all the hosts that are allowed to access this subsystem + for host in allowed_hosts: + hostnqn = host.get('nqn') + hostkey = host.get('key') + if all([hostnqn, hostkey]): + dname = os.path.join('/sys/kernel/config/nvmet/hosts/', hostnqn) + _mkdir(dname) + _echo(hostkey, os.path.join(dname, 'dhchap_key')) + _symlink_allowed_hosts(hostnqn, subsysnqn) def _create_namespace(subsysnqn: str, id: str, node: str) -> str: @@ -107,7 +134,6 @@ def _create_namespace(subsysnqn: str, id: str, node: str) -> str: _mkdir(dname) _echo(node, os.path.join(dname, 'device_path')) _echo(1, os.path.join(dname, 'enable')) - return dname def _args_valid(id, traddr, trsvcid, trtype, adrfam): @@ -215,8 +241,9 @@ def create(args): str(subsystem.get('port')), subsystem.get('namespaces'), ) + if None not in (subsysnqn, port, namespaces): - _create_subsystem(subsysnqn) + _create_subsystem(subsysnqn, subsystem.get('allowed_hosts', [])) for id in namespaces: _create_namespace(subsysnqn, str(id), dev_node) else: @@ -235,10 +262,16 @@ def clean(args): if not args.dry_run and os.geteuid() != 0: sys.exit(f'Permission denied. You need root privileges to run {os.path.basename(__file__)}.') + print(f'###{Fore.GREEN} 1st) Remove the symlinks{Style.RESET_ALL}') print('rm -f /sys/kernel/config/nvmet/ports/*/subsystems/*') for dname in pathlib.Path('/sys/kernel/config/nvmet/ports').glob('*/subsystems/*'): _runcmd(['rm', '-f', str(dname)], quiet=True) + print('rm -f /sys/kernel/config/nvmet/subsystems/*/allowed_hosts/*') + for dname in pathlib.Path('/sys/kernel/config/nvmet/subsystems').glob('*/allowed_hosts/*'): + _runcmd(['rm', '-f', str(dname)], quiet=True) + + print(f'###{Fore.GREEN} 2nd) Remove directories{Style.RESET_ALL}') print('rmdir /sys/kernel/config/nvmet/ports/*') for dname in pathlib.Path('/sys/kernel/config/nvmet/ports').glob('*'): _runcmd(['rmdir', str(dname)], quiet=True) @@ -251,6 +284,11 @@ def clean(args): for dname in pathlib.Path('/sys/kernel/config/nvmet/subsystems').glob('*'): _runcmd(['rmdir', str(dname)], quiet=True) + print('rmdir /sys/kernel/config/nvmet/hosts/*') + for dname in pathlib.Path('/sys/kernel/config/nvmet/hosts').glob('*'): + _runcmd(['rmdir', str(dname)], quiet=True) + + print(f'###{Fore.GREEN} 3rd) Unload kernel modules{Style.RESET_ALL}') for module in _get_loaded_nvmet_modules(): _modprobe(module, ['--remove'])