mirror of
https://github.com/samba-team/samba.git
synced 2025-07-29 15:42:04 +03:00
While passwords are not normally available for read, Group Managed Service Account passwords are, as this is how they are distributed. Signed-off-by: Andrew Bartlett <abartlet@samba.org> Reviewed-by: Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
879 lines
38 KiB
Python
879 lines
38 KiB
Python
# 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
|
|
|
|
"""
|
|
|
|
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_for_passwords(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_for_passwords(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
|