]> git.ipfire.org Git - thirdparty/samba.git/commitdiff
netcmd: user: readpasswords: move syncpasswords command to readpasswords
authorRob van der Linde <rob@catalyst.net.nz>
Tue, 1 Aug 2023 01:28:33 +0000 (13:28 +1200)
committerAndrew Bartlett <abartlet@samba.org>
Fri, 4 Aug 2023 05:27:53 +0000 (05:27 +0000)
Signed-off-by: Rob van der Linde <rob@catalyst.net.nz>
Reviewed-by: Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet@samba.org>
Autobuild-User(master): Andrew Bartlett <abartlet@samba.org>
Autobuild-Date(master): Fri Aug  4 05:27:53 UTC 2023 on atb-devel-224

python/samba/netcmd/user/readpasswords/__init__.py
python/samba/netcmd/user/readpasswords/syncpasswords.py [new file with mode: 0644]

index c282d351aa8332224717c0eb462e0b3f9cd2c444..8ca999b02159198d45519143ddb5bfaf27c72be6 100644 (file)
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 
-import base64
-import errno
-import fcntl
-import os
-import signal
-import time
-from subprocess import Popen, PIPE, STDOUT
-
-import ldb
-import samba.getopt as options
-from samba import Ldb, dsdb
-from samba.dcerpc import misc, security
-from samba.ndr import ndr_unpack
-from samba.common import get_bytes
-from samba.netcmd import CommandError, Option
-
-from .common import (
-    GetPasswordCommand,
-    gpg_decrypt,
-    decrypt_samba_gpg_help,
-    virtual_attributes_help
-)
 from .getpassword import cmd_user_getpassword
 from .show import cmd_user_show
-
-
-class cmd_user_syncpasswords(GetPasswordCommand):
-    """Sync the password of user accounts.
-
-This syncs logon passwords for user accounts.
-
-Note that this command should run on a single domain controller only
-(typically the PDC-emulator). However the "password hash gpg key ids"
-option should to be configured on all domain controllers.
-
-The command must be run from the root user id or another authorized user id.
-The '-H' or '--URL' option only supports ldapi:// and can be used to adjust the
-local path.  By default, ldapi:// is used with the default path to the
-privileged ldapi socket.
-
-This command has three modes: "Cache Initialization", "Sync Loop Run" and
-"Sync Loop Terminate".
-
-
-Cache Initialization
-====================
-
-The first time, this command needs to be called with
-'--cache-ldb-initialize' in order to initialize its cache.
-
-The cache initialization requires '--attributes' and allows the following
-optional options: '--decrypt-samba-gpg', '--script', '--filter' or
-'-H/--URL'.
-
-The '--attributes' parameter takes a comma separated list of attributes,
-which will be printed or given to the script specified by '--script'. If a
-specified attribute is not available on an object it will be silently omitted.
-All attributes defined in the schema (e.g. the unicodePwd attribute holds
-the NTHASH) and the following virtual attributes are possible (see '--help'
-for supported virtual attributes in your environment):
-
-   virtualClearTextUTF16: The raw cleartext as stored in the
-                          'Primary:CLEARTEXT' (or 'Primary:SambaGPG'
-                          with '--decrypt-samba-gpg') buffer inside of the
-                          supplementalCredentials attribute. This typically
-                          contains valid UTF-16-LE, but may contain random
-                          bytes, e.g. for computer accounts.
-
-   virtualClearTextUTF8:  As virtualClearTextUTF16, but converted to UTF-8
-                          (only from valid UTF-16-LE).
-
-   virtualSSHA:           As virtualClearTextUTF8, but a salted SHA-1
-                          checksum, useful for OpenLDAP's '{SSHA}' algorithm.
-
-   virtualCryptSHA256:    As virtualClearTextUTF8, but a salted SHA256
-                          checksum, useful for OpenLDAP's '{CRYPT}' algorithm,
-                          with a $5$... salt, see crypt(3) on modern systems.
-                          The number of rounds used to calculate the hash can
-                          also be specified. By appending ";rounds=x" to the
-                          attribute name i.e. virtualCryptSHA256;rounds=10000
-                          will calculate a SHA256 hash with 10,000 rounds.
-                          Non numeric values for rounds are silently ignored.
-                          The value is calculated as follows:
-                          1) If a value exists in 'Primary:userPassword' with
-                             the specified number of rounds it is returned.
-                          2) If 'Primary:CLEARTEXT', or 'Primary:SambaGPG' with
-                             '--decrypt-samba-gpg'. Calculate a hash with
-                             the specified number of rounds
-                          3) Return the first CryptSHA256 value in
-                             'Primary:userPassword'.
-
-   virtualCryptSHA512:    As virtualClearTextUTF8, but a salted SHA512
-                          checksum, useful for OpenLDAP's '{CRYPT}' algorithm,
-                          with a $6$... salt, see crypt(3) on modern systems.
-                          The number of rounds used to calculate the hash can
-                          also be specified. By appending ";rounds=x" to the
-                          attribute name i.e. virtualCryptSHA512;rounds=10000
-                          will calculate a SHA512 hash with 10,000 rounds.
-                          Non numeric values for rounds are silently ignored.
-                          The value is calculated as follows:
-                          1) If a value exists in 'Primary:userPassword' with
-                             the specified number of rounds it is returned.
-                          2) If 'Primary:CLEARTEXT', or 'Primary:SambaGPG' with
-                             '--decrypt-samba-gpg'. Calculate a hash with
-                             the specified number of rounds.
-                          3) Return the first CryptSHA512 value in
-                             'Primary:userPassword'.
-
-   virtualWDigestNN:      The individual hash values stored in
-                          'Primary:WDigest' where NN is the hash number in
-                          the range 01 to 29.
-                          NOTE: As at 22-05-2017 the documentation:
-                          3.1.1.8.11.3.1 WDIGEST_CREDENTIALS Construction
-                        https://msdn.microsoft.com/en-us/library/cc245680.aspx
-                          is incorrect.
-
-   virtualKerberosSalt:   This results the salt string that is used to compute
-                          Kerberos keys from a UTF-8 cleartext password.
-
-   virtualSambaGPG:       The raw cleartext as stored in the
-                          'Primary:SambaGPG' buffer inside of the
-                          supplementalCredentials attribute.
-                          See the 'password hash gpg key ids' option in
-                          smb.conf.
-
-The '--decrypt-samba-gpg' option triggers decryption of the
-Primary:SambaGPG buffer. Check with '--help' if this feature is available
-in your environment or not (the python-gpgme package is required).  Please
-note that you might need to set the GNUPGHOME environment variable.  If the
-decryption key has a passphrase you have to make sure that the GPG_AGENT_INFO
-environment variable has been set correctly and the passphrase is already
-known by the gpg-agent.
-
-The '--script' option specifies a custom script that is called whenever any
-of the dirsyncAttributes (see below) was changed. The script is called
-without any arguments. It gets the LDIF for exactly one object on STDIN.
-If the script processed the object successfully it has to respond with a
-single line starting with 'DONE-EXIT: ' followed by an optional message.
-
-Note that the script might be called without any password change, e.g. if
-the account was disabled (a userAccountControl change) or the
-sAMAccountName was changed. The objectGUID,isDeleted,isRecycled attributes
-are always returned as unique identifier of the account. It might be useful
-to also ask for non-password attributes like: objectSid, sAMAccountName,
-userPrincipalName, userAccountControl, pwdLastSet and msDS-KeyVersionNumber.
-Depending on the object, some attributes may not be present/available,
-but you always get the current state (and not a diff).
-
-If no '--script' option is specified, the LDIF will be printed on STDOUT or
-into the logfile.
-
-The default filter for the LDAP_SERVER_DIRSYNC_OID search is:
-(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=512)\\
-    (!(sAMAccountName=krbtgt*)))
-This means only normal (non-krbtgt) user
-accounts are monitored.  The '--filter' can modify that, e.g. if it's
-required to also sync computer accounts.
-
-
-Sync Loop Run
-=============
-
-This (default) mode runs in an endless loop waiting for password related
-changes in the active directory database. It makes use of the
-LDAP_SERVER_DIRSYNC_OID and LDAP_SERVER_NOTIFICATION_OID controls in order
-get changes in a reliable fashion. Objects are monitored for changes of the
-following dirsyncAttributes:
-
-  unicodePwd, dBCSPwd, supplementalCredentials, pwdLastSet, sAMAccountName,
-  userPrincipalName and userAccountControl.
-
-It recovers from LDAP disconnects and updates the cache in conservative way
-(in single steps after each successfully processed change).  An error from
-the script (specified by '--script') will result in fatal error and this
-command will exit.  But the cache state should be still valid and can be
-resumed in the next "Sync Loop Run".
-
-The '--logfile' option specifies an optional (required if '--daemon' is
-specified) logfile that takes all output of the command. The logfile is
-automatically reopened if fstat returns st_nlink == 0.
-
-The optional '--daemon' option will put the command into the background.
-
-You can stop the command without the '--daemon' option, also by hitting
-strg+c.
-
-If you specify the '--no-wait' option the command skips the
-LDAP_SERVER_NOTIFICATION_OID 'waiting' step and exit once
-all LDAP_SERVER_DIRSYNC_OID changes are consumed.
-
-Sync Loop Terminate
-===================
-
-In order to terminate an already running command (likely as daemon) the
-'--terminate' option can be used. This also requires the '--logfile' option
-to be specified.
-
-
-Example1:
-samba-tool user syncpasswords --cache-ldb-initialize \\
-    --attributes=virtualClearTextUTF8
-samba-tool user syncpasswords
-
-Example2:
-samba-tool user syncpasswords --cache-ldb-initialize \\
-    --attributes=objectGUID,objectSID,sAMAccountName,\\
-    userPrincipalName,userAccountControl,pwdLastSet,\\
-    msDS-KeyVersionNumber,virtualCryptSHA512 \\
-    --script=/path/to/my-custom-syncpasswords-script.py
-samba-tool user syncpasswords --daemon \\
-    --logfile=/var/log/samba/user-syncpasswords.log
-samba-tool user syncpasswords --terminate \\
-    --logfile=/var/log/samba/user-syncpasswords.log
-
-"""
-    def __init__(self):
-        super(cmd_user_syncpasswords, self).__init__()
-
-    synopsis = "%prog [--cache-ldb-initialize] [options]"
-
-    takes_optiongroups = {
-        "sambaopts": options.SambaOptions,
-        "versionopts": options.VersionOptions,
-    }
-
-    takes_options = [
-        Option("--cache-ldb-initialize",
-               help="Initialize the cache for the first time",
-               dest="cache_ldb_initialize", action="store_true"),
-        Option("--cache-ldb", help="optional LDB URL user-syncpasswords-cache.ldb", type=str,
-               metavar="CACHE-LDB-PATH", dest="cache_ldb"),
-        Option("-H", "--URL", help="optional LDB URL for a local ldapi server", type=str,
-               metavar="URL", dest="H"),
-        Option("--filter", help="optional LDAP filter to set password on", type=str,
-               metavar="LDAP-SEARCH-FILTER", dest="filter"),
-        Option("--attributes", type=str,
-               help=virtual_attributes_help,
-               metavar="ATTRIBUTELIST", dest="attributes"),
-        Option("--decrypt-samba-gpg",
-               help=decrypt_samba_gpg_help,
-               action="store_true", default=False, dest="decrypt_samba_gpg"),
-        Option("--script", help="Script that is called for each password change", type=str,
-               metavar="/path/to/syncpasswords.script", dest="script"),
-        Option("--no-wait", help="Don't block waiting for changes",
-               action="store_true", default=False, dest="nowait"),
-        Option("--logfile", type=str,
-               help="The logfile to use (required in --daemon mode).",
-               metavar="/path/to/syncpasswords.log", dest="logfile"),
-        Option("--daemon", help="daemonize after initial setup",
-               action="store_true", default=False, dest="daemon"),
-        Option("--terminate",
-               help="Send a SIGTERM to an already running (daemon) process",
-               action="store_true", default=False, dest="terminate"),
-    ]
-
-    def run(self, cache_ldb_initialize=False, cache_ldb=None,
-            H=None, filter=None,
-            attributes=None, decrypt_samba_gpg=None,
-            script=None, nowait=None, logfile=None, daemon=None, terminate=None,
-            sambaopts=None, versionopts=None):
-
-        self.lp = sambaopts.get_loadparm()
-        self.logfile = None
-        self.samdb_url = None
-        self.samdb = None
-        self.cache = None
-
-        if not cache_ldb_initialize:
-            if attributes is not None:
-                raise CommandError("--attributes is only allowed together with --cache-ldb-initialize")
-            if decrypt_samba_gpg:
-                raise CommandError("--decrypt-samba-gpg is only allowed together with --cache-ldb-initialize")
-            if script is not None:
-                raise CommandError("--script is only allowed together with --cache-ldb-initialize")
-            if filter is not None:
-                raise CommandError("--filter is only allowed together with --cache-ldb-initialize")
-            if H is not None:
-                raise CommandError("-H/--URL is only allowed together with --cache-ldb-initialize")
-        else:
-            if nowait is not False:
-                raise CommandError("--no-wait is not allowed together with --cache-ldb-initialize")
-            if logfile is not None:
-                raise CommandError("--logfile is not allowed together with --cache-ldb-initialize")
-            if daemon is not False:
-                raise CommandError("--daemon is not allowed together with --cache-ldb-initialize")
-            if terminate is not False:
-                raise CommandError("--terminate is not allowed together with --cache-ldb-initialize")
-
-        if nowait is True:
-            if daemon is True:
-                raise CommandError("--daemon is not allowed together with --no-wait")
-            if terminate is not False:
-                raise CommandError("--terminate is not allowed together with --no-wait")
-
-        if terminate is True and daemon is True:
-            raise CommandError("--terminate is not allowed together with --daemon")
-
-        if daemon is True and logfile is None:
-            raise CommandError("--daemon is only allowed together with --logfile")
-
-        if terminate is True and logfile is None:
-            raise CommandError("--terminate is only allowed together with --logfile")
-
-        if script is not None:
-            if not os.path.exists(script):
-                raise CommandError("script[%s] does not exist!" % script)
-
-            sync_command = "%s" % os.path.abspath(script)
-        else:
-            sync_command = None
-
-        dirsync_filter = filter
-        if dirsync_filter is None:
-            dirsync_filter = "(&" + \
-                               "(objectClass=user)" + \
-                               "(userAccountControl:%s:=%u)" % (
-                                   ldb.OID_COMPARATOR_AND, dsdb.UF_NORMAL_ACCOUNT) + \
-                               "(!(sAMAccountName=krbtgt*))" + \
-                             ")"
-
-        dirsync_secret_attrs = [
-            "unicodePwd",
-            "dBCSPwd",
-            "supplementalCredentials",
-        ]
-
-        dirsync_attrs = dirsync_secret_attrs + [
-            "pwdLastSet",
-            "sAMAccountName",
-            "userPrincipalName",
-            "userAccountControl",
-            "isDeleted",
-            "isRecycled",
-        ]
-
-        password_attrs = None
-
-        if cache_ldb_initialize:
-            if H is None:
-                H = "ldapi://%s" % os.path.abspath(self.lp.private_path("ldap_priv/ldapi"))
-
-            if decrypt_samba_gpg and not gpg_decrypt:
-                raise CommandError(decrypt_samba_gpg_help)
-
-            password_attrs = self.parse_attributes(attributes)
-            lower_attrs = [x.lower() for x in password_attrs]
-            # We always return these in order to track deletions
-            for a in ["objectGUID", "isDeleted", "isRecycled"]:
-                if a.lower() not in lower_attrs:
-                    password_attrs += [a]
-
-        if cache_ldb is not None:
-            if cache_ldb.lower().startswith("ldapi://"):
-                raise CommandError("--cache_ldb ldapi:// is not supported")
-            elif cache_ldb.lower().startswith("ldap://"):
-                raise CommandError("--cache_ldb ldap:// is not supported")
-            elif cache_ldb.lower().startswith("ldaps://"):
-                raise CommandError("--cache_ldb ldaps:// is not supported")
-            elif cache_ldb.lower().startswith("tdb://"):
-                pass
-            else:
-                if not os.path.exists(cache_ldb):
-                    cache_ldb = self.lp.private_path(cache_ldb)
-        else:
-            cache_ldb = self.lp.private_path("user-syncpasswords-cache.ldb")
-
-        self.lockfile = "%s.pid" % cache_ldb
-
-        def log_msg(msg):
-            if self.logfile is not None:
-                info = os.fstat(0)
-                if info.st_nlink == 0:
-                    logfile = self.logfile
-                    self.logfile = None
-                    log_msg("Closing logfile[%s] (st_nlink == 0)\n" % (logfile))
-                    logfd = os.open(logfile, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
-                    os.dup2(logfd, 0)
-                    os.dup2(logfd, 1)
-                    os.dup2(logfd, 2)
-                    os.close(logfd)
-                    log_msg("Reopened logfile[%s]\n" % (logfile))
-                    self.logfile = logfile
-            msg = "%s: pid[%d]: %s" % (
-                    time.ctime(),
-                    os.getpid(),
-                    msg)
-            self.outf.write(msg)
-            return
-
-        def load_cache():
-            cache_attrs = [
-                "samdbUrl",
-                "dirsyncFilter",
-                "dirsyncAttribute",
-                "dirsyncControl",
-                "passwordAttribute",
-                "decryptSambaGPG",
-                "syncCommand",
-                "currentPid",
-            ]
-
-            self.cache = Ldb(cache_ldb)
-            self.cache_dn = ldb.Dn(self.cache, "KEY=USERSYNCPASSWORDS")
-            res = self.cache.search(base=self.cache_dn, scope=ldb.SCOPE_BASE,
-                                    attrs=cache_attrs)
-            if len(res) == 1:
-                try:
-                    self.samdb_url = str(res[0]["samdbUrl"][0])
-                except KeyError as e:
-                    self.samdb_url = None
-            else:
-                self.samdb_url = None
-            if self.samdb_url is None and not cache_ldb_initialize:
-                raise CommandError("cache_ldb[%s] not initialized, use --cache-ldb-initialize the first time" % (
-                                   cache_ldb))
-            if self.samdb_url is not None and cache_ldb_initialize:
-                raise CommandError("cache_ldb[%s] already initialized, --cache-ldb-initialize not allowed" % (
-                                   cache_ldb))
-            if self.samdb_url is None:
-                self.samdb_url = H
-                self.dirsync_filter = dirsync_filter
-                self.dirsync_attrs = dirsync_attrs
-                self.dirsync_controls = ["dirsync:1:0:0", "extended_dn:1:0"]
-                self.password_attrs = password_attrs
-                self.decrypt_samba_gpg = decrypt_samba_gpg
-                self.sync_command = sync_command
-                add_ldif = "dn: %s\n" % self.cache_dn +\
-                           "objectClass: userSyncPasswords\n" +\
-                           "samdbUrl:: %s\n" % base64.b64encode(get_bytes(self.samdb_url)).decode('utf8') +\
-                           "dirsyncFilter:: %s\n" % base64.b64encode(get_bytes(self.dirsync_filter)).decode('utf8') +\
-                           "".join("dirsyncAttribute:: %s\n" % base64.b64encode(get_bytes(a)).decode('utf8') for a in self.dirsync_attrs) +\
-                           "dirsyncControl: %s\n" % self.dirsync_controls[0] +\
-                           "".join("passwordAttribute:: %s\n" % base64.b64encode(get_bytes(a)).decode('utf8') for a in self.password_attrs)
-                if self.decrypt_samba_gpg:
-                    add_ldif += "decryptSambaGPG: TRUE\n"
-                else:
-                    add_ldif += "decryptSambaGPG: FALSE\n"
-                if self.sync_command is not None:
-                    add_ldif += "syncCommand: %s\n" % self.sync_command
-                add_ldif += "currentTime: %s\n" % ldb.timestring(int(time.time()))
-                self.cache.add_ldif(add_ldif)
-                self.current_pid = None
-                self.outf.write("Initialized cache_ldb[%s]\n" % (cache_ldb))
-                msgs = self.cache.parse_ldif(add_ldif)
-                changetype, msg = next(msgs)
-                ldif = self.cache.write_ldif(msg, ldb.CHANGETYPE_NONE)
-                self.outf.write("%s" % ldif)
-            else:
-                self.dirsync_filter = str(res[0]["dirsyncFilter"][0])
-                self.dirsync_attrs = []
-                for a in res[0]["dirsyncAttribute"]:
-                    self.dirsync_attrs.append(str(a))
-                self.dirsync_controls = [str(res[0]["dirsyncControl"][0]), "extended_dn:1:0"]
-                self.password_attrs = []
-                for a in res[0]["passwordAttribute"]:
-                    self.password_attrs.append(str(a))
-                decrypt_string = str(res[0]["decryptSambaGPG"][0])
-                assert(decrypt_string in ["TRUE", "FALSE"])
-                if decrypt_string == "TRUE":
-                    self.decrypt_samba_gpg = True
-                else:
-                    self.decrypt_samba_gpg = False
-                if "syncCommand" in res[0]:
-                    self.sync_command = str(res[0]["syncCommand"][0])
-                else:
-                    self.sync_command = None
-                if "currentPid" in res[0]:
-                    self.current_pid = int(res[0]["currentPid"][0])
-                else:
-                    self.current_pid = None
-                log_msg("Using cache_ldb[%s]\n" % (cache_ldb))
-
-            return
-
-        def run_sync_command(dn, ldif):
-            log_msg("Call Popen[%s] for %s\n" % (self.sync_command, dn))
-            sync_command_p = Popen(self.sync_command,
-                                   stdin=PIPE,
-                                   stdout=PIPE,
-                                   stderr=STDOUT)
-
-            res = sync_command_p.poll()
-            assert res is None
-
-            input = "%s" % (ldif)
-            reply = sync_command_p.communicate(
-                input.encode('utf-8'))[0].decode('utf-8')
-            log_msg("%s\n" % (reply))
-            res = sync_command_p.poll()
-            if res is None:
-                sync_command_p.terminate()
-            res = sync_command_p.wait()
-
-            if reply.startswith("DONE-EXIT: "):
-                return
-
-            log_msg("RESULT: %s\n" % (res))
-            raise Exception("ERROR: %s - %s\n" % (res, reply))
-
-        def handle_object(idx, dirsync_obj):
-            binary_guid = dirsync_obj.dn.get_extended_component("GUID")
-            guid = ndr_unpack(misc.GUID, binary_guid)
-            binary_sid = dirsync_obj.dn.get_extended_component("SID")
-            sid = ndr_unpack(security.dom_sid, binary_sid)
-            domain_sid, rid = sid.split()
-            if rid == security.DOMAIN_RID_KRBTGT:
-                log_msg("# Dirsync[%d] SKIP: DOMAIN_RID_KRBTGT\n\n" % (idx))
-                return
-            for a in list(dirsync_obj.keys()):
-                for h in dirsync_secret_attrs:
-                    if a.lower() == h.lower():
-                        del dirsync_obj[a]
-                        dirsync_obj["# %s::" % a] = ["REDACTED SECRET ATTRIBUTE"]
-            dirsync_ldif = self.samdb.write_ldif(dirsync_obj, ldb.CHANGETYPE_NONE)
-            log_msg("# Dirsync[%d] %s %s\n%s" % (idx, guid, sid, dirsync_ldif))
-            obj = self.get_account_attributes(self.samdb,
-                                              username="%s" % sid,
-                                              basedn="<GUID=%s>" % guid,
-                                              filter="(objectClass=user)",
-                                              scope=ldb.SCOPE_BASE,
-                                              attrs=self.password_attrs,
-                                              decrypt=self.decrypt_samba_gpg)
-            ldif = self.samdb.write_ldif(obj, ldb.CHANGETYPE_NONE)
-            log_msg("# Passwords[%d] %s %s\n" % (idx, guid, sid))
-            if self.sync_command is None:
-                self.outf.write("%s" % (ldif))
-                return
-            self.outf.write("# attrs=%s\n" % (sorted(obj.keys())))
-            run_sync_command(obj.dn, ldif)
-
-        def check_current_pid_conflict(terminate):
-            flags = os.O_RDWR
-            if not terminate:
-                flags |= os.O_CREAT
-
-            try:
-                self.lockfd = os.open(self.lockfile, flags, 0o600)
-            except IOError as e4:
-                (err, msg) = e4.args
-                if err == errno.ENOENT:
-                    if terminate:
-                        return False
-                log_msg("check_current_pid_conflict: failed to open[%s] - %s (%d)" %
-                        (self.lockfile, msg, err))
-                raise
-
-            got_exclusive = False
-            try:
-                fcntl.lockf(self.lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
-                got_exclusive = True
-            except IOError as e5:
-                (err, msg) = e5.args
-                if err != errno.EACCES and err != errno.EAGAIN:
-                    log_msg("check_current_pid_conflict: failed to get exclusive lock[%s] - %s (%d)" %
-                            (self.lockfile, msg, err))
-                    raise
-
-            if not got_exclusive:
-                buf = os.read(self.lockfd, 64)
-                self.current_pid = None
-                try:
-                    self.current_pid = int(buf)
-                except ValueError as e:
-                    pass
-                if self.current_pid is not None:
-                    return True
-
-            if got_exclusive and terminate:
-                try:
-                    os.ftruncate(self.lockfd, 0)
-                except IOError as e2:
-                    (err, msg) = e2.args
-                    log_msg("check_current_pid_conflict: failed to truncate [%s] - %s (%d)" %
-                            (self.lockfile, msg, err))
-                    raise
-                os.close(self.lockfd)
-                self.lockfd = -1
-                return False
-
-            try:
-                fcntl.lockf(self.lockfd, fcntl.LOCK_SH)
-            except IOError as e6:
-                (err, msg) = e6.args
-                log_msg("check_current_pid_conflict: failed to get shared lock[%s] - %s (%d)" %
-                        (self.lockfile, msg, err))
-
-            # We leave the function with the shared lock.
-            return False
-
-        def update_pid(pid):
-            if self.lockfd != -1:
-                got_exclusive = False
-                # Try 5 times to get the exclusive lock.
-                for i in range(0, 5):
-                    try:
-                        fcntl.lockf(self.lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
-                        got_exclusive = True
-                    except IOError as e:
-                        (err, msg) = e.args
-                        if err != errno.EACCES and err != errno.EAGAIN:
-                            log_msg("update_pid(%r): failed to get exclusive lock[%s] - %s (%d)" %
-                                    (pid, self.lockfile, msg, err))
-                            raise
-                    if got_exclusive:
-                        break
-                    time.sleep(1)
-                if not got_exclusive:
-                    log_msg("update_pid(%r): failed to get exclusive lock[%s]" %
-                            (pid, self.lockfile))
-                    raise CommandError("update_pid(%r): failed to get "
-                                       "exclusive lock[%s] after 5 seconds" %
-                                       (pid, self.lockfile))
-
-                if pid is not None:
-                    buf = "%d\n" % pid
-                else:
-                    buf = None
-                try:
-                    os.ftruncate(self.lockfd, 0)
-                    if buf is not None:
-                        os.write(self.lockfd, get_bytes(buf))
-                except IOError as e3:
-                    (err, msg) = e3.args
-                    log_msg("check_current_pid_conflict: failed to write pid to [%s] - %s (%d)" %
-                            (self.lockfile, msg, err))
-                    raise
-            self.current_pid = pid
-            if self.current_pid is not None:
-                log_msg("currentPid: %d\n" % self.current_pid)
-
-            modify_ldif = "dn: %s\n" % (self.cache_dn) +\
-                          "changetype: modify\n" +\
-                          "replace: currentPid\n"
-            if self.current_pid is not None:
-                modify_ldif += "currentPid: %d\n" % (self.current_pid)
-            modify_ldif += "replace: currentTime\n" +\
-                           "currentTime: %s\n" % ldb.timestring(int(time.time()))
-            self.cache.modify_ldif(modify_ldif)
-            return
-
-        def update_cache(res_controls):
-            assert len(res_controls) > 0
-            assert res_controls[0].oid == "1.2.840.113556.1.4.841"
-            res_controls[0].critical = True
-            self.dirsync_controls = [str(res_controls[0]), "extended_dn:1:0"]
-            # This cookie can be extremely long
-            # log_msg("dirsyncControls: %r\n" % self.dirsync_controls)
-
-            modify_ldif = "dn: %s\n" % (self.cache_dn) +\
-                          "changetype: modify\n" +\
-                          "replace: dirsyncControl\n" +\
-                          "dirsyncControl: %s\n" % (self.dirsync_controls[0]) +\
-                          "replace: currentTime\n" +\
-                          "currentTime: %s\n" % ldb.timestring(int(time.time()))
-            self.cache.modify_ldif(modify_ldif)
-            return
-
-        def check_object(dirsync_obj, res_controls):
-            assert len(res_controls) > 0
-            assert res_controls[0].oid == "1.2.840.113556.1.4.841"
-
-            binary_sid = dirsync_obj.dn.get_extended_component("SID")
-            sid = ndr_unpack(security.dom_sid, binary_sid)
-            dn = "KEY=%s" % sid
-            lastCookie = str(res_controls[0])
-
-            res = self.cache.search(base=dn, scope=ldb.SCOPE_BASE,
-                                    expression="(lastCookie=%s)" % (
-                                        ldb.binary_encode(lastCookie)),
-                                    attrs=[])
-            if len(res) == 1:
-                return True
-            return False
-
-        def update_object(dirsync_obj, res_controls):
-            assert len(res_controls) > 0
-            assert res_controls[0].oid == "1.2.840.113556.1.4.841"
-
-            binary_sid = dirsync_obj.dn.get_extended_component("SID")
-            sid = ndr_unpack(security.dom_sid, binary_sid)
-            dn = "KEY=%s" % sid
-            lastCookie = str(res_controls[0])
-
-            self.cache.transaction_start()
-            try:
-                res = self.cache.search(base=dn, scope=ldb.SCOPE_BASE,
-                                        expression="(objectClass=*)",
-                                        attrs=["lastCookie"])
-                if len(res) == 0:
-                    add_ldif  = "dn: %s\n" % (dn) +\
-                                "objectClass: userCookie\n" +\
-                                "lastCookie: %s\n" % (lastCookie) +\
-                                "currentTime: %s\n" % ldb.timestring(int(time.time()))
-                    self.cache.add_ldif(add_ldif)
-                else:
-                    modify_ldif = "dn: %s\n" % (dn) +\
-                                  "changetype: modify\n" +\
-                                  "replace: lastCookie\n" +\
-                                  "lastCookie: %s\n" % (lastCookie) +\
-                                  "replace: currentTime\n" +\
-                                  "currentTime: %s\n" % ldb.timestring(int(time.time()))
-                    self.cache.modify_ldif(modify_ldif)
-                self.cache.transaction_commit()
-            except Exception as e:
-                self.cache.transaction_cancel()
-
-            return
-
-        def dirsync_loop():
-            while True:
-                res = self.samdb.search(expression=str(self.dirsync_filter),
-                                        scope=ldb.SCOPE_SUBTREE,
-                                        attrs=self.dirsync_attrs,
-                                        controls=self.dirsync_controls)
-                log_msg("dirsync_loop(): results %d\n" % len(res))
-                ri = 0
-                for r in res:
-                    done = check_object(r, res.controls)
-                    if not done:
-                        handle_object(ri, r)
-                        update_object(r, res.controls)
-                    ri += 1
-                update_cache(res.controls)
-                if len(res) == 0:
-                    break
-
-        def sync_loop(wait):
-            notify_attrs = ["name", "uSNCreated", "uSNChanged", "objectClass"]
-            notify_controls = ["notification:1", "show_recycled:1"]
-            notify_handle = self.samdb.search_iterator(expression="objectClass=*",
-                                                       scope=ldb.SCOPE_SUBTREE,
-                                                       attrs=notify_attrs,
-                                                       controls=notify_controls,
-                                                       timeout=-1)
-
-            if wait is True:
-                log_msg("Resuming monitoring\n")
-            else:
-                log_msg("Getting changes\n")
-            self.outf.write("dirsyncFilter: %s\n" % self.dirsync_filter)
-            self.outf.write("dirsyncControls: %r\n" % self.dirsync_controls)
-            self.outf.write("syncCommand: %s\n" % self.sync_command)
-            dirsync_loop()
-
-            if wait is not True:
-                return
-
-            for msg in notify_handle:
-                if not isinstance(msg, ldb.Message):
-                    self.outf.write("referral: %s\n" % msg)
-                    continue
-                created = msg.get("uSNCreated")[0]
-                changed = msg.get("uSNChanged")[0]
-                log_msg("# Notify %s uSNCreated[%s] uSNChanged[%s]\n" %
-                        (msg.dn, created, changed))
-
-                dirsync_loop()
-
-            res = notify_handle.result()
-
-        def daemonize():
-            self.samdb = None
-            self.cache = None
-            orig_pid = os.getpid()
-            pid = os.fork()
-            if pid == 0:
-                os.setsid()
-                pid = os.fork()
-                if pid == 0:  # Actual daemon
-                    pid = os.getpid()
-                    log_msg("Daemonized as pid %d (from %d)\n" % (pid, orig_pid))
-                    load_cache()
-                    return
-            os._exit(0)
-
-        if cache_ldb_initialize:
-            self.samdb_url = H
-            self.samdb = self.connect_system_samdb(url=self.samdb_url,
-                                                   verbose=True)
-            load_cache()
-            return
-
-        if logfile is not None:
-            import resource      # Resource usage information.
-            maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
-            if maxfd == resource.RLIM_INFINITY:
-                maxfd = 1024  # Rough guess at maximum number of open file descriptors.
-            logfd = os.open(logfile, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
-            self.outf.write("Using logfile[%s]\n" % logfile)
-            for fd in range(0, maxfd):
-                if fd == logfd:
-                    continue
-                try:
-                    os.close(fd)
-                except OSError:
-                    pass
-            os.dup2(logfd, 0)
-            os.dup2(logfd, 1)
-            os.dup2(logfd, 2)
-            os.close(logfd)
-            log_msg("Attached to logfile[%s]\n" % (logfile))
-            self.logfile = logfile
-
-        load_cache()
-        conflict = check_current_pid_conflict(terminate)
-        if terminate:
-            if self.current_pid is None:
-                log_msg("No process running.\n")
-                return
-            if not conflict:
-                log_msg("Process %d is not running anymore.\n" % (
-                        self.current_pid))
-                update_pid(None)
-                return
-            log_msg("Sending SIGTERM to process %d.\n" % (
-                    self.current_pid))
-            os.kill(self.current_pid, signal.SIGTERM)
-            return
-        if conflict:
-            raise CommandError("Exiting pid %d, command is already running as pid %d" % (
-                               os.getpid(), self.current_pid))
-
-        if daemon is True:
-            daemonize()
-        update_pid(os.getpid())
-
-        wait = True
-        while wait is True:
-            retry_sleep_min = 1
-            retry_sleep_max = 600
-            if nowait is True:
-                wait = False
-                retry_sleep = 0
-            else:
-                retry_sleep = retry_sleep_min
-
-            while self.samdb is None:
-                if retry_sleep != 0:
-                    log_msg("Wait before connect - sleep(%d)\n" % retry_sleep)
-                    time.sleep(retry_sleep)
-                retry_sleep = retry_sleep * 2
-                if retry_sleep >= retry_sleep_max:
-                    retry_sleep = retry_sleep_max
-                log_msg("Connecting to '%s'\n" % self.samdb_url)
-                try:
-                    self.samdb = self.connect_system_samdb(url=self.samdb_url)
-                except Exception as msg:
-                    self.samdb = None
-                    log_msg("Connect to samdb Exception => (%s)\n" % msg)
-                    if wait is not True:
-                        raise
-
-            try:
-                sync_loop(wait)
-            except ldb.LdbError as e7:
-                (enum, estr) = e7.args
-                self.samdb = None
-                log_msg("ldb.LdbError(%d) => (%s)\n" % (enum, estr))
-
-        update_pid(None)
-        return
+from .syncpasswords import cmd_user_syncpasswords
diff --git a/python/samba/netcmd/user/readpasswords/syncpasswords.py b/python/samba/netcmd/user/readpasswords/syncpasswords.py
new file mode 100644 (file)
index 0000000..79ff650
--- /dev/null
@@ -0,0 +1,880 @@
+# user management
+#
+# user syncpasswords command
+#
+# Copyright Jelmer Vernooij 2010 <jelmer@samba.org>
+# Copyright Theresa Halloran 2011 <theresahalloran@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import base64
+import errno
+import fcntl
+import os
+import signal
+import time
+from subprocess import Popen, PIPE, STDOUT
+
+import ldb
+import samba.getopt as options
+from samba import Ldb, dsdb
+from samba.dcerpc import misc, security
+from samba.ndr import ndr_unpack
+from samba.common import get_bytes
+from samba.netcmd import CommandError, Option
+
+from .common import (
+    GetPasswordCommand,
+    gpg_decrypt,
+    decrypt_samba_gpg_help,
+    virtual_attributes_help
+)
+
+
+class cmd_user_syncpasswords(GetPasswordCommand):
+    """Sync the password of user accounts.
+
+This syncs logon passwords for user accounts.
+
+Note that this command should run on a single domain controller only
+(typically the PDC-emulator). However the "password hash gpg key ids"
+option should to be configured on all domain controllers.
+
+The command must be run from the root user id or another authorized user id.
+The '-H' or '--URL' option only supports ldapi:// and can be used to adjust the
+local path.  By default, ldapi:// is used with the default path to the
+privileged ldapi socket.
+
+This command has three modes: "Cache Initialization", "Sync Loop Run" and
+"Sync Loop Terminate".
+
+
+Cache Initialization
+====================
+
+The first time, this command needs to be called with
+'--cache-ldb-initialize' in order to initialize its cache.
+
+The cache initialization requires '--attributes' and allows the following
+optional options: '--decrypt-samba-gpg', '--script', '--filter' or
+'-H/--URL'.
+
+The '--attributes' parameter takes a comma separated list of attributes,
+which will be printed or given to the script specified by '--script'. If a
+specified attribute is not available on an object it will be silently omitted.
+All attributes defined in the schema (e.g. the unicodePwd attribute holds
+the NTHASH) and the following virtual attributes are possible (see '--help'
+for supported virtual attributes in your environment):
+
+   virtualClearTextUTF16: The raw cleartext as stored in the
+                          'Primary:CLEARTEXT' (or 'Primary:SambaGPG'
+                          with '--decrypt-samba-gpg') buffer inside of the
+                          supplementalCredentials attribute. This typically
+                          contains valid UTF-16-LE, but may contain random
+                          bytes, e.g. for computer accounts.
+
+   virtualClearTextUTF8:  As virtualClearTextUTF16, but converted to UTF-8
+                          (only from valid UTF-16-LE).
+
+   virtualSSHA:           As virtualClearTextUTF8, but a salted SHA-1
+                          checksum, useful for OpenLDAP's '{SSHA}' algorithm.
+
+   virtualCryptSHA256:    As virtualClearTextUTF8, but a salted SHA256
+                          checksum, useful for OpenLDAP's '{CRYPT}' algorithm,
+                          with a $5$... salt, see crypt(3) on modern systems.
+                          The number of rounds used to calculate the hash can
+                          also be specified. By appending ";rounds=x" to the
+                          attribute name i.e. virtualCryptSHA256;rounds=10000
+                          will calculate a SHA256 hash with 10,000 rounds.
+                          Non numeric values for rounds are silently ignored.
+                          The value is calculated as follows:
+                          1) If a value exists in 'Primary:userPassword' with
+                             the specified number of rounds it is returned.
+                          2) If 'Primary:CLEARTEXT', or 'Primary:SambaGPG' with
+                             '--decrypt-samba-gpg'. Calculate a hash with
+                             the specified number of rounds
+                          3) Return the first CryptSHA256 value in
+                             'Primary:userPassword'.
+
+   virtualCryptSHA512:    As virtualClearTextUTF8, but a salted SHA512
+                          checksum, useful for OpenLDAP's '{CRYPT}' algorithm,
+                          with a $6$... salt, see crypt(3) on modern systems.
+                          The number of rounds used to calculate the hash can
+                          also be specified. By appending ";rounds=x" to the
+                          attribute name i.e. virtualCryptSHA512;rounds=10000
+                          will calculate a SHA512 hash with 10,000 rounds.
+                          Non numeric values for rounds are silently ignored.
+                          The value is calculated as follows:
+                          1) If a value exists in 'Primary:userPassword' with
+                             the specified number of rounds it is returned.
+                          2) If 'Primary:CLEARTEXT', or 'Primary:SambaGPG' with
+                             '--decrypt-samba-gpg'. Calculate a hash with
+                             the specified number of rounds.
+                          3) Return the first CryptSHA512 value in
+                             'Primary:userPassword'.
+
+   virtualWDigestNN:      The individual hash values stored in
+                          'Primary:WDigest' where NN is the hash number in
+                          the range 01 to 29.
+                          NOTE: As at 22-05-2017 the documentation:
+                          3.1.1.8.11.3.1 WDIGEST_CREDENTIALS Construction
+                        https://msdn.microsoft.com/en-us/library/cc245680.aspx
+                          is incorrect.
+
+   virtualKerberosSalt:   This results the salt string that is used to compute
+                          Kerberos keys from a UTF-8 cleartext password.
+
+   virtualSambaGPG:       The raw cleartext as stored in the
+                          'Primary:SambaGPG' buffer inside of the
+                          supplementalCredentials attribute.
+                          See the 'password hash gpg key ids' option in
+                          smb.conf.
+
+The '--decrypt-samba-gpg' option triggers decryption of the
+Primary:SambaGPG buffer. Check with '--help' if this feature is available
+in your environment or not (the python-gpgme package is required).  Please
+note that you might need to set the GNUPGHOME environment variable.  If the
+decryption key has a passphrase you have to make sure that the GPG_AGENT_INFO
+environment variable has been set correctly and the passphrase is already
+known by the gpg-agent.
+
+The '--script' option specifies a custom script that is called whenever any
+of the dirsyncAttributes (see below) was changed. The script is called
+without any arguments. It gets the LDIF for exactly one object on STDIN.
+If the script processed the object successfully it has to respond with a
+single line starting with 'DONE-EXIT: ' followed by an optional message.
+
+Note that the script might be called without any password change, e.g. if
+the account was disabled (a userAccountControl change) or the
+sAMAccountName was changed. The objectGUID,isDeleted,isRecycled attributes
+are always returned as unique identifier of the account. It might be useful
+to also ask for non-password attributes like: objectSid, sAMAccountName,
+userPrincipalName, userAccountControl, pwdLastSet and msDS-KeyVersionNumber.
+Depending on the object, some attributes may not be present/available,
+but you always get the current state (and not a diff).
+
+If no '--script' option is specified, the LDIF will be printed on STDOUT or
+into the logfile.
+
+The default filter for the LDAP_SERVER_DIRSYNC_OID search is:
+(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=512)\\
+    (!(sAMAccountName=krbtgt*)))
+This means only normal (non-krbtgt) user
+accounts are monitored.  The '--filter' can modify that, e.g. if it's
+required to also sync computer accounts.
+
+
+Sync Loop Run
+=============
+
+This (default) mode runs in an endless loop waiting for password related
+changes in the active directory database. It makes use of the
+LDAP_SERVER_DIRSYNC_OID and LDAP_SERVER_NOTIFICATION_OID controls in order
+get changes in a reliable fashion. Objects are monitored for changes of the
+following dirsyncAttributes:
+
+  unicodePwd, dBCSPwd, supplementalCredentials, pwdLastSet, sAMAccountName,
+  userPrincipalName and userAccountControl.
+
+It recovers from LDAP disconnects and updates the cache in conservative way
+(in single steps after each successfully processed change).  An error from
+the script (specified by '--script') will result in fatal error and this
+command will exit.  But the cache state should be still valid and can be
+resumed in the next "Sync Loop Run".
+
+The '--logfile' option specifies an optional (required if '--daemon' is
+specified) logfile that takes all output of the command. The logfile is
+automatically reopened if fstat returns st_nlink == 0.
+
+The optional '--daemon' option will put the command into the background.
+
+You can stop the command without the '--daemon' option, also by hitting
+strg+c.
+
+If you specify the '--no-wait' option the command skips the
+LDAP_SERVER_NOTIFICATION_OID 'waiting' step and exit once
+all LDAP_SERVER_DIRSYNC_OID changes are consumed.
+
+Sync Loop Terminate
+===================
+
+In order to terminate an already running command (likely as daemon) the
+'--terminate' option can be used. This also requires the '--logfile' option
+to be specified.
+
+
+Example1:
+samba-tool user syncpasswords --cache-ldb-initialize \\
+    --attributes=virtualClearTextUTF8
+samba-tool user syncpasswords
+
+Example2:
+samba-tool user syncpasswords --cache-ldb-initialize \\
+    --attributes=objectGUID,objectSID,sAMAccountName,\\
+    userPrincipalName,userAccountControl,pwdLastSet,\\
+    msDS-KeyVersionNumber,virtualCryptSHA512 \\
+    --script=/path/to/my-custom-syncpasswords-script.py
+samba-tool user syncpasswords --daemon \\
+    --logfile=/var/log/samba/user-syncpasswords.log
+samba-tool user syncpasswords --terminate \\
+    --logfile=/var/log/samba/user-syncpasswords.log
+
+"""
+    def __init__(self):
+        super(cmd_user_syncpasswords, self).__init__()
+
+    synopsis = "%prog [--cache-ldb-initialize] [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+    }
+
+    takes_options = [
+        Option("--cache-ldb-initialize",
+               help="Initialize the cache for the first time",
+               dest="cache_ldb_initialize", action="store_true"),
+        Option("--cache-ldb", help="optional LDB URL user-syncpasswords-cache.ldb", type=str,
+               metavar="CACHE-LDB-PATH", dest="cache_ldb"),
+        Option("-H", "--URL", help="optional LDB URL for a local ldapi server", type=str,
+               metavar="URL", dest="H"),
+        Option("--filter", help="optional LDAP filter to set password on", type=str,
+               metavar="LDAP-SEARCH-FILTER", dest="filter"),
+        Option("--attributes", type=str,
+               help=virtual_attributes_help,
+               metavar="ATTRIBUTELIST", dest="attributes"),
+        Option("--decrypt-samba-gpg",
+               help=decrypt_samba_gpg_help,
+               action="store_true", default=False, dest="decrypt_samba_gpg"),
+        Option("--script", help="Script that is called for each password change", type=str,
+               metavar="/path/to/syncpasswords.script", dest="script"),
+        Option("--no-wait", help="Don't block waiting for changes",
+               action="store_true", default=False, dest="nowait"),
+        Option("--logfile", type=str,
+               help="The logfile to use (required in --daemon mode).",
+               metavar="/path/to/syncpasswords.log", dest="logfile"),
+        Option("--daemon", help="daemonize after initial setup",
+               action="store_true", default=False, dest="daemon"),
+        Option("--terminate",
+               help="Send a SIGTERM to an already running (daemon) process",
+               action="store_true", default=False, dest="terminate"),
+    ]
+
+    def run(self, cache_ldb_initialize=False, cache_ldb=None,
+            H=None, filter=None,
+            attributes=None, decrypt_samba_gpg=None,
+            script=None, nowait=None, logfile=None, daemon=None, terminate=None,
+            sambaopts=None, versionopts=None):
+
+        self.lp = sambaopts.get_loadparm()
+        self.logfile = None
+        self.samdb_url = None
+        self.samdb = None
+        self.cache = None
+
+        if not cache_ldb_initialize:
+            if attributes is not None:
+                raise CommandError("--attributes is only allowed together with --cache-ldb-initialize")
+            if decrypt_samba_gpg:
+                raise CommandError("--decrypt-samba-gpg is only allowed together with --cache-ldb-initialize")
+            if script is not None:
+                raise CommandError("--script is only allowed together with --cache-ldb-initialize")
+            if filter is not None:
+                raise CommandError("--filter is only allowed together with --cache-ldb-initialize")
+            if H is not None:
+                raise CommandError("-H/--URL is only allowed together with --cache-ldb-initialize")
+        else:
+            if nowait is not False:
+                raise CommandError("--no-wait is not allowed together with --cache-ldb-initialize")
+            if logfile is not None:
+                raise CommandError("--logfile is not allowed together with --cache-ldb-initialize")
+            if daemon is not False:
+                raise CommandError("--daemon is not allowed together with --cache-ldb-initialize")
+            if terminate is not False:
+                raise CommandError("--terminate is not allowed together with --cache-ldb-initialize")
+
+        if nowait is True:
+            if daemon is True:
+                raise CommandError("--daemon is not allowed together with --no-wait")
+            if terminate is not False:
+                raise CommandError("--terminate is not allowed together with --no-wait")
+
+        if terminate is True and daemon is True:
+            raise CommandError("--terminate is not allowed together with --daemon")
+
+        if daemon is True and logfile is None:
+            raise CommandError("--daemon is only allowed together with --logfile")
+
+        if terminate is True and logfile is None:
+            raise CommandError("--terminate is only allowed together with --logfile")
+
+        if script is not None:
+            if not os.path.exists(script):
+                raise CommandError("script[%s] does not exist!" % script)
+
+            sync_command = "%s" % os.path.abspath(script)
+        else:
+            sync_command = None
+
+        dirsync_filter = filter
+        if dirsync_filter is None:
+            dirsync_filter = "(&" + \
+                               "(objectClass=user)" + \
+                               "(userAccountControl:%s:=%u)" % (
+                                   ldb.OID_COMPARATOR_AND, dsdb.UF_NORMAL_ACCOUNT) + \
+                               "(!(sAMAccountName=krbtgt*))" + \
+                             ")"
+
+        dirsync_secret_attrs = [
+            "unicodePwd",
+            "dBCSPwd",
+            "supplementalCredentials",
+        ]
+
+        dirsync_attrs = dirsync_secret_attrs + [
+            "pwdLastSet",
+            "sAMAccountName",
+            "userPrincipalName",
+            "userAccountControl",
+            "isDeleted",
+            "isRecycled",
+        ]
+
+        password_attrs = None
+
+        if cache_ldb_initialize:
+            if H is None:
+                H = "ldapi://%s" % os.path.abspath(self.lp.private_path("ldap_priv/ldapi"))
+
+            if decrypt_samba_gpg and not gpg_decrypt:
+                raise CommandError(decrypt_samba_gpg_help)
+
+            password_attrs = self.parse_attributes(attributes)
+            lower_attrs = [x.lower() for x in password_attrs]
+            # We always return these in order to track deletions
+            for a in ["objectGUID", "isDeleted", "isRecycled"]:
+                if a.lower() not in lower_attrs:
+                    password_attrs += [a]
+
+        if cache_ldb is not None:
+            if cache_ldb.lower().startswith("ldapi://"):
+                raise CommandError("--cache_ldb ldapi:// is not supported")
+            elif cache_ldb.lower().startswith("ldap://"):
+                raise CommandError("--cache_ldb ldap:// is not supported")
+            elif cache_ldb.lower().startswith("ldaps://"):
+                raise CommandError("--cache_ldb ldaps:// is not supported")
+            elif cache_ldb.lower().startswith("tdb://"):
+                pass
+            else:
+                if not os.path.exists(cache_ldb):
+                    cache_ldb = self.lp.private_path(cache_ldb)
+        else:
+            cache_ldb = self.lp.private_path("user-syncpasswords-cache.ldb")
+
+        self.lockfile = "%s.pid" % cache_ldb
+
+        def log_msg(msg):
+            if self.logfile is not None:
+                info = os.fstat(0)
+                if info.st_nlink == 0:
+                    logfile = self.logfile
+                    self.logfile = None
+                    log_msg("Closing logfile[%s] (st_nlink == 0)\n" % (logfile))
+                    logfd = os.open(logfile, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
+                    os.dup2(logfd, 0)
+                    os.dup2(logfd, 1)
+                    os.dup2(logfd, 2)
+                    os.close(logfd)
+                    log_msg("Reopened logfile[%s]\n" % (logfile))
+                    self.logfile = logfile
+            msg = "%s: pid[%d]: %s" % (
+                    time.ctime(),
+                    os.getpid(),
+                    msg)
+            self.outf.write(msg)
+            return
+
+        def load_cache():
+            cache_attrs = [
+                "samdbUrl",
+                "dirsyncFilter",
+                "dirsyncAttribute",
+                "dirsyncControl",
+                "passwordAttribute",
+                "decryptSambaGPG",
+                "syncCommand",
+                "currentPid",
+            ]
+
+            self.cache = Ldb(cache_ldb)
+            self.cache_dn = ldb.Dn(self.cache, "KEY=USERSYNCPASSWORDS")
+            res = self.cache.search(base=self.cache_dn, scope=ldb.SCOPE_BASE,
+                                    attrs=cache_attrs)
+            if len(res) == 1:
+                try:
+                    self.samdb_url = str(res[0]["samdbUrl"][0])
+                except KeyError as e:
+                    self.samdb_url = None
+            else:
+                self.samdb_url = None
+            if self.samdb_url is None and not cache_ldb_initialize:
+                raise CommandError("cache_ldb[%s] not initialized, use --cache-ldb-initialize the first time" % (
+                                   cache_ldb))
+            if self.samdb_url is not None and cache_ldb_initialize:
+                raise CommandError("cache_ldb[%s] already initialized, --cache-ldb-initialize not allowed" % (
+                                   cache_ldb))
+            if self.samdb_url is None:
+                self.samdb_url = H
+                self.dirsync_filter = dirsync_filter
+                self.dirsync_attrs = dirsync_attrs
+                self.dirsync_controls = ["dirsync:1:0:0", "extended_dn:1:0"]
+                self.password_attrs = password_attrs
+                self.decrypt_samba_gpg = decrypt_samba_gpg
+                self.sync_command = sync_command
+                add_ldif = "dn: %s\n" % self.cache_dn +\
+                           "objectClass: userSyncPasswords\n" +\
+                           "samdbUrl:: %s\n" % base64.b64encode(get_bytes(self.samdb_url)).decode('utf8') +\
+                           "dirsyncFilter:: %s\n" % base64.b64encode(get_bytes(self.dirsync_filter)).decode('utf8') +\
+                           "".join("dirsyncAttribute:: %s\n" % base64.b64encode(get_bytes(a)).decode('utf8') for a in self.dirsync_attrs) +\
+                           "dirsyncControl: %s\n" % self.dirsync_controls[0] +\
+                           "".join("passwordAttribute:: %s\n" % base64.b64encode(get_bytes(a)).decode('utf8') for a in self.password_attrs)
+                if self.decrypt_samba_gpg:
+                    add_ldif += "decryptSambaGPG: TRUE\n"
+                else:
+                    add_ldif += "decryptSambaGPG: FALSE\n"
+                if self.sync_command is not None:
+                    add_ldif += "syncCommand: %s\n" % self.sync_command
+                add_ldif += "currentTime: %s\n" % ldb.timestring(int(time.time()))
+                self.cache.add_ldif(add_ldif)
+                self.current_pid = None
+                self.outf.write("Initialized cache_ldb[%s]\n" % (cache_ldb))
+                msgs = self.cache.parse_ldif(add_ldif)
+                changetype, msg = next(msgs)
+                ldif = self.cache.write_ldif(msg, ldb.CHANGETYPE_NONE)
+                self.outf.write("%s" % ldif)
+            else:
+                self.dirsync_filter = str(res[0]["dirsyncFilter"][0])
+                self.dirsync_attrs = []
+                for a in res[0]["dirsyncAttribute"]:
+                    self.dirsync_attrs.append(str(a))
+                self.dirsync_controls = [str(res[0]["dirsyncControl"][0]), "extended_dn:1:0"]
+                self.password_attrs = []
+                for a in res[0]["passwordAttribute"]:
+                    self.password_attrs.append(str(a))
+                decrypt_string = str(res[0]["decryptSambaGPG"][0])
+                assert(decrypt_string in ["TRUE", "FALSE"])
+                if decrypt_string == "TRUE":
+                    self.decrypt_samba_gpg = True
+                else:
+                    self.decrypt_samba_gpg = False
+                if "syncCommand" in res[0]:
+                    self.sync_command = str(res[0]["syncCommand"][0])
+                else:
+                    self.sync_command = None
+                if "currentPid" in res[0]:
+                    self.current_pid = int(res[0]["currentPid"][0])
+                else:
+                    self.current_pid = None
+                log_msg("Using cache_ldb[%s]\n" % (cache_ldb))
+
+            return
+
+        def run_sync_command(dn, ldif):
+            log_msg("Call Popen[%s] for %s\n" % (self.sync_command, dn))
+            sync_command_p = Popen(self.sync_command,
+                                   stdin=PIPE,
+                                   stdout=PIPE,
+                                   stderr=STDOUT)
+
+            res = sync_command_p.poll()
+            assert res is None
+
+            input = "%s" % (ldif)
+            reply = sync_command_p.communicate(
+                input.encode('utf-8'))[0].decode('utf-8')
+            log_msg("%s\n" % (reply))
+            res = sync_command_p.poll()
+            if res is None:
+                sync_command_p.terminate()
+            res = sync_command_p.wait()
+
+            if reply.startswith("DONE-EXIT: "):
+                return
+
+            log_msg("RESULT: %s\n" % (res))
+            raise Exception("ERROR: %s - %s\n" % (res, reply))
+
+        def handle_object(idx, dirsync_obj):
+            binary_guid = dirsync_obj.dn.get_extended_component("GUID")
+            guid = ndr_unpack(misc.GUID, binary_guid)
+            binary_sid = dirsync_obj.dn.get_extended_component("SID")
+            sid = ndr_unpack(security.dom_sid, binary_sid)
+            domain_sid, rid = sid.split()
+            if rid == security.DOMAIN_RID_KRBTGT:
+                log_msg("# Dirsync[%d] SKIP: DOMAIN_RID_KRBTGT\n\n" % (idx))
+                return
+            for a in list(dirsync_obj.keys()):
+                for h in dirsync_secret_attrs:
+                    if a.lower() == h.lower():
+                        del dirsync_obj[a]
+                        dirsync_obj["# %s::" % a] = ["REDACTED SECRET ATTRIBUTE"]
+            dirsync_ldif = self.samdb.write_ldif(dirsync_obj, ldb.CHANGETYPE_NONE)
+            log_msg("# Dirsync[%d] %s %s\n%s" % (idx, guid, sid, dirsync_ldif))
+            obj = self.get_account_attributes(self.samdb,
+                                              username="%s" % sid,
+                                              basedn="<GUID=%s>" % guid,
+                                              filter="(objectClass=user)",
+                                              scope=ldb.SCOPE_BASE,
+                                              attrs=self.password_attrs,
+                                              decrypt=self.decrypt_samba_gpg)
+            ldif = self.samdb.write_ldif(obj, ldb.CHANGETYPE_NONE)
+            log_msg("# Passwords[%d] %s %s\n" % (idx, guid, sid))
+            if self.sync_command is None:
+                self.outf.write("%s" % (ldif))
+                return
+            self.outf.write("# attrs=%s\n" % (sorted(obj.keys())))
+            run_sync_command(obj.dn, ldif)
+
+        def check_current_pid_conflict(terminate):
+            flags = os.O_RDWR
+            if not terminate:
+                flags |= os.O_CREAT
+
+            try:
+                self.lockfd = os.open(self.lockfile, flags, 0o600)
+            except IOError as e4:
+                (err, msg) = e4.args
+                if err == errno.ENOENT:
+                    if terminate:
+                        return False
+                log_msg("check_current_pid_conflict: failed to open[%s] - %s (%d)" %
+                        (self.lockfile, msg, err))
+                raise
+
+            got_exclusive = False
+            try:
+                fcntl.lockf(self.lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+                got_exclusive = True
+            except IOError as e5:
+                (err, msg) = e5.args
+                if err != errno.EACCES and err != errno.EAGAIN:
+                    log_msg("check_current_pid_conflict: failed to get exclusive lock[%s] - %s (%d)" %
+                            (self.lockfile, msg, err))
+                    raise
+
+            if not got_exclusive:
+                buf = os.read(self.lockfd, 64)
+                self.current_pid = None
+                try:
+                    self.current_pid = int(buf)
+                except ValueError as e:
+                    pass
+                if self.current_pid is not None:
+                    return True
+
+            if got_exclusive and terminate:
+                try:
+                    os.ftruncate(self.lockfd, 0)
+                except IOError as e2:
+                    (err, msg) = e2.args
+                    log_msg("check_current_pid_conflict: failed to truncate [%s] - %s (%d)" %
+                            (self.lockfile, msg, err))
+                    raise
+                os.close(self.lockfd)
+                self.lockfd = -1
+                return False
+
+            try:
+                fcntl.lockf(self.lockfd, fcntl.LOCK_SH)
+            except IOError as e6:
+                (err, msg) = e6.args
+                log_msg("check_current_pid_conflict: failed to get shared lock[%s] - %s (%d)" %
+                        (self.lockfile, msg, err))
+
+            # We leave the function with the shared lock.
+            return False
+
+        def update_pid(pid):
+            if self.lockfd != -1:
+                got_exclusive = False
+                # Try 5 times to get the exclusive lock.
+                for i in range(0, 5):
+                    try:
+                        fcntl.lockf(self.lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+                        got_exclusive = True
+                    except IOError as e:
+                        (err, msg) = e.args
+                        if err != errno.EACCES and err != errno.EAGAIN:
+                            log_msg("update_pid(%r): failed to get exclusive lock[%s] - %s (%d)" %
+                                    (pid, self.lockfile, msg, err))
+                            raise
+                    if got_exclusive:
+                        break
+                    time.sleep(1)
+                if not got_exclusive:
+                    log_msg("update_pid(%r): failed to get exclusive lock[%s]" %
+                            (pid, self.lockfile))
+                    raise CommandError("update_pid(%r): failed to get "
+                                       "exclusive lock[%s] after 5 seconds" %
+                                       (pid, self.lockfile))
+
+                if pid is not None:
+                    buf = "%d\n" % pid
+                else:
+                    buf = None
+                try:
+                    os.ftruncate(self.lockfd, 0)
+                    if buf is not None:
+                        os.write(self.lockfd, get_bytes(buf))
+                except IOError as e3:
+                    (err, msg) = e3.args
+                    log_msg("check_current_pid_conflict: failed to write pid to [%s] - %s (%d)" %
+                            (self.lockfile, msg, err))
+                    raise
+            self.current_pid = pid
+            if self.current_pid is not None:
+                log_msg("currentPid: %d\n" % self.current_pid)
+
+            modify_ldif = "dn: %s\n" % (self.cache_dn) +\
+                          "changetype: modify\n" +\
+                          "replace: currentPid\n"
+            if self.current_pid is not None:
+                modify_ldif += "currentPid: %d\n" % (self.current_pid)
+            modify_ldif += "replace: currentTime\n" +\
+                           "currentTime: %s\n" % ldb.timestring(int(time.time()))
+            self.cache.modify_ldif(modify_ldif)
+            return
+
+        def update_cache(res_controls):
+            assert len(res_controls) > 0
+            assert res_controls[0].oid == "1.2.840.113556.1.4.841"
+            res_controls[0].critical = True
+            self.dirsync_controls = [str(res_controls[0]), "extended_dn:1:0"]
+            # This cookie can be extremely long
+            # log_msg("dirsyncControls: %r\n" % self.dirsync_controls)
+
+            modify_ldif = "dn: %s\n" % (self.cache_dn) +\
+                          "changetype: modify\n" +\
+                          "replace: dirsyncControl\n" +\
+                          "dirsyncControl: %s\n" % (self.dirsync_controls[0]) +\
+                          "replace: currentTime\n" +\
+                          "currentTime: %s\n" % ldb.timestring(int(time.time()))
+            self.cache.modify_ldif(modify_ldif)
+            return
+
+        def check_object(dirsync_obj, res_controls):
+            assert len(res_controls) > 0
+            assert res_controls[0].oid == "1.2.840.113556.1.4.841"
+
+            binary_sid = dirsync_obj.dn.get_extended_component("SID")
+            sid = ndr_unpack(security.dom_sid, binary_sid)
+            dn = "KEY=%s" % sid
+            lastCookie = str(res_controls[0])
+
+            res = self.cache.search(base=dn, scope=ldb.SCOPE_BASE,
+                                    expression="(lastCookie=%s)" % (
+                                        ldb.binary_encode(lastCookie)),
+                                    attrs=[])
+            if len(res) == 1:
+                return True
+            return False
+
+        def update_object(dirsync_obj, res_controls):
+            assert len(res_controls) > 0
+            assert res_controls[0].oid == "1.2.840.113556.1.4.841"
+
+            binary_sid = dirsync_obj.dn.get_extended_component("SID")
+            sid = ndr_unpack(security.dom_sid, binary_sid)
+            dn = "KEY=%s" % sid
+            lastCookie = str(res_controls[0])
+
+            self.cache.transaction_start()
+            try:
+                res = self.cache.search(base=dn, scope=ldb.SCOPE_BASE,
+                                        expression="(objectClass=*)",
+                                        attrs=["lastCookie"])
+                if len(res) == 0:
+                    add_ldif  = "dn: %s\n" % (dn) +\
+                                "objectClass: userCookie\n" +\
+                                "lastCookie: %s\n" % (lastCookie) +\
+                                "currentTime: %s\n" % ldb.timestring(int(time.time()))
+                    self.cache.add_ldif(add_ldif)
+                else:
+                    modify_ldif = "dn: %s\n" % (dn) +\
+                                  "changetype: modify\n" +\
+                                  "replace: lastCookie\n" +\
+                                  "lastCookie: %s\n" % (lastCookie) +\
+                                  "replace: currentTime\n" +\
+                                  "currentTime: %s\n" % ldb.timestring(int(time.time()))
+                    self.cache.modify_ldif(modify_ldif)
+                self.cache.transaction_commit()
+            except Exception as e:
+                self.cache.transaction_cancel()
+
+            return
+
+        def dirsync_loop():
+            while True:
+                res = self.samdb.search(expression=str(self.dirsync_filter),
+                                        scope=ldb.SCOPE_SUBTREE,
+                                        attrs=self.dirsync_attrs,
+                                        controls=self.dirsync_controls)
+                log_msg("dirsync_loop(): results %d\n" % len(res))
+                ri = 0
+                for r in res:
+                    done = check_object(r, res.controls)
+                    if not done:
+                        handle_object(ri, r)
+                        update_object(r, res.controls)
+                    ri += 1
+                update_cache(res.controls)
+                if len(res) == 0:
+                    break
+
+        def sync_loop(wait):
+            notify_attrs = ["name", "uSNCreated", "uSNChanged", "objectClass"]
+            notify_controls = ["notification:1", "show_recycled:1"]
+            notify_handle = self.samdb.search_iterator(expression="objectClass=*",
+                                                       scope=ldb.SCOPE_SUBTREE,
+                                                       attrs=notify_attrs,
+                                                       controls=notify_controls,
+                                                       timeout=-1)
+
+            if wait is True:
+                log_msg("Resuming monitoring\n")
+            else:
+                log_msg("Getting changes\n")
+            self.outf.write("dirsyncFilter: %s\n" % self.dirsync_filter)
+            self.outf.write("dirsyncControls: %r\n" % self.dirsync_controls)
+            self.outf.write("syncCommand: %s\n" % self.sync_command)
+            dirsync_loop()
+
+            if wait is not True:
+                return
+
+            for msg in notify_handle:
+                if not isinstance(msg, ldb.Message):
+                    self.outf.write("referral: %s\n" % msg)
+                    continue
+                created = msg.get("uSNCreated")[0]
+                changed = msg.get("uSNChanged")[0]
+                log_msg("# Notify %s uSNCreated[%s] uSNChanged[%s]\n" %
+                        (msg.dn, created, changed))
+
+                dirsync_loop()
+
+            res = notify_handle.result()
+
+        def daemonize():
+            self.samdb = None
+            self.cache = None
+            orig_pid = os.getpid()
+            pid = os.fork()
+            if pid == 0:
+                os.setsid()
+                pid = os.fork()
+                if pid == 0:  # Actual daemon
+                    pid = os.getpid()
+                    log_msg("Daemonized as pid %d (from %d)\n" % (pid, orig_pid))
+                    load_cache()
+                    return
+            os._exit(0)
+
+        if cache_ldb_initialize:
+            self.samdb_url = H
+            self.samdb = self.connect_system_samdb(url=self.samdb_url,
+                                                   verbose=True)
+            load_cache()
+            return
+
+        if logfile is not None:
+            import resource      # Resource usage information.
+            maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
+            if maxfd == resource.RLIM_INFINITY:
+                maxfd = 1024  # Rough guess at maximum number of open file descriptors.
+            logfd = os.open(logfile, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
+            self.outf.write("Using logfile[%s]\n" % logfile)
+            for fd in range(0, maxfd):
+                if fd == logfd:
+                    continue
+                try:
+                    os.close(fd)
+                except OSError:
+                    pass
+            os.dup2(logfd, 0)
+            os.dup2(logfd, 1)
+            os.dup2(logfd, 2)
+            os.close(logfd)
+            log_msg("Attached to logfile[%s]\n" % (logfile))
+            self.logfile = logfile
+
+        load_cache()
+        conflict = check_current_pid_conflict(terminate)
+        if terminate:
+            if self.current_pid is None:
+                log_msg("No process running.\n")
+                return
+            if not conflict:
+                log_msg("Process %d is not running anymore.\n" % (
+                        self.current_pid))
+                update_pid(None)
+                return
+            log_msg("Sending SIGTERM to process %d.\n" % (
+                    self.current_pid))
+            os.kill(self.current_pid, signal.SIGTERM)
+            return
+        if conflict:
+            raise CommandError("Exiting pid %d, command is already running as pid %d" % (
+                               os.getpid(), self.current_pid))
+
+        if daemon is True:
+            daemonize()
+        update_pid(os.getpid())
+
+        wait = True
+        while wait is True:
+            retry_sleep_min = 1
+            retry_sleep_max = 600
+            if nowait is True:
+                wait = False
+                retry_sleep = 0
+            else:
+                retry_sleep = retry_sleep_min
+
+            while self.samdb is None:
+                if retry_sleep != 0:
+                    log_msg("Wait before connect - sleep(%d)\n" % retry_sleep)
+                    time.sleep(retry_sleep)
+                retry_sleep = retry_sleep * 2
+                if retry_sleep >= retry_sleep_max:
+                    retry_sleep = retry_sleep_max
+                log_msg("Connecting to '%s'\n" % self.samdb_url)
+                try:
+                    self.samdb = self.connect_system_samdb(url=self.samdb_url)
+                except Exception as msg:
+                    self.samdb = None
+                    log_msg("Connect to samdb Exception => (%s)\n" % msg)
+                    if wait is not True:
+                        raise
+
+            try:
+                sync_loop(wait)
+            except ldb.LdbError as e7:
+                (enum, estr) = e7.args
+                self.samdb = None
+                log_msg("ldb.LdbError(%d) => (%s)\n" % (enum, estr))
+
+        update_pid(None)
+        return