#!/usr/bin/python3
#
# Univention Samba Machine Password Rotation Script
#
# Like what you see? Join us!
# https://www.univention.com/about-us/careers/vacancies/
#
# Copyright 2013-2023 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.

from __future__ import print_function

import struct
import sys
import os
import time
import traceback
import subprocess
from datetime import datetime, timedelta

import tdb

from univention.config_registry import ConfigRegistry
import univention.lib.admember


def write_machine_secret_to_secrets_tdb(machine_password, windows_domain):
	machine_password_key = b'SECRETS/MACHINE_PASSWORD/%s' % windows_domain.encode('UTF-8')
	previous_machine_password_key = b'SECRETS/MACHINE_PASSWORD.PREV/%s' % windows_domain.encode('UTF-8')
	machine_last_change_key = b'SECRETS/MACHINE_LAST_CHANGE_TIME/%s' % windows_domain.encode('UTF-8')

	if os.path.exists('/var/lib/samba/private/secrets.tdb'):
		secrets_tdb_filename = '/var/lib/samba/private/secrets.tdb'
	else:
		secrets_tdb_filename = '/var/lib/samba/secrets.tdb'

	secrets_tdb = tdb.open(secrets_tdb_filename)
	secrets_tdb.transaction_start()
	try:
		previous_machine_password = secrets_tdb.get(machine_password_key)
		secrets_tdb.store(previous_machine_password_key, previous_machine_password)

		secrets_tdb.store(machine_password_key, b"%s\0" % (machine_password.encode('UTF-8'),))

		seconds_since_epoch = int(time.mktime(time.localtime()))
		seconds_since_epoch_uint32 = struct.pack("<L", seconds_since_epoch)
		assert len(seconds_since_epoch_uint32) == 4
		secrets_tdb.store(machine_last_change_key, seconds_since_epoch_uint32)

		secrets_tdb.transaction_commit()
	except BaseException:
		secrets_tdb.transaction_cancel()
		raise
	finally:
		secrets_tdb.close()


def run_postchange():
	windows_domain = ucr.get('windows/domain', '').upper()
	with open('/etc/machine.secret', 'r') as fd:
		machine_password = fd.read().strip()
	ldap_hostdn = ucr.get('ldap/hostdn')
	samba_user = ucr.get('samba/user')
	samba_role = ucr.get('samba/role')
	server_role = ucr.get('server/role')
	ldap_base = ucr.get('ldap/base')

	if not windows_domain:
		sys.stdout.write("ERROR: windows/domain is not set!\n")
		return 1

	# change password on ad in member mode
	if samba_role == 'memberserver' and univention.lib.admember.is_localhost_in_admember_mode(ucr=ucr):
		cmd = ['/usr/bin/net', 'ads', 'password', '-P']
		cmd.append('%s$' % ucr.get('hostname', '').upper())
		cmd.append(machine_password)
		process = subprocess.Popen(cmd)
		process.wait()

	# store machine.secret in secrets.tdb
	try:
		write_machine_secret_to_secrets_tdb(machine_password, windows_domain)
	except BaseException:
		sys.stdout.write(traceback.format_exc())
		sys.stdout.flush()
		return 1
	else:
		sys.stdout.write("machine password stored successfully in secrets.tdb\n")
		sys.stdout.flush()

	idmap_domains = ['*']
	samba_idmap_domains = ucr.get('samba/idmap/domains')
	if samba_idmap_domains:
		idmap_domains.extend(samba_idmap_domains)

	if (server_role == 'domaincontroller_slave') or (samba_role == 'memberserver'):  # this is the criterion used in 26univention-samba.inst
		if samba_user == ldap_hostdn:
			with open('/etc/machine.secret', 'r') as fd:
				samba_user_secret = fd.read().strip()

			# store new machine secret as ldap bind password for passdb LDAP
			process = subprocess.Popen(['/usr/bin/smbpasswd', '-w', samba_user_secret])
			process.wait()

			for idmap_domain in idmap_domains:
				# store secret for idmap domain
				sys.stdout.write("setting idmap secret for '%s' from /etc/machine.secret\n" % idmap_domain)
				sys.stdout.flush()
				process = subprocess.Popen(['net', 'idmap', 'set', 'secret', idmap_domain, machine_password])
				process.wait()
		else:
			# don't touch anything, just issue a warning
			sys.stdout.write("WARNING: samba/user is expected to be set to the ldap/hostdn on UCS Replica Directory Nodes and UCS Managed Nodes.\n")
			sys.stdout.write("WARNING: samba/user is '%s' instead, skipping update of idmap secrets.\n" % samba_user)
	else:
		# don't touch anything, just check that things are sane:
		default_samba_user = "cn=admin,%s" % ldap_base
		if samba_user != default_samba_user:
			special_dc_roles = {'domaincontroller_master': 'UCS Primary Directory Node', 'domaincontroller_backup': 'UCS Backup Directory Node'}
			if server_role in special_dc_roles:
				sys.stdout.write("WARNING: samba/user is expected to be set to the %s on a %s.\n" % (default_samba_user, special_dc_roles[server_role]))
				sys.stdout.write("WARNING: samba/user is '%s' instead. Anyway, this is just a warning.\n" % samba_user)
			else:
				sys.stdout.write("WARNING: unexpected server role %s.\n" % server_role)

	for service in ('samba', 'winbind'):
		# restart services
		initscript = '/etc/init.d/%s' % service
		if os.path.isfile(initscript) and os.access(initscript, os.X_OK):
			if service == 'winbind':
				time.sleep(3)
			process = subprocess.Popen([initscript, 'restart'])
			process.wait()

	return 0


def run_prechange():
	windows_domain = ucr.get('windows/domain', '').upper()
	samba_role = ucr.get('samba/role')

	if not windows_domain:
		sys.stdout.write("ERROR: windows/domain is not set!\n")
		return 1

	# check time diff with ad server
	if samba_role == 'memberserver' and univention.lib.admember.is_localhost_in_admember_mode(ucr=ucr):

		TIME_FORMAT = "%a %b %d %H:%M:%S %Z %Y"
		tolerance = 180
		info = univention.lib.admember.lookup_adds_dc()
		if 'DC IP' not in info:
			sys.stderr.write('could not find DC IP\n')
			return 1

		env = os.environ.copy()
		env["LC_ALL"] = "C"
		p1 = subprocess.Popen(['rdate', '-p', '-n', info['DC IP']], close_fds=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
		stdout, stderr = p1.communicate()

		if p1.returncode:
			sys.stderr.write('could not check time in AD DC (%s) - %s\n' % (info['DC IP'], stderr.decode('UTF-8', 'replace')))
			return 1

		local_datetime = datetime.today()
		remote_datetime = datetime.strptime(stdout.decode('UTF-8', 'replace').strip(), TIME_FORMAT)
		delta_t = local_datetime - remote_datetime

		if abs(delta_t) > timedelta(0, tolerance):
			sys.stderr.write('Time difference with AD server (%s) is too big. Can not change password via net ads.\n' % (info['DC IP'],))
			return 1
	return 0


if __name__ == '__main__':
	ucr = ConfigRegistry()
	ucr.load()

	if len(sys.argv) != 2:
		print("%s [prechange|nochange|postchange]" % sys.argv[0])
	else:
		if sys.argv[1] == "postchange":
			rc = run_postchange()
			sys.exit(rc)
		elif sys.argv[1] == "prechange":
			rc = run_prechange()
			sys.exit(rc)
		else:
			sys.exit(0)
