BIND 10 master, updated. 4246e4ed383f3178287eeac97807b52563592f2f [master] Update changelog for merge of 2713

BIND 10 source code commits bind10-changes at lists.isc.org
Thu Feb 28 14:56:13 UTC 2013


The branch, master has been updated
       via  4246e4ed383f3178287eeac97807b52563592f2f (commit)
       via  9925af3b3f4daa47ba8c2eb66f556b01ed6f0502 (commit)
       via  a09e9bd49e0dfa6f4eb9b50da0e1d8b7e76affa2 (commit)
       via  4162a63acb076e595b004e862b4f117a184156b1 (commit)
       via  e3fec3af0e4ce88e51f52d9dd3388a63e78e0b90 (commit)
       via  b63f18150df8c3449b7b6b52b3228863e6104474 (commit)
       via  ed259e05b0cef2364c20515f60bf464e5396b3d7 (commit)
       via  d3d28bdcd3444d16e1ccca67e5127fa732ad3b81 (commit)
       via  54cbca7f405db2241d18cd93c63624d573eefb4a (commit)
       via  5836dc544cdbe3180f8ce34788e2187824263ea8 (commit)
       via  7f486337fadd80b4529e927bed9ed809f8658cdf (commit)
       via  cdb634f118607f9730c7218cc15773fb35a7309a (commit)
       via  44fab7f71b9e197762f3ce95bd79dc70cf4f7a47 (commit)
       via  d5b8d3d8ddea4161a63f0653d1a56dffe2297bdc (commit)
       via  0da895c79de752795279c4babfb7578d409c6d72 (commit)
       via  cd98bdae788fd3871f568b99d9bda61b76879f7f (commit)
       via  01d2ac16e2edab2b81294cd8974938226dd76953 (commit)
      from  0e90f0055da9a5dad308d56934ccac5d9432f245 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 4246e4ed383f3178287eeac97807b52563592f2f
Author: Jelte Jansen <jelte at isc.org>
Date:   Thu Feb 28 15:48:49 2013 +0100

    [master] Update changelog for merge of 2713

commit 9925af3b3f4daa47ba8c2eb66f556b01ed6f0502
Merge: 0e90f00 a09e9bd
Author: Jelte Jansen <jelte at isc.org>
Date:   Thu Feb 28 15:45:51 2013 +0100

    [master] Merge branch 'trac2713'

-----------------------------------------------------------------------

Summary of changes:
 ChangeLog                                        |   11 +
 configure.ac                                     |    1 +
 src/bin/usermgr/Makefile.am                      |    9 +-
 src/bin/usermgr/b10-cmdctl-usermgr.py.in         |  297 +++++++++----
 src/bin/usermgr/b10-cmdctl-usermgr.xml           |   43 +-
 src/bin/usermgr/run_b10-cmdctl-usermgr.sh.in     |    3 +
 src/bin/usermgr/tests/Makefile.am                |   22 +
 src/bin/usermgr/tests/b10-cmdctl-usermgr_test.py |  483 ++++++++++++++++++++++
 src/lib/python/bind10_config.py.in               |    5 +
 9 files changed, 768 insertions(+), 106 deletions(-)
 mode change 100644 => 100755 src/bin/usermgr/b10-cmdctl-usermgr.py.in
 mode change 100644 => 100755 src/bin/usermgr/run_b10-cmdctl-usermgr.sh.in
 create mode 100644 src/bin/usermgr/tests/Makefile.am
 create mode 100644 src/bin/usermgr/tests/b10-cmdctl-usermgr_test.py

-----------------------------------------------------------------------
diff --git a/ChangeLog b/ChangeLog
index d7af227..2c1f6bc 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,14 @@
+583.	[func]*		jelte
+	b10-cmdctl-usermgr has been updated and its options and arguments
+	have changed; it now defaults to the same accounts file as
+	b10-cmdctl defaults to. It can now be used to remove users from the
+	accounts file as well, and it now accepts command-line arguments to
+	specify the username and password to add or remove, in which case
+	it will not prompt for them.
+	Note that using a password on the command line is not recommended,
+	as this can be viewed by other users.
+	(Trac #2713, git 9925af3b3f4daa47ba8c2eb66f556b01ed6f0502)
+
 582.	[func]		naokikambe
 	New statistics items related unixdomain sockets added into Xfrout :
 	open, openfail, close, bindfail, acceptfail, accept, senderr, and
diff --git a/configure.ac b/configure.ac
index f2bdf0b..956c387 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1204,6 +1204,7 @@ AC_CONFIG_FILES([Makefile
                  src/bin/stats/tests/Makefile
                  src/bin/stats/tests/testdata/Makefile
                  src/bin/usermgr/Makefile
+                 src/bin/usermgr/tests/Makefile
                  src/bin/tests/Makefile
                  src/lib/Makefile
                  src/lib/asiolink/Makefile
diff --git a/src/bin/usermgr/Makefile.am b/src/bin/usermgr/Makefile.am
index ce7977f..d356d20 100644
--- a/src/bin/usermgr/Makefile.am
+++ b/src/bin/usermgr/Makefile.am
@@ -1,8 +1,11 @@
+SUBDIRS = tests
+
 sbin_SCRIPTS = b10-cmdctl-usermgr
+noinst_SCRIPTS = run_b10-cmdctl-usermgr.sh
 
 b10_cmdctl_usermgrdir = $(pkgdatadir)
 
-CLEANFILES=	b10-cmdctl-usermgr
+CLEANFILES=	b10-cmdctl-usermgr b10-cmdctl-usermgr.pyc
 
 man_MANS = b10-cmdctl-usermgr.8
 DISTCLEANFILES = $(man_MANS)
@@ -25,3 +28,7 @@ endif
 b10-cmdctl-usermgr: b10-cmdctl-usermgr.py
 	$(SED) "s|@@PYTHONPATH@@|@pyexecdir@|" b10-cmdctl-usermgr.py >$@
 	chmod a+x $@
+
+CLEANDIRS = __pycache__
+clean-local:
+	rm -rf $(CLEANDIRS)
diff --git a/src/bin/usermgr/b10-cmdctl-usermgr.py.in b/src/bin/usermgr/b10-cmdctl-usermgr.py.in
old mode 100644
new mode 100755
index d62ad72..5fd033e
--- a/src/bin/usermgr/b10-cmdctl-usermgr.py.in
+++ b/src/bin/usermgr/b10-cmdctl-usermgr.py.in
@@ -11,115 +11,234 @@
 # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
 # INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
 # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
-# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
-# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
+# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN COMMAND OF CONTRACT,
+# NEGLIGENCE OR OTHER TORTIOUS COMMAND, ARISING OUT OF OR IN CONNECTION
 # WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 '''
-This file implements user management program. The user name and 
-its password is appended to csv file.
+This tool implements user management for b10-cmdctl. It is used to
+add and remove users from the accounts file.
 '''
+from bind10_config import SYSCONFPATH
+from collections import OrderedDict
 import random
 from hashlib import sha1
 import csv
 import getpass
-import getopt
+from optparse import OptionParser, OptionValueError
+import os
 import sys; sys.path.append ('@@PYTHONPATH@@')
 import isc.util.process
 
 isc.util.process.rename()
 
-VERSION_NUMBER = 'bind10'
-DEFAULT_FILE = 'cmdctl-accounts.csv'
-
-def gen_password_hash(password):
-    salt = "".join(chr(random.randint(33, 127)) for x in range(64))
-    saltedpwd = sha1((password + salt).encode()).hexdigest()
-    return salt, saltedpwd
-
-def username_exist(name, filename):
-    # The file may doesn't exist.
-    exist = False
-    csvfile = None
-    try:
-        csvfile = open(filename)        
-        reader = csv.reader(csvfile)
-        for row in reader:
-            if name == row[0]:
-                exist = True               
-                break
-    except Exception:
-        pass
-            
-    if csvfile:    
-        csvfile.close()
-    return exist
-
-def save_userinfo(username, pw, salt, filename):
-    csvfile = open(filename, 'a')
-    writer = csv.writer(csvfile)
-    writer.writerow([username, pw, salt])
-    csvfile.close()
-    print("\n create new account successfully! \n")
-
-def usage():
-    print('''Usage: usermgr [options]
-           -h, --help \t Show this help message and exit
-           -f, --file \t Specify the file to append user name and password
-           -v, --version\t Get version number
-           ''')           
+VERSION_STRING = "b10-cmdctl-usermgr @PACKAGE_VERSION@"
+DEFAULT_FILE = SYSCONFPATH + "/cmdctl-accounts.csv"
 
-def main():
-    filename = DEFAULT_FILE
-    try: 
-        opts, args = getopt.getopt(sys.argv[1:], 'f:hv', 
-                                   ['file=', 'help', 'version']) 
-    except getopt.GetoptError as err: 
-        print(err) 
-        usage() 
-        sys.exit(2)
-    for op, param in opts:        
-        if op in ('-h', '--help'): 
-            usage()
-            sys.exit()
-        elif op in ('-v', '--version'): 
-            print(VERSION_NUMBER)
-            sys.exit()                     
-        elif op in ('-f', "--file"):
-            filename = param
-        else: 
-            assert False, 'unknown option' 
-            usage()
-    
-    try:
-        while True :
-            name = input("Desired Login Name:")
-            if name == '':
-                print("error, user name can't be empty")
+# Actions that can be performed (used for argument parsing,
+# code paths, and output)
+COMMAND_ADD = "add"
+COMMAND_DELETE = "delete"
+
+# Non-zero return codes, used in tests
+BAD_ARGUMENTS = 1
+FILE_ERROR = 2
+USER_EXISTS = 3
+USER_DOES_NOT_EXIST = 4
+
+class UserManager:
+    def __init__(self, options, args):
+        self.options = options
+        self.args = args
+
+    def __print(self, msg):
+        if not self.options.quiet:
+            print(msg)
+
+    def __gen_password_hash(self, password):
+        salt = "".join(chr(random.randint(ord('!'), ord('~')))\
+                    for x in range(64))
+        saltedpwd = sha1((password + salt).encode()).hexdigest()
+        return salt, saltedpwd
+
+    def __read_user_info(self):
+        """
+        Read the existing user info
+        Raises an IOError if the file can't be read
+        """
+        # Currently, this is done quite naively (there is no
+        # check that the file is modified between read and write)
+        # But taking multiple simultaneous users of this tool on the
+        # same file seems unnecessary at this point.
+        self.user_info = OrderedDict()
+        if os.path.exists(self.options.output_file):
+            # Just let any file read error bubble up; it will
+            # be caught in the run() method
+            with open(self.options.output_file, newline='') as csvfile:
+                reader = csv.reader(csvfile)
+                for row in reader:
+                    self.user_info[row[0]] = row
+
+    def __save_user_info(self):
+        """
+        Write out the (modified) user info
+        Raises an IOError if the file can't be written
+        """
+        # Just let any file write error bubble up; it will
+        # be caught in the run() method
+        with open(self.options.output_file, 'w',
+                  newline='') as csvfile:
+            writer = csv.writer(csvfile)
+            for row in self.user_info.values():
+                writer.writerow(row)
+
+    def __add_user(self, name, password):
+        """
+        Add the given username/password combination to the stored user_info.
+        First checks if the username exists, and returns False if so.
+        If not, it is added, and this method returns True.
+        """
+        if name in self.user_info:
+            return False
+        salt, pw = self.__gen_password_hash(password)
+        self.user_info[name] = [name, pw, salt]
+        return True
+
+    def __delete_user(self, name):
+        """
+        Removes the row with the given name from the stored user_info
+        First checks if the username exists, and returns False if not.
+        Otherwise, it is removed, and this mehtod returns True
+        """
+        if name not in self.user_info:
+            return False
+        del self.user_info[name]
+        return True
+
+    # overridable input() call, used in testing
+    def _input(self, prompt):
+        return input(prompt)
+
+    # in essence this is private, but made 'protected' for ease
+    # of testing
+    def _prompt_for_username(self, command):
+        # Note, direct prints here are intentional
+        while True:
+            name = self._input("Username to " + command + ": ")
+            if name == "":
+                print("Error username can't be empty")
                 continue
 
-            if username_exist(name, filename):
-                 print("user name already exists!")
+            if command == COMMAND_ADD and name in self.user_info:
+                 print("user already exists")
+                 continue
+            elif command == COMMAND_DELETE and name not in self.user_info:
+                 print("user does not exist")
                  continue
 
-            while True:
-                pwd1 = getpass.getpass("Choose a password:")
-                pwd2 = getpass.getpass("Re-enter password:")
-                if pwd1 != pwd2:
-                    print("password is not same, please input again")
+            return name
+
+    # in essence this is private, but made 'protected' for ease
+    # of testing
+    def _prompt_for_password(self):
+        # Note, direct prints here are intentional
+        while True:
+            pwd1 = getpass.getpass("Choose a password: ")
+            if pwd1 == "":
+                print("Error: password cannot be empty")
+                continue
+            pwd2 = getpass.getpass("Re-enter password: ")
+            if pwd1 != pwd2:
+                print("passwords do not match, try again")
+                continue
+            return pwd1
+
+    def __verify_options_and_args(self):
+        """
+        Basic sanity checks on command line arguments.
+        Returns False if there is a problem, True if everything seems OK.
+        """
+        if len(self.args) < 1:
+            self.__print("Error: no command specified")
+            return False
+        if len(self.args) > 3:
+            self.__print("Error: extraneous arguments")
+            return False
+        if self.args[0] not in [ COMMAND_ADD, COMMAND_DELETE ]:
+            self.__print("Error: command must be either add or delete")
+            return False
+        if self.args[0] == COMMAND_DELETE and len(self.args) > 2:
+            self.__print("Error: delete only needs username, not a password")
+            return False
+        return True
+
+    def run(self):
+        if not self.__verify_options_and_args():
+            return BAD_ARGUMENTS
+
+        try:
+            self.__print("Using accounts file: " + self.options.output_file)
+            self.__read_user_info()
+
+            command = self.args[0]
+
+            if len(self.args) > 1:
+                username = self.args[1]
+            else:
+                username = self._prompt_for_username(command)
+
+            if command == COMMAND_ADD:
+                if len(self.args) > 2:
+                    password = self.args[2]
                 else:
-                    break;
-            
-            salt, pw = gen_password_hash(pwd1)
-            save_userinfo(name, pw, salt, filename)
-            inputdata = input('continue to create new account by input \'y\' or \'Y\':')
-            if inputdata not in ['y', 'Y']:
-                break                
-                
-    except KeyboardInterrupt:
-        pass                
+                    password = self._prompt_for_password()
+                if not self.__add_user(username, password):
+                    print("Error: username exists")
+                    return USER_EXISTS
+            elif command == COMMAND_DELETE:
+                if not self.__delete_user(username):
+                    print("Error: username does not exist")
+                    return USER_DOES_NOT_EXIST
+
+            self.__save_user_info()
+            return 0
+        except IOError as ioe:
+            self.__print("Error accessing " + ioe.filename +\
+                         ": " + str(ioe.strerror))
+            return FILE_ERROR
+        except csv.Error as csve:
+            self.__print("Error parsing csv file: " + str(csve))
+            return FILE_ERROR
+
+def set_options(parser):
+    parser.add_option("-f", "--file",
+                      dest="output_file", default=DEFAULT_FILE,
+                      help="Accounts file to modify"
+                     )
+    parser.add_option("-q", "--quiet",
+                      dest="quiet", action="store_true", default=False,
+                      help="Quiet mode, don't print any output"
+                     )
+
+def main():
+    usage = "usage: %prog [options] <command> [username] [password]\n\n"\
+            "Arguments:\n"\
+            "  command\t\teither 'add' or 'delete'\n"\
+            "  username\t\tthe username to add or delete\n"\
+            "  password\t\tthe password to set for the added user\n"\
+            "\n"\
+            "If username or password are not specified, %prog will\n"\
+            "prompt for them. It is recommended practice to let the\n"\
+            "tool prompt for the password, as command-line\n"\
+            "arguments can be visible through history or process\n"\
+            "viewers."
+    parser = OptionParser(usage=usage, version=VERSION_STRING)
+    set_options(parser)
+    (options, args) = parser.parse_args()
 
+    usermgr = UserManager(options, args)
+    return usermgr.run()
 
 if __name__ == '__main__':
-    main()
+    sys.exit(main())
 
diff --git a/src/bin/usermgr/b10-cmdctl-usermgr.xml b/src/bin/usermgr/b10-cmdctl-usermgr.xml
index 529a8db..26940d7 100644
--- a/src/bin/usermgr/b10-cmdctl-usermgr.xml
+++ b/src/bin/usermgr/b10-cmdctl-usermgr.xml
@@ -12,8 +12,8 @@
  - REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  - AND FITNESS.  IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
  - INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
- - LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
- - OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+ - LOSS OF USE, DATA OR PROFITS, WHETHER IN AN COMMAND OF CONTRACT, NEGLIGENCE
+ - OR OTHER TORTIOUS COMMAND, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  - PERFORMANCE OF THIS SOFTWARE.
 -->
 
@@ -47,10 +47,12 @@
 
       <arg><option>-f <replaceable>filename</replaceable></option></arg>
       <arg><option>-h</option></arg>
-      <arg><option>-v</option></arg>
-      <arg><option>--file <replaceable>filename</replaceable></option></arg>
+      <arg><option>--file=<replaceable>filename</replaceable></option></arg>
       <arg><option>--help</option></arg>
       <arg><option>--version</option></arg>
+      <arg choice="plain"><replaceable>command</replaceable></arg>
+      <arg><option>username</option></arg>
+      <arg><option>password</option></arg>
 
     </cmdsynopsis>
   </refsynopsisdiv>
@@ -58,24 +60,22 @@
   <refsect1>
     <title>DESCRIPTION</title>
     <para>The <command>b10-cmdctl-usermgr</command> tool may be used
-      to add accounts with passwords for the
+      to add and remove accounts with passwords for the
       <citerefentry><refentrytitle>b10-cmdctl</refentrytitle><manvolnum>8</manvolnum></citerefentry>
       daemon.
     </para>
 
     <para>
       By default, the accounts are saved in the
-      <filename>cmdctl-accounts.csv</filename> file in the current directory,
-      unless the <option>--filename</option> switch is used.
-      The entry is appended to the file.
-<!-- TODO: default should have full path? -->
+      <filename>cmdctl-accounts.csv</filename> file in the system config
+      directory, unless the <option>--filename</option> switch is used.
+      The entry is appended to or removed from the file.
     </para>
 
     <para>
-      The tool can't remove or replace existing entries.
+      The tool can't replace existing entries, but this can easily be
+      accomplished by removing the entry and adding a new one.
     </para>
-<!-- TODO: the tool can't remove or replace existing entries -->
-
   </refsect1>
 
   <refsect1>
@@ -83,6 +83,18 @@
 
     <para>The arguments are as follows:</para>
 
+    <para>
+      command is either 'add' or 'delete', respectively to add or delete users.
+    </para>
+
+    <para>
+      If a username and password are given (or just a username in case of
+      deletion), these are used. Otherwise, the tool shall prompt for a
+      username and/or password. It is recommended practice to let the
+      tool prompt for the password, as command-line arguments can be
+      visible through history or process viewers.
+    </para>
+
     <variablelist>
 
       <varlistentry>
@@ -97,14 +109,13 @@
         <term><option>-f <replaceable>filename</replaceable></option></term>
         <term><option>--file <replaceable>filename</replaceable></option></term>
         <listitem><para>
-          Define the filename to append the account to. The default
-          is <filename>cmdctl-accounts.csv</filename> in the current directory.
-<!-- TODO: default should have full path? -->
+          Specify the accounts file to update. The default is
+          <filename>cmdctl-accounts.csv</filename> in the system config
+          directory.
         </para></listitem>
       </varlistentry>
 
       <varlistentry>
-        <term><option>-v</option></term>
         <term><option>--version</option></term>
         <listitem><para>
           Report the version and exit.
diff --git a/src/bin/usermgr/run_b10-cmdctl-usermgr.sh.in b/src/bin/usermgr/run_b10-cmdctl-usermgr.sh.in
old mode 100644
new mode 100755
index 5989205..8e8e743
--- a/src/bin/usermgr/run_b10-cmdctl-usermgr.sh.in
+++ b/src/bin/usermgr/run_b10-cmdctl-usermgr.sh.in
@@ -18,6 +18,9 @@
 PYTHON_EXEC=${PYTHON_EXEC:- at PYTHON@}
 export PYTHON_EXEC
 
+PYTHONPATH=@abs_top_builddir@/src/lib/python
+export PYTHONPATH
+
 MYPATH_PATH=@abs_top_builddir@/src/bin/usermgr
 
 BIND10_MSGQ_SOCKET_FILE=@abs_top_builddir@/msgq_socket
diff --git a/src/bin/usermgr/tests/Makefile.am b/src/bin/usermgr/tests/Makefile.am
new file mode 100644
index 0000000..230f164
--- /dev/null
+++ b/src/bin/usermgr/tests/Makefile.am
@@ -0,0 +1,22 @@
+PYCOVERAGE_RUN=@PYCOVERAGE_RUN@
+PYTESTS = b10-cmdctl-usermgr_test.py
+EXTRA_DIST = $(PYTESTS)
+
+CLEANFILES = *.csv
+
+# test using command-line arguments, so use check-local target instead of TESTS
+check-local:
+if ENABLE_PYTHON_COVERAGE
+	touch $(abs_top_srcdir)/.coverage
+	rm -f .coverage
+	${LN_S} $(abs_top_srcdir)/.coverage .coverage
+endif
+	for pytest in $(PYTESTS) ; do \
+	echo Running test: $$pytest ; \
+	$(LIBRARY_PATH_PLACEHOLDER) \
+	PYTHONPATH=$(COMMON_PYTHON_PATH):$(abs_top_builddir)/src/bin/cmdctl \
+	CMDCTL_BUILD_PATH=$(abs_top_builddir)/src/bin/cmdctl \
+	CMDCTL_SRC_PATH=$(abs_top_srcdir)/src/bin/cmdctl \
+	B10_LOCKFILE_DIR_FROM_BUILD=$(abs_top_builddir) \
+	$(PYCOVERAGE_RUN) $(abs_srcdir)/$$pytest || exit ; \
+	done
diff --git a/src/bin/usermgr/tests/b10-cmdctl-usermgr_test.py b/src/bin/usermgr/tests/b10-cmdctl-usermgr_test.py
new file mode 100644
index 0000000..3d0d4af
--- /dev/null
+++ b/src/bin/usermgr/tests/b10-cmdctl-usermgr_test.py
@@ -0,0 +1,483 @@
+# Copyright (C) 2013  Internet Systems Consortium.
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
+# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
+# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
+# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
+# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN COMMAND OF CONTRACT,
+# NEGLIGENCE OR OTHER TORTIOUS COMMAND, ARISING OUT OF OR IN CONNECTION
+# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import csv
+from hashlib import sha1
+import getpass
+import imp
+import os
+import subprocess
+import stat
+import sys
+import unittest
+from bind10_config import SYSCONFPATH
+
+class PrintCatcher:
+    def __init__(self):
+        self.stdout_lines = []
+
+    def __enter__(self):
+        self.__orig_stdout_write = sys.stdout.write
+        def new_write(line):
+            self.stdout_lines.append(line)
+
+        sys.stdout.write = new_write
+        return self
+
+    def __exit__(self, type, value, traceback):
+        sys.stdout.write = self.__orig_stdout_write
+
+class OverrideGetpass:
+    def __init__(self, new_getpass):
+        self.__new_getpass = new_getpass
+        self.__orig_getpass = getpass.getpass
+
+    def __enter__(self):
+        getpass.getpass = self.__new_getpass
+        return self
+
+    def __exit__(self, type, value, traceback):
+        getpass.getpass = self.__orig_getpass
+
+# input() is a built-in function and not easily overridable
+# so this one uses usermgr for that
+class OverrideInput:
+    def __init__(self, usermgr, new_getpass):
+        self.__usermgr = usermgr
+        self.__new_input = new_getpass
+        self.__orig_input = usermgr._input
+
+    def __enter__(self):
+        self.__usermgr._input = self.__new_input
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.__usermgr._input = self.__orig_input
+
+def run(command):
+    """
+    Small helper function that returns a tuple of (rcode, stdout, stderr)
+    after running the given command (an array of command and arguments, as
+    passed on to subprocess).
+    Parameters:
+    command: an array of command and argument strings, which will be
+             passed to subprocess.Popen()
+    """
+    subp = subprocess.Popen(command, stdout=subprocess.PIPE,
+                            stderr=subprocess.PIPE)
+    (stdout, stderr) = subp.communicate()
+    return (subp.returncode, stdout, stderr)
+
+class TestUserMgr(unittest.TestCase):
+    TOOL = '../b10-cmdctl-usermgr'
+    OUTPUT_FILE = 'test_users.csv'
+
+    def setUp(self):
+        self.delete_output_file()
+        # For access to the actual module, we load it directly
+        self.usermgr_module = imp.load_source('usermgr',
+                                              '../b10-cmdctl-usermgr.py')
+        # And instantiate 1 instance (with fake options/args)
+        self.usermgr = self.usermgr_module.UserManager(object(), object())
+
+    def tearDown(self):
+        self.delete_output_file()
+
+    def delete_output_file(self):
+        if os.path.exists(self.OUTPUT_FILE):
+            os.remove(self.OUTPUT_FILE)
+
+    def check_output_file(self, expected_content):
+        self.assertTrue(os.path.exists(self.OUTPUT_FILE))
+
+        csv_entries = []
+        with open(self.OUTPUT_FILE, newline='') as csvfile:
+            reader = csv.reader(csvfile)
+            csv_entries = [row for row in reader]
+
+        self.assertEqual(len(expected_content), len(csv_entries))
+        csv_entries.reverse()
+        for expected_entry in expected_content:
+            expected_name = expected_entry[0]
+            expected_pass = expected_entry[1]
+
+            csv_entry = csv_entries.pop()
+            entry_name = csv_entry[0]
+            entry_salt = csv_entry[2]
+            entry_hash = csv_entry[1]
+
+            self.assertEqual(expected_name, entry_name)
+            expected_hash =\
+                sha1((expected_pass + entry_salt).encode()).hexdigest()
+            self.assertEqual(expected_hash, entry_hash)
+
+    def run_check(self, expected_returncode, expected_stdout, expected_stderr,
+                  command):
+        """
+        Runs the given command, and checks return code, and outputs (if provided).
+        Arguments:
+        expected_returncode, return code of the command
+        expected_stdout, (multiline) string that is checked against stdout.
+                         May be None, in which case the check is skipped.
+        expected_stderr, (multiline) string that is checked against stderr.
+                         May be None, in which case the check is skipped.
+        """
+        (returncode, stdout, stderr) = run(command)
+        if expected_stderr is not None:
+            self.assertEqual(expected_stderr, stderr.decode())
+        if expected_stdout is not None:
+            self.assertEqual(expected_stdout, stdout.decode())
+        self.assertEqual(expected_returncode, returncode, " ".join(command))
+
+    def test_help(self):
+        self.run_check(0,
+'''Usage: b10-cmdctl-usermgr [options] <command> [username] [password]
+
+Arguments:
+  command		either 'add' or 'delete'
+  username		the username to add or delete
+  password		the password to set for the added user
+
+If username or password are not specified, b10-cmdctl-usermgr will
+prompt for them. It is recommended practice to let the
+tool prompt for the password, as command-line
+arguments can be visible through history or process
+viewers.
+
+Options:
+  --version             show program's version number and exit
+  -h, --help            show this help message and exit
+  -f OUTPUT_FILE, --file=OUTPUT_FILE
+                        Accounts file to modify
+  -q, --quiet           Quiet mode, don't print any output
+''',
+                       '',
+                       [self.TOOL, '-h'])
+
+    def test_add_delete_users_ok(self):
+        """
+        Test that a file is created, and users are added.
+        Also tests quiet mode for adding a user to an existing file.
+        """
+        # content is a list of (user, pass) tuples
+        expected_content = []
+
+        # Creating a file
+        self.run_check(0,
+                       'Using accounts file: test_users.csv\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user1', 'pass1'
+                       ])
+        expected_content.append(('user1', 'pass1'))
+        self.check_output_file(expected_content)
+
+        # Add to existing file
+        self.run_check(0,
+                       'Using accounts file: test_users.csv\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user2', 'pass2'
+                       ])
+        expected_content.append(('user2', 'pass2'))
+        self.check_output_file(expected_content)
+
+        # Quiet mode
+        self.run_check(0,
+                       '',
+                       '',
+                       [ self.TOOL, '-q',
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user3', 'pass3'
+                       ])
+        expected_content.append(('user3', 'pass3'))
+        self.check_output_file(expected_content)
+
+        # Delete a user (let's pick the middle one)
+        self.run_check(0,
+                       '',
+                       '',
+                       [ self.TOOL, '-q',
+                         '-f', self.OUTPUT_FILE,
+                         'delete', 'user2'
+                       ])
+        del expected_content[1]
+        self.check_output_file(expected_content)
+
+    def test_add_delete_users_bad(self):
+        """
+        More add/delete tests, this time for some error scenarios
+        """
+        # content is a list of (user, pass) tuples
+        expected_content = []
+        # First add one
+        self.run_check(0, None, None,
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user', 'pass'
+                       ])
+        expected_content.append(('user', 'pass'))
+        self.check_output_file(expected_content)
+
+        # Adding it again should error
+        self.run_check(3,
+                       'Using accounts file: test_users.csv\n'
+                       'Error: username exists\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user', 'pass'
+                       ])
+        self.check_output_file(expected_content)
+
+        # Deleting a non-existent one should fail too
+        self.run_check(4,
+                       'Using accounts file: test_users.csv\n'
+                       'Error: username does not exist\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'delete', 'nosuchuser'
+                       ])
+        self.check_output_file(expected_content)
+
+    def test_bad_arguments(self):
+        """
+        Assorted tests with bad command-line arguments
+        """
+        self.run_check(1,
+                       'Error: no command specified\n',
+                       '',
+                       [ self.TOOL ])
+        self.run_check(1,
+                       'Error: command must be either add or delete\n',
+                       '',
+                       [ self.TOOL, 'foo' ])
+        self.run_check(1,
+                       'Error: extraneous arguments\n',
+                       '',
+                       [ self.TOOL, 'add', 'user', 'pass', 'toomuch' ])
+        self.run_check(1,
+                       'Error: delete only needs username, not a password\n',
+                       '',
+                       [ self.TOOL, 'delete', 'user', 'pass' ])
+
+    def test_default_file(self):
+        """
+        Check the default file is the correct one.
+        """
+        # Hardcoded path .. should be ok since this is run from make check
+        self.assertEqual(SYSCONFPATH + '/cmdctl-accounts.csv',
+                         self.usermgr_module.DEFAULT_FILE)
+
+    def test_prompt_for_password_different(self):
+        """
+        Check that the method that prompts for a password verifies that
+        the same value is entered twice
+        """
+        # returns a different string (the representation of the number
+        # of times it has been called), until it has been called
+        # over 10 times, in which case it will always return "11"
+        getpass_different_called = 0
+        def getpass_different(question):
+            nonlocal getpass_different_called
+            getpass_different_called += 1
+            if getpass_different_called > 10:
+                return "11"
+            else:
+                return str(getpass_different_called)
+
+        with PrintCatcher() as pc:
+            with OverrideGetpass(getpass_different):
+                pwd = self.usermgr._prompt_for_password()
+                self.assertEqual(12, getpass_different_called)
+                self.assertEqual("11", pwd)
+                # stdout should be 5 times the no match string;
+                expected_output = "passwords do not match, try again\n"*5
+                self.assertEqual(expected_output, ''.join(pc.stdout_lines))
+
+    def test_prompt_for_password_empty(self):
+        """
+        Check that the method that prompts for a password verifies that
+        the value entered is not empty
+        """
+        # returns an empty string until it has been called over 10
+        # times
+        getpass_empty_called = 0
+        def getpass_empty(prompt):
+            nonlocal getpass_empty_called
+            getpass_empty_called += 1
+            if getpass_empty_called > 10:
+                return "nonempty"
+            else:
+                return ""
+
+        with PrintCatcher() as pc:
+            with OverrideGetpass(getpass_empty):
+                pwd = self.usermgr._prompt_for_password()
+                self.assertEqual("nonempty", pwd)
+                self.assertEqual(12, getpass_empty_called)
+                # stdout should be 10 times the 'cannot be empty' string
+                expected_output = "Error: password cannot be empty\n"*10
+                self.assertEqual(expected_output, ''.join(pc.stdout_lines))
+
+    def test_prompt_for_user(self):
+        """
+        Test that the method that prompts for a username verifies that
+        is not empty, and that it exists (or does not, depending on the
+        action that is specified)
+        """
+        new_input_called = 0
+        input_results = [ '', '', 'existinguser', 'nonexistinguser',
+                          '', '', 'nonexistinguser', 'existinguser' ]
+        def new_input(prompt):
+            nonlocal new_input_called
+
+            if new_input_called < len(input_results):
+                result = input_results[new_input_called]
+            else:
+                result = 'empty'
+            new_input_called += 1
+            return result
+
+        # add fake user (value doesn't matter, method only checks for key)
+        self.usermgr.user_info = { 'existinguser': None }
+
+        expected_output = ''
+
+        with PrintCatcher() as pc:
+            with OverrideInput(self.usermgr, new_input):
+                # should skip the first three since empty or existing
+                # are not allowed, then return 'nonexistinguser'
+                username = self.usermgr._prompt_for_username(
+                                self.usermgr_module.COMMAND_ADD)
+                self.assertEqual('nonexistinguser', username)
+                expected_output += "Error username can't be empty\n"*2
+                expected_output += "user already exists\n"
+                self.assertEqual(expected_output, ''.join(pc.stdout_lines))
+
+                # For delete, should again not accept empty (in a while true
+                # loop), and this time should not accept nonexisting users
+                username = self.usermgr._prompt_for_username(
+                                self.usermgr_module.COMMAND_DELETE)
+                self.assertEqual('existinguser', username)
+                expected_output += "Error username can't be empty\n"*2
+                expected_output += "user does not exist\n"
+                self.assertEqual(expected_output, ''.join(pc.stdout_lines))
+
+    def test_bad_file(self):
+        """
+        Check for graceful handling of bad file argument
+        """
+        self.run_check(2,
+                       'Using accounts file: /\n'
+                       'Error accessing /: Is a directory\n',
+                       '',
+                       [ self.TOOL, '-f', '/', 'add', 'user', 'pass' ])
+
+        # Make sure we can initially write to the test file
+        self.run_check(0, None, None,
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user1', 'pass1'
+                       ])
+
+        # Make it non-writable (don't worry about cleanup, the
+        # file should be deleted after each test anyway
+        os.chmod(self.OUTPUT_FILE, stat.S_IRUSR)
+        self.run_check(2,
+                       'Using accounts file: test_users.csv\n'
+                       'Error accessing test_users.csv: Permission denied\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user2', 'pass1'
+                       ])
+
+        self.run_check(2,
+                       'Using accounts file: test_users.csv\n'
+                       'Error accessing test_users.csv: Permission denied\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'delete', 'user1'
+                       ])
+
+        # Making it write-only should have the same effect
+        os.chmod(self.OUTPUT_FILE, stat.S_IWUSR)
+        self.run_check(2,
+                       'Using accounts file: test_users.csv\n'
+                       'Error accessing test_users.csv: Permission denied\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user2', 'pass1'
+                       ])
+
+        self.run_check(2,
+                       'Using accounts file: test_users.csv\n'
+                       'Error accessing test_users.csv: Permission denied\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'delete', 'user1'
+                       ])
+
+    def test_missing_fields(self):
+        """
+        Test that an invalid csv file is handled gracefully
+        """
+        # Valid but incomplete csv; should be handled
+        # correctly
+        with open(self.OUTPUT_FILE, 'w', newline='') as f:
+            f.write('onlyuserfield\n')
+            f.write('userfield,saltfield\n')
+            f.write(',emptyuserfield,passwordfield\n')
+
+        self.run_check(0, None, None,
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user1', 'pass1'
+                       ])
+        self.run_check(0, None, None,
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'delete', 'onlyuserfield'
+                       ])
+        self.run_check(0, None, None,
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'delete', ''
+                       ])
+
+    def test_bad_data(self):
+        # I can only think of one invalid format, an unclosed string
+        with open(self.OUTPUT_FILE, 'w', newline='') as f:
+            f.write('a,"\n')
+        self.run_check(2,
+                       'Using accounts file: test_users.csv\n'
+                       'Error parsing csv file: newline inside string\n',
+                       '',
+                       [ self.TOOL,
+                         '-f', self.OUTPUT_FILE,
+                         'add', 'user1', 'pass1'
+                       ])
+
+
+if __name__== '__main__':
+    unittest.main()
+
diff --git a/src/lib/python/bind10_config.py.in b/src/lib/python/bind10_config.py.in
index 9cd8d66..7ef660b 100644
--- a/src/lib/python/bind10_config.py.in
+++ b/src/lib/python/bind10_config.py.in
@@ -24,11 +24,13 @@ def reload():
     global PLUGIN_PATHS
     global PREFIX
     global LIBEXECPATH
+    global SYSCONFPATH
     BIND10_MSGQ_SOCKET_FILE = os.path.join("@localstatedir@",
                                            "@PACKAGE_NAME@",
                                            "msgq_socket").replace("${prefix}",
                                                                   "@prefix@")
     PREFIX = "@prefix@"
+    SYSCONFPATH="@sysconfdir@/@PACKAGE@".replace('${prefix}', PREFIX)
 
     # B10_FROM_SOURCE is set in the environment for internal tests and
     # an experimental run without installagion.  In that case we need to
@@ -49,6 +51,9 @@ def reload():
     #  When "FROM_SOURCE", it lists the directories where the programs are
     #  built so that when BIND 10 is experimentally started on the source
     #  tree the programs in the tree (not installed ones) will be used.
+    # SYSCONFPATH: Path where the system-wide configuration files are
+    # stored (e.g. <prefix>/var/<package name>). This value is *not*
+    # overwritten if B10_FROM_SOURCE is specified.
     #
     # B10_FROM_SOURCE_LOCALSTATEDIR is specifically intended to be used for
     # tests where we want to use various types of configuration within the test



More information about the bind10-changes mailing list